diff --git a/README.md b/README.md index e767524..777c499 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Payloads include a protocol version negotiated between the plugin and Warp (`min The plugin registers six hooks: - **SessionStart** — emits the plugin version and a welcome system message -- **Stop** — reads the transcript to extract your prompt and Claude's response, then sends a task-complete notification +- **Stop** — reads the transcript to extract your prompt and Claude's response, then sends a task-complete notification. Skipped while background sub-agents or shells are still running (`background_tasks` in the hook input, Claude Code ≥ 2.1.145); the Stop that fires once they finish sends the notification instead - **Notification** (`idle_prompt`) — fires when Claude has been idle and needs your input - **PermissionRequest** — fires when Claude wants to run a tool, includes the tool name and a preview of its input - **UserPromptSubmit** — fires when you submit a prompt, signaling the session is active again diff --git a/plugins/warp/scripts/has-background-work.sh b/plugins/warp/scripts/has-background-work.sh new file mode 100755 index 0000000..118efca --- /dev/null +++ b/plugins/warp/scripts/has-background-work.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Determines whether the Claude Code session still has background work in flight. +# +# Claude Code (>= 2.1.145) includes a `background_tasks` array in Stop hook +# input listing in-flight sub-agents, background shells, monitors, workflows +# and teammates. The main agent's turn ends (firing Stop) while those keep +# running, and the harness re-invokes the main agent — firing Stop again — +# each time one of them completes. Only the Stop with nothing left running is +# a real "task complete". +# +# Usage: +# source "$SCRIPT_DIR/has-background-work.sh" +# if has_background_work "$INPUT"; then +# exit 0 # not done yet — a later Stop will fire when the work finishes +# fi +# +# Returns 0 (true) when at least one background task is still running, +# 1 (false) otherwise. On older Claude Code the field is absent, so this +# returns false and behaviour is unchanged. Only `status == "running"` counts, +# so an unfamiliar status fails open toward notifying rather than staying silent. +has_background_work() { + local input="$1" + local running + running=$(echo "$input" | jq -r ' + [.background_tasks[]? | select(.status == "running")] | length + ' 2>/dev/null) + [ "${running:-0}" -gt 0 ] 2>/dev/null +} diff --git a/plugins/warp/scripts/legacy/on-stop.sh b/plugins/warp/scripts/legacy/on-stop.sh index 2a45dd9..fb4d379 100755 --- a/plugins/warp/scripts/legacy/on-stop.sh +++ b/plugins/warp/scripts/legacy/on-stop.sh @@ -3,10 +3,18 @@ # Sends a Warp notification when Claude completes a task SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../has-background-work.sh" # Read hook input from stdin INPUT=$(cat) +# Skip while background work (sub-agents, background shells, ...) is still +# running — only the final Stop, with nothing left in flight, is a real +# "task complete". See has-background-work.sh. +if has_background_work "$INPUT"; then + exit 0 +fi + # Extract transcript path from the hook input TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) diff --git a/plugins/warp/scripts/on-stop.sh b/plugins/warp/scripts/on-stop.sh index 4163bb9..c9380c0 100755 --- a/plugins/warp/scripts/on-stop.sh +++ b/plugins/warp/scripts/on-stop.sh @@ -12,6 +12,7 @@ if ! should_use_structured; then fi source "$SCRIPT_DIR/build-payload.sh" +source "$SCRIPT_DIR/has-background-work.sh" # Read hook input from stdin INPUT=$(cat) @@ -22,6 +23,14 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi +# Skip while background work (sub-agents, background shells, monitors, ...) is +# still running. Stop fires when the main agent's turn ends even though tasks +# are in flight, and again each time one finishes and wakes the main agent. +# Only the final Stop, with nothing left running, is a real "task complete". +if has_background_work "$INPUT"; then + exit 0 +fi + # Extract the last user prompt and assistant response from the transcript. # Small delay to allow Claude Code to flush the current turn to the transcript file. # The Stop hook fires before the transcript is fully written. diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh index 754bdd0..f40c0e6 100755 --- a/plugins/warp/tests/test-hooks.sh +++ b/plugins/warp/tests/test-hooks.sh @@ -260,6 +260,41 @@ OUTPUT=$(emit_terminal_sequence "test-seq") assert_json_field "new CC outputs terminalSequence" "$OUTPUT" ".terminalSequence" "test-seq" unset CLAUDE_CODE_VERSION +echo "" +echo "=== has-background-work.sh ===" + +source "$SCRIPT_DIR/../scripts/has-background-work.sh" + +echo "" +echo "--- Field absent (older Claude Code) → no background work ---" +has_background_work '{"session_id":"s1"}' +assert_eq "missing background_tasks returns false" "1" "$?" + +echo "" +echo "--- Empty array → no background work ---" +has_background_work '{"background_tasks":[]}' +assert_eq "empty background_tasks returns false" "1" "$?" + +echo "" +echo "--- Running sub-agent → background work ---" +has_background_work '{"background_tasks":[{"id":"a1","type":"subagent","status":"running","description":"Explore repo","agent_type":"Explore"}]}' +assert_eq "running subagent returns true" "0" "$?" + +echo "" +echo "--- Running background shell → background work ---" +has_background_work '{"background_tasks":[{"id":"b1","type":"shell","status":"running","description":"tail logs","command":"tail -f log"}]}' +assert_eq "running shell returns true" "0" "$?" + +echo "" +echo "--- Only non-running entries → no background work ---" +has_background_work '{"background_tasks":[{"id":"a1","type":"subagent","status":"completed"}]}' +assert_eq "completed task returns false" "1" "$?" + +echo "" +echo "--- Malformed input → fail open ---" +has_background_work 'not json' +assert_eq "invalid JSON returns false" "1" "$?" + # --- Routing tests --- # These test the hook scripts as subprocesses to verify routing behavior. # We override /dev/tty writes since they'd fail in CI. @@ -279,6 +314,24 @@ assert_eq "legacy Warp shows active message" \ "🔔 Warp plugin active. You'll receive native Warp notifications when tasks complete or input is needed." \ "$SYS_MSG" +echo "" +echo "--- Stop routing with background tasks ---" + +# Structured Warp + Claude Code that supports terminalSequence, so a fired +# notification shows up on stdout instead of /dev/tty. +STOP_ENV="WARP_CLI_AGENT_PROTOCOL_VERSION=1 WARP_CLIENT_VERSION=v0.2026.04.01.08.00.stable_00 CLAUDE_CODE_VERSION=2.1.145" + +OUTPUT=$(echo '{"session_id":"s1","cwd":"/tmp","background_tasks":[{"id":"a1","type":"subagent","status":"running"}]}' \ + | env $STOP_ENV bash "$HOOK_DIR/on-stop.sh" 2>/dev/null) +assert_eq "on-stop.sh exits 0 while background tasks run" "0" "$?" +assert_eq "on-stop.sh sends nothing while background tasks run" "" "$OUTPUT" + +OUTPUT=$(echo '{"session_id":"s1","cwd":"/tmp","background_tasks":[]}' \ + | env $STOP_ENV bash "$HOOK_DIR/on-stop.sh" 2>/dev/null) +SEQ=$(echo "$OUTPUT" | jq -r '.terminalSequence // empty' 2>/dev/null) +assert_eq "on-stop.sh notifies once background tasks are done" "stop" \ + "$(printf '%s' "$SEQ" | sed -n 's/.*"event":"\([a-z_]*\)".*/\1/p')" + echo "" echo "--- Modern-only hooks exit silently without protocol version ---"