Let the tests run while the agent keeps typing: a background hook that interrupts only when something breaks
A PostToolUse hook with asyncRewake runs your suite after each edit without blocking the agent, stays silent on a pass and wakes Claude Code with a trimmed failure report on a fail. Fifteen minutes to set up.
Part of the hooks reading guide

The agent edits a file, then edits another, then starts on a third. Somewhere in that sequence the first edit broke a test. Nobody finds out until the agent finishes its turn and runs the suite itself, if it remembers to. By then it has built two more changes on top of the broken one.
The usual fix is a PostToolUse hook that runs the tests after every edit. That works, and it makes every edit wait for the suite. On a project where the tests take 40 seconds, the agent spends most of its day standing still.
Claude Code has a middle path that few setups use. A command hook can set asyncRewake: true, which runs it in the background and does nothing visible unless the script exits with code 2. On exit 2 the hook’s stderr is delivered to Claude as a system reminder, and the agent reacts to it. Green runs cost the agent nothing, because they say nothing. Red runs arrive as an interruption carrying the failure.
This Playbook builds that hook with the two details that make it usable: a debounce, so a burst of edits yields one run, and a lock, so two suites never overlap. It needs Claude Code, jq, flock and a test command. The example uses Vitest. It takes about fifteen minutes.
What the flag does
Claude Code’s hooks reference documents three behaviours for background command hooks. They differ in ways that matter.
A plain hook blocks the tool loop until it finishes. async: true runs it in the background and delivers its JSON output on the next conversation turn, so an idle session sits on the result until the next prompt. asyncRewake: true runs it in the background too, but an exit code of 2 wakes Claude straight away, even when the session is idle, and passes along stderr (or stdout, if stderr is empty).
Two more facts from the same page shape the script. Claude Code still enforces timeout on an asyncRewake hook, unlike plain async ones, so a hung test run gets killed instead of living forever. And each execution is a separate background process, which is the reason the script needs its own guard against overlap. Nothing in Claude Code serialises the runs for you.
The outcome of the hook is also worth knowing. Exit 0 is silent and exit 2 is the interruption, so the script below keeps to those two.
The hook script
Save this as .claude/hooks/test-in-background.sh in the project and run chmod +x on it.
#!/usr/bin/env bash
# PostToolUse hook, run with asyncRewake. Silent on pass, exit 2 with a trimmed report on fail.
set -u
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in
*/src/*.ts|*/test/*.ts) ;;
*) exit 0 ;;
esac
mkdir -p .claude/.state
stamp=$(date +%s%N)
echo "$stamp" > .claude/.state/latest-edit
# Debounce: a burst of edits should produce one run, owned by the last edit.
sleep 2
[ "$(cat .claude/.state/latest-edit)" = "$stamp" ] || exit 0
# Never run two suites at once.
exec 9> .claude/.state/lock
flock 9
# An edit that landed while waiting for the lock makes this run stale.
[ "$(cat .claude/.state/latest-edit)" = "$stamp" ] || exit 0
out=$(npx vitest run --reporter=dot 2>&1)
status=$?
[ "$status" -eq 0 ] && exit 0
{
echo "Background test run failed after the edit to ${file#"$PWD"/}. Fix the failure before continuing."
echo "$out" | sed 's/\x1b\[[0-9;]*m//g' | grep -v '^$' | tail -n 40
} >&2
exit 2
Going through it in order. The hook receives the event JSON on stdin, and for Write and Edit calls tool_input.file_path is always an absolute path. The case filter drops everything that isn’t TypeScript under src/ or test/, so editing a README doesn’t start a test run.
The stamp is a nanosecond timestamp written to a state file. After a two-second sleep the script reads the file back. If a later edit has overwritten it, this run exits quietly and leaves the work to the newer hook process. That’s the debounce: five edits in a row start five processes, and four of them give up.
The flock on file descriptor 9 handles the case where the suite is already running from an earlier edit. The second check after the lock matters because waiting for the lock can take as long as a full test run, and an edit may have landed in the meantime. A run that starts on code the agent has already changed again is wasted, and its failure report may describe a state that no longer exists.
The report goes to stderr, because that’s what Claude sees on exit 2. It’s capped at 40 lines with ANSI colour codes stripped. An uncapped report from a large suite can dump hundreds of lines into the agent’s context for a single failing assertion, so cap it. If your runner supports a more compact failure reporter, use it.
Wire it into settings
Put this in .claude/settings.json so the whole team gets it:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/test-in-background.sh",
"asyncRewake": true,
"timeout": 120
}
]
}
]
}
}
Add .claude/.state to .gitignore. The timeout of 120 seconds covers the debounce, the lock wait and the run itself, so size it to your slowest realistic suite plus a margin. When it expires, Claude Code stops waiting on the hook. A suite that regularly hits the timeout is a suite this hook can’t help.
Watch it fail
The demo project is two files: a totalPence function that sums unitPence * qty over cart lines, and a Vitest file with two tests (a two-line cart that should total 600, and an empty cart that should total 0). With the hook in place, break the function by hand and feed the script the event it would get:
sed -i 's/unitPence \* l.qty/unitPence + l.qty/' src/cart.ts
echo '{"tool_input":{"file_path":"'$PWD'/src/cart.ts"}}' | .claude/hooks/test-in-background.sh; echo "exit=$?"
Run it and it prints the report from stderr, then the exit code. The test output is trimmed here at the lower edge:
Background test run failed after the edit to src/cart.ts. Fix the failure before continuing.
RUN v5.0.5 /tmp/claude-0/demo
x·
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/cart.test.ts > totalPence > sums unit price times quantity
AssertionError: expected 353 to be 600 // Object.is equality
- Expected
+ Received
- 600
+ 353
❯ src/cart.test.ts:6:102
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)
exit=2
With the function restored, the same command prints nothing and exits 0. That silence is the point. The agent never hears about green runs.
Watch the agent react
The script alone proves little, so the next test uses a real session. A headless run is enough, because claude -p fires the same hooks. The prompt asks for a change that will break an existing test, forbids touching the tests, and then gives the agent several more steps so the turn is still in progress when the background run finishes:
claude -p "In src/cart.ts, change totalPence so that any line with unitPence under 200 is treated as a free sample and adds nothing to the total. Do not edit any test file and do not run any commands. After the edit, write a 300-word explanation of the change for a changelog, then read each of package.json, src/cart.ts and src/cart.test.ts in turn and summarise each in two sentences." \
--permission-mode acceptEdits
The stream shows the Edit call, the hook starting, the Read calls that follow and then the hook finishing with exit code 2 and the report on stderr. The agent’s answer opens like this:
The edit is in, but the hook's test run fails, and the cause is the new behaviour, not a bug in the edit. The existing test "sums unit price times quantity" includes a 100p line, which is now a free sample. The total is therefore 500 instead of the 600 the test expects. You told me not to edit test files, so I've left it.
Two things stand out. The agent never ran the suite: the prompt forbade commands, and the failure came entirely from the hook. And it read the failure correctly. It separated a bug from a deliberate behaviour change, which is the call a human would otherwise have to make from a red terminal.
The same exercise with a change that keeps the tests green (skip lines with a quantity of zero or less) produces no message at all. The hook’s log shows one run starting and finishing with status 0, and the agent’s answer never mentions it.
One side effect is useful to know. Under --permission-mode acceptEdits, an agent asked to run npx vitest run itself is refused, in the foreground and the background, and ends by saying it couldn’t verify its work. The hook ran the suite anyway, because it runs with your privileges and doesn’t go through the agent’s permission prompts. That’s convenient here and a security point further down.
Check the debounce
Three edit events 0.5 seconds apart, fired against a broken function, show the debounce and lock working:
edit 1 exit=0
edit 2 exit=0
edit 3 exit=2
The log has a single entry, 07:06:48 run start followed by 07:06:50 run end status=1, and only the third process wrote a report to stderr. Without the stamp, every edit would have started its own suite. Without the lock, those suites would have run at the same time.
Make it yours
The structure carries over to other stacks. Swap the runner line and the path filter.
For a .NET solution, filter on *.cs and run dotnet test --nologo --verbosity quiet. This variation wasn’t run, since the sandbox has no .NET SDK, so check how your test output looks before capping it with tail. .NET builds are slower than Vitest runs, so put the build and the test in one command and raise the timeout and the debounce sleep to match.
For a monorepo, run only the affected package. Derive the package directory from file_path and cd into it before invoking the runner, or use your runner’s changed-files mode. Running the whole repo’s suite on every edit is the fastest way to teach yourself to disable the hook.
For a type check instead of tests, the same hook works with tsc --noEmit. An existing Tokenise piece on a type-error monitor plugin
covers a different approach to the same signal, a monitor that reports only new errors.
To keep the reports short, add a second filter that drops passing-test noise. Dot reporters already do most of the work. If your suite prints stack traces for expected failures, trim them in the sed step.
If you also want a gate at the end of a turn, pair this hook with a Stop hook that runs the suite synchronously and blocks completion on failure. The background hook catches mistakes early. The Stop hook makes sure a red suite never counts as done. The two don’t overlap in purpose, and the Stop hook piece
shows how to build the second one.
What goes wrong
Headless runs cut it short. The docs say that in claude -p, Claude Code kills any async hook still running at teardown and records the outcome as cancelled. A short headless task that ends before the suite finishes gets no report. The debounce makes this likelier, because the run can’t start until two seconds after the last edit. For scripted use, keep the synchronous Stop hook or run the suite as an explicit last step.
Idle sessions are the other documented case. The docs say an asyncRewake hook that exits with 2 wakes Claude even when the session is idle. That behaviour wasn’t exercised here, since the test sessions were all mid-turn, so treat it as the documented behaviour and confirm it in your own terminal before relying on it.
A flaky suite turns this into a source of noise. Every flaky failure becomes an interruption that the agent will take seriously and may try to fix by changing code that was never wrong. Fix or quarantine flaky tests before wiring this in, or the agent will spend its turns chasing them.
Slow suites hurt too. If the run takes longer than a few edits take, reports arrive about code the agent has already moved past, and the stale-run check discards only the runs that haven’t started yet. A run already in flight still reports. In practice that means one slightly out-of-date message, and the next edit’s run corrects it.
The hook executes repository code with your permissions on every edit. In the demo that’s npx vitest run, but the script is yours to read, and a test file written by the agent is code that runs outside its permission prompts. In a repo where you wouldn’t normally let an agent execute its own tests unattended, don’t add a hook that does it for you. A sandboxed test command, or a runner invoked inside a container, closes the gap.
Don’t bother if your suite takes under three seconds and the agent already runs it itself. The hook adds moving parts to save a few seconds.
First fifteen minutes
Copy the script into .claude/hooks/, make it executable and add the settings block. Edit .gitignore. Break a function by hand, pipe the event JSON into the script as shown above and confirm you see exit 2 and a report of about 20 lines. Then restore the function and confirm you get silence and exit 0.
Start a session, ask for a small change that you know will break a test, and watch for the agent mentioning a background test failure before you do. Once that works, tune the path filter and the debounce to your suite.