From ecac7daa18fdb50a716da93d7103a3a1f16d1d86 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 8 Oct 2026 17:37:56 -0400 Subject: [PATCH 1/2] Report pending background work on the Stop hook Claude Code fires Stop at the end of every assistant turn, including turns that end while background tasks (shell, subagent, monitor) or session crons are still pending and will wake the agent again. Forward the counts of both on the `stop` event so Warp can tell a finished session from one that is paused waiting on background work, instead of treating every turn boundary as completion. Bump the warp plugin to 2.4.0. Co-Authored-By: Warp --- .claude-plugin/marketplace.json | 2 +- README.md | 2 +- plugins/warp/.claude-plugin/plugin.json | 2 +- plugins/warp/scripts/build-payload.sh | 11 +++++++++++ plugins/warp/scripts/on-stop.sh | 9 ++++++++- plugins/warp/tests/test-hooks.sh | 17 ++++++++++++++++- 6 files changed, 38 insertions(+), 5 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2e2bcc2..ffd8069 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "warp", "description": "Native Warp notifications when Claude completes tasks or needs input", "source": "./plugins/warp", - "version": "2.3.0", + "version": "2.4.0", "category": "productivity", "tags": ["notifications", "terminal", "warp"] }, diff --git a/README.md b/README.md index 9bd0c27..fa0930a 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 lifecycle hooks including: - **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 that also reports how many background tasks and scheduled wakeups are still pending, so Warp can tell a finished session from one waiting on background work - **Notification** (`idle_prompt`, `agent_needs_input`) — fires when Claude is waiting for user 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/.claude-plugin/plugin.json b/plugins/warp/.claude-plugin/plugin.json index 219e43a..dd8f2ba 100644 --- a/plugins/warp/.claude-plugin/plugin.json +++ b/plugins/warp/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "warp", "description": "Warp terminal integration for Claude Code - native notifications, and more to come", - "version": "2.3.0", + "version": "2.4.0", "author": { "name": "Warp", "url": "https://warp.dev" diff --git a/plugins/warp/scripts/build-payload.sh b/plugins/warp/scripts/build-payload.sh index 9ad610e..3554316 100644 --- a/plugins/warp/scripts/build-payload.sh +++ b/plugins/warp/scripts/build-payload.sh @@ -27,6 +27,17 @@ negotiate_protocol_version() { fi } +# Number of entries in a top-level array field of the hook input. Prints 0 when the field is +# absent or not an array, so callers can pass the result straight to `--argjson`. +hook_input_array_length() { + local input="$1" + local field="$2" + local length + length=$(echo "$input" | jq -r --arg field "$field" \ + 'if (.[$field] | type) == "array" then (.[$field] | length) else 0 end' 2>/dev/null) + echo "${length:-0}" +} + build_payload() { local input="$1" local event="$2" diff --git a/plugins/warp/scripts/on-stop.sh b/plugins/warp/scripts/on-stop.sh index 4163bb9..465fcfc 100755 --- a/plugins/warp/scripts/on-stop.sh +++ b/plugins/warp/scripts/on-stop.sh @@ -65,9 +65,16 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then fi fi +# Claude Code wakes the agent again when a background task finishes or a session cron fires, +# so a Stop with either pending is a pause rather than the end of the session. +BACKGROUND_TASK_COUNT=$(hook_input_array_length "$INPUT" background_tasks) +SESSION_CRON_COUNT=$(hook_input_array_length "$INPUT" session_crons) + BODY=$(build_payload "$INPUT" "stop" \ --arg query "$QUERY" \ --arg response "$RESPONSE" \ - --arg transcript_path "$TRANSCRIPT_PATH") + --arg transcript_path "$TRANSCRIPT_PATH" \ + --argjson background_task_count "$BACKGROUND_TASK_COUNT" \ + --argjson session_cron_count "$SESSION_CRON_COUNT") "$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh index 2dc273a..dd5a6b1 100755 --- a/plugins/warp/tests/test-hooks.sh +++ b/plugins/warp/tests/test-hooks.sh @@ -56,7 +56,7 @@ MARKETPLACE_VERSION=$(jq -r '.plugins[] | select(.name == "warp") | .version' \ "$REPO_DIR/.claude-plugin/marketplace.json") NOTIFICATION_MATCHER=$(jq -r '.hooks.Notification[0].matcher' "$PLUGIN_DIR/hooks/hooks.json") -assert_eq "plugin version bumped" "2.3.0" "$PLUGIN_VERSION" +assert_eq "plugin version bumped" "2.4.0" "$PLUGIN_VERSION" assert_eq "marketplace version matches plugin" "$PLUGIN_VERSION" "$MARKETPLACE_VERSION" assert_eq "notification matcher includes input-required events" \ "idle_prompt|agent_needs_input" \ @@ -102,6 +102,21 @@ assert_json_field "query present" "$PAYLOAD" ".query" "write a haiku" assert_json_field "response present" "$PAYLOAD" ".response" "Memory is safe, the borrow checker stands guard" assert_json_field "transcript_path present" "$PAYLOAD" ".transcript_path" "/tmp/transcript.jsonl" +echo "" +echo "--- Stop event pending background work ---" +STOP_INPUT='{"session_id":"s1","cwd":"/tmp/proj","background_tasks":[{"id":"t1","type":"shell","status":"running"},{"id":"t2","type":"subagent","status":"running"}],"session_crons":[{"id":"c1","schedule":"* * * * *"}]}' +assert_eq "background_tasks counted" "2" "$(hook_input_array_length "$STOP_INPUT" background_tasks)" +assert_eq "session_crons counted" "1" "$(hook_input_array_length "$STOP_INPUT" session_crons)" +assert_eq "missing array counts as 0" "0" "$(hook_input_array_length '{"session_id":"s1"}' background_tasks)" +assert_eq "non-array field counts as 0" "0" "$(hook_input_array_length '{"background_tasks":"nope"}' background_tasks)" +assert_eq "invalid input counts as 0" "0" "$(hook_input_array_length 'not json' background_tasks)" +PAYLOAD=$(build_payload "$STOP_INPUT" "stop" \ + --argjson background_task_count "$(hook_input_array_length "$STOP_INPUT" background_tasks)" \ + --argjson session_cron_count "$(hook_input_array_length "$STOP_INPUT" session_crons)") +assert_json_field "background_task_count is numeric" "$PAYLOAD" ".background_task_count | type" "number" +assert_json_field "background_task_count merged" "$PAYLOAD" ".background_task_count" "2" +assert_json_field "session_cron_count merged" "$PAYLOAD" ".session_cron_count" "1" + echo "" echo "--- Permission request event ---" PAYLOAD=$(build_payload '{"session_id":"s1","cwd":"/tmp/proj"}' "permission_request" \ From b56adf9f1dbe49c29bfa651eb6909f115d6193f9 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 8 Oct 2026 17:41:58 -0400 Subject: [PATCH 2/2] Inline the pending-work counts in on-stop.sh Co-Authored-By: Warp --- plugins/warp/scripts/build-payload.sh | 11 ----------- plugins/warp/scripts/on-stop.sh | 11 ++++++----- plugins/warp/tests/test-hooks.sh | 10 +++------- 3 files changed, 9 insertions(+), 23 deletions(-) diff --git a/plugins/warp/scripts/build-payload.sh b/plugins/warp/scripts/build-payload.sh index 3554316..9ad610e 100644 --- a/plugins/warp/scripts/build-payload.sh +++ b/plugins/warp/scripts/build-payload.sh @@ -27,17 +27,6 @@ negotiate_protocol_version() { fi } -# Number of entries in a top-level array field of the hook input. Prints 0 when the field is -# absent or not an array, so callers can pass the result straight to `--argjson`. -hook_input_array_length() { - local input="$1" - local field="$2" - local length - length=$(echo "$input" | jq -r --arg field "$field" \ - 'if (.[$field] | type) == "array" then (.[$field] | length) else 0 end' 2>/dev/null) - echo "${length:-0}" -} - build_payload() { local input="$1" local event="$2" diff --git a/plugins/warp/scripts/on-stop.sh b/plugins/warp/scripts/on-stop.sh index 465fcfc..34e06f7 100755 --- a/plugins/warp/scripts/on-stop.sh +++ b/plugins/warp/scripts/on-stop.sh @@ -66,15 +66,16 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then fi # Claude Code wakes the agent again when a background task finishes or a session cron fires, -# so a Stop with either pending is a pause rather than the end of the session. -BACKGROUND_TASK_COUNT=$(hook_input_array_length "$INPUT" background_tasks) -SESSION_CRON_COUNT=$(hook_input_array_length "$INPUT" session_crons) +# so a Stop with either pending is a pause rather than the end of the session. Default to 0 +# so --argjson always receives a number and the notification is never dropped. +BACKGROUND_TASK_COUNT=$(echo "$INPUT" | jq -r '.background_tasks // [] | length' 2>/dev/null) +SESSION_CRON_COUNT=$(echo "$INPUT" | jq -r '.session_crons // [] | length' 2>/dev/null) BODY=$(build_payload "$INPUT" "stop" \ --arg query "$QUERY" \ --arg response "$RESPONSE" \ --arg transcript_path "$TRANSCRIPT_PATH" \ - --argjson background_task_count "$BACKGROUND_TASK_COUNT" \ - --argjson session_cron_count "$SESSION_CRON_COUNT") + --argjson background_task_count "${BACKGROUND_TASK_COUNT:-0}" \ + --argjson session_cron_count "${SESSION_CRON_COUNT:-0}") "$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh index dd5a6b1..e2d3ea7 100755 --- a/plugins/warp/tests/test-hooks.sh +++ b/plugins/warp/tests/test-hooks.sh @@ -105,17 +105,13 @@ assert_json_field "transcript_path present" "$PAYLOAD" ".transcript_path" "/tmp/ echo "" echo "--- Stop event pending background work ---" STOP_INPUT='{"session_id":"s1","cwd":"/tmp/proj","background_tasks":[{"id":"t1","type":"shell","status":"running"},{"id":"t2","type":"subagent","status":"running"}],"session_crons":[{"id":"c1","schedule":"* * * * *"}]}' -assert_eq "background_tasks counted" "2" "$(hook_input_array_length "$STOP_INPUT" background_tasks)" -assert_eq "session_crons counted" "1" "$(hook_input_array_length "$STOP_INPUT" session_crons)" -assert_eq "missing array counts as 0" "0" "$(hook_input_array_length '{"session_id":"s1"}' background_tasks)" -assert_eq "non-array field counts as 0" "0" "$(hook_input_array_length '{"background_tasks":"nope"}' background_tasks)" -assert_eq "invalid input counts as 0" "0" "$(hook_input_array_length 'not json' background_tasks)" PAYLOAD=$(build_payload "$STOP_INPUT" "stop" \ - --argjson background_task_count "$(hook_input_array_length "$STOP_INPUT" background_tasks)" \ - --argjson session_cron_count "$(hook_input_array_length "$STOP_INPUT" session_crons)") + --argjson background_task_count "$(echo "$STOP_INPUT" | jq -r '.background_tasks // [] | length')" \ + --argjson session_cron_count "$(echo "$STOP_INPUT" | jq -r '.session_crons // [] | length')") assert_json_field "background_task_count is numeric" "$PAYLOAD" ".background_task_count | type" "number" assert_json_field "background_task_count merged" "$PAYLOAD" ".background_task_count" "2" assert_json_field "session_cron_count merged" "$PAYLOAD" ".session_cron_count" "1" +assert_eq "missing arrays count as 0" "0" "$(echo '{"session_id":"s1"}' | jq -r '.background_tasks // [] | length')" echo "" echo "--- Permission request event ---"