From 8a3b7b84ebb7d8ffb06247f72508444199581e0b Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:00:24 +0200 Subject: [PATCH] Stop the Stop hook from loading the whole transcript into memory every turn on-stop.sh ran `jq -s` over the whole transcript twice per turn end to pick the last prompt and reply, so memory grew with the session (1.2 GB for a 448 MB transcript). Read a bounded tail instead: the last 4 MiB, retrying with 32 MiB only when no prompt is in the first window. The legacy hook streams its first prompt and stops at the first match. Fixes #90 --- .claude-plugin/marketplace.json | 2 +- README.md | 2 +- plugins/warp/.claude-plugin/plugin.json | 2 +- plugins/warp/scripts/legacy/on-stop.sh | 15 ++++--- plugins/warp/scripts/on-stop.sh | 20 +++++++--- plugins/warp/scripts/transcript-tail.sh | 33 +++++++++++++++ plugins/warp/tests/test-hooks.sh | 53 ++++++++++++++++++++++++- 7 files changed, 112 insertions(+), 15 deletions(-) create mode 100755 plugins/warp/scripts/transcript-tail.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ffd8069..a40b1a7 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.4.0", + "version": "2.4.1", "category": "productivity", "tags": ["notifications", "terminal", "warp"] }, diff --git a/README.md b/README.md index fa0930a..fd26eb5 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 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 +- **Stop** — reads the end of 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 dd8f2ba..a4fa554 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.4.0", + "version": "2.4.1", "author": { "name": "Warp", "url": "https://warp.dev" diff --git a/plugins/warp/scripts/legacy/on-stop.sh b/plugins/warp/scripts/legacy/on-stop.sh index 2a45dd9..336bd38 100755 --- a/plugins/warp/scripts/legacy/on-stop.sh +++ b/plugins/warp/scripts/legacy/on-stop.sh @@ -3,6 +3,7 @@ # Sends a Warp notification when Claude completes a task SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../transcript-tail.sh" # Read hook input from stdin INPUT=$(cat) @@ -15,16 +16,18 @@ MSG="Task completed" # Try to extract prompt and response from the transcript (JSONL format) if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then - # Get the first user prompt - PROMPT=$(jq -rs ' - [.[] | select(.type == "user")] | first | .message.content // empty + # Get the first user prompt. Stream records and stop at the first match, + # so memory stays at one record however long the transcript is. + PROMPT=$(jq -rn ' + first(inputs | select(.type == "user")) | .message.content // empty ' "$TRANSCRIPT_PATH" 2>/dev/null) - # Get the last assistant response - RESPONSE=$(jq -rs ' + # Get the last assistant response from a bounded tail of the transcript + RESPONSE_FILTER=' [.[] | select(.type == "assistant" and .message.content)] | last | [.message.content[] | select(.type == "text") | .text] | join(" ") - ' "$TRANSCRIPT_PATH" 2>/dev/null) + ' + RESPONSE=$(transcript_tail "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_BYTES" | jq -rs "$RESPONSE_FILTER" 2>/dev/null) if [ -n "$PROMPT" ] && [ -n "$RESPONSE" ]; then # Truncate prompt to 50 chars diff --git a/plugins/warp/scripts/on-stop.sh b/plugins/warp/scripts/on-stop.sh index 34e06f7..f4db998 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/transcript-tail.sh" # Read hook input from stdin INPUT=$(cat) @@ -36,7 +37,10 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then # containing {type:"text"} blocks. Tool-result messages have content arrays # containing only {type:"tool_result"} blocks. We filter to messages that # have at least one "text" block (or are a plain string). - QUERY=$(jq -rs ' + # + # Both values sit near the end, so read a bounded tail instead of the whole + # transcript, and retry with a larger one only when no prompt is in the first. + QUERY_FILTER=' [ .[] | select(.type == "user") | if .message.content | type == "string" then . @@ -48,13 +52,19 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then then [.message.content[] | select(.type == "text") | .text] | join(" ") else .message.content // empty end - ' "$TRANSCRIPT_PATH" 2>/dev/null) + ' + QUERY=$(transcript_tail "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_BYTES" | jq -rs "$QUERY_FILTER" 2>/dev/null) + if [ -z "$QUERY" ] && transcript_larger_than "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_BYTES"; then + QUERY=$(transcript_tail "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_MAX_BYTES" | jq -rs "$QUERY_FILTER" 2>/dev/null) + fi - # Get the last assistant response - RESPONSE=$(jq -rs ' + # Get the last assistant response. The last assistant record is always at + # the very end, so the first window is enough. + RESPONSE_FILTER=' [.[] | select(.type == "assistant" and .message.content)] | last | [.message.content[] | select(.type == "text") | .text] | join(" ") - ' "$TRANSCRIPT_PATH" 2>/dev/null) + ' + RESPONSE=$(transcript_tail "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_BYTES" | jq -rs "$RESPONSE_FILTER" 2>/dev/null) # Truncate for notification display if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then diff --git a/plugins/warp/scripts/transcript-tail.sh b/plugins/warp/scripts/transcript-tail.sh new file mode 100755 index 0000000..a6a4e8c --- /dev/null +++ b/plugins/warp/scripts/transcript-tail.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Reads only the end of a Claude Code transcript. +# +# Transcripts grow for the whole session and can reach gigabytes. Slurping the +# whole file with `jq -s` costs about three times its size in memory at every +# turn end, while the last prompt and response sit within the final few +# hundred KB. Reading a bounded tail keeps the Stop hook's memory flat. +# +# Usage: +# source "$SCRIPT_DIR/transcript-tail.sh" +# transcript_tail "$TRANSCRIPT_PATH" "$TRANSCRIPT_TAIL_BYTES" | jq -rs '...' + +# Window to read, and the larger one to retry with when the last prompt +# is further back (a long autonomous turn with big tool results). +TRANSCRIPT_TAIL_BYTES=4194304 +TRANSCRIPT_TAIL_MAX_BYTES=33554432 + +# Prints the last $2 bytes of transcript $1, dropping the partial line the cut +# lands in. Transcripts are JSONL, so every remaining line is a whole record. +transcript_tail() { + local path="$1" bytes="$2" + if [ "$(wc -c < "$path")" -gt "$bytes" ]; then + tail -c "$bytes" "$path" | tail -n +2 + else + cat "$path" + fi +} + +# Returns 0 if transcript $1 is larger than $2 bytes, so a bigger window could +# find something the smaller one missed. +transcript_larger_than() { + [ "$(wc -c < "$1")" -gt "$2" ] +} diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh index e2d3ea7..5127a0e 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.4.0" "$PLUGIN_VERSION" +assert_eq "plugin version bumped" "2.4.1" "$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" \ @@ -320,6 +320,57 @@ for HOOK in on-permission-request.sh on-prompt-submit.sh on-post-tool-use.sh; do assert_eq "$HOOK exits 0 without protocol version" "0" "$?" done +echo "" +echo "--- Transcript tail ---" + +source "$SCRIPT_DIR/transcript-tail.sh" +TAIL_TMP=$(mktemp -d) +trap 'rm -rf "$TAIL_TMP"' EXIT + +prompt_line() { jq -nc --arg t "$1" '{type:"user",message:{role:"user",content:$t}}'; } +reply_line() { jq -nc --arg t "$1" '{type:"assistant",message:{role:"assistant",content:[{type:"text",text:$t}]}}'; } +# About 1 MB of tool results, the bulk of a long session's transcript. +FILLER=$(jq -nc --arg c "$(head -c 1000 /dev/zero | tr '\0' x)" \ + '{type:"user",message:{role:"user",content:[{type:"tool_result",tool_use_id:"t1",content:$c}]}}') +filler_mb() { yes "$FILLER" | head -n $(($1 * 1000)); } + +# Runs on-stop.sh on a transcript and prints the structured payload it emits. +run_stop_hook() { + local seq + seq=$(jq -nc --arg p "$1" '{session_id:"s1",cwd:"/tmp/proj",transcript_path:$p}' \ + | WARP_CLI_AGENT_PROTOCOL_VERSION=1 WARP_CLIENT_VERSION=v0.2026.09.30.08.29.stable_01 \ + CLAUDE_CODE_VERSION=2.1.283 bash "$SCRIPT_DIR/on-stop.sh" 2>/dev/null \ + | jq -r '.terminalSequence // empty' 2>/dev/null) + seq=${seq#*warp://cli-agent;} + printf '%s' "${seq%$'\a'}" +} + +{ prompt_line "first prompt"; reply_line "first reply"; } > "$TAIL_TMP/small.jsonl" +assert_eq "small transcript is read whole" "2" "$(transcript_tail "$TAIL_TMP/small.jsonl" "$TRANSCRIPT_TAIL_BYTES" | wc -l | tr -d ' ')" + +{ prompt_line "old prompt"; filler_mb 6; prompt_line "recent prompt"; reply_line "recent reply"; } > "$TAIL_TMP/large.jsonl" +FIRST_LINE=$(transcript_tail "$TAIL_TMP/large.jsonl" "$TRANSCRIPT_TAIL_BYTES" | head -n 1) +echo "$FIRST_LINE" | jq -e . >/dev/null 2>&1 +assert_eq "tail starts on a whole record" "0" "$?" +TAIL_SIZE=$(transcript_tail "$TAIL_TMP/large.jsonl" "$TRANSCRIPT_TAIL_BYTES" | wc -c | tr -d ' ') +assert_eq "tail stays within the window" "true" "$([ "$TAIL_SIZE" -le "$TRANSCRIPT_TAIL_BYTES" ] && echo true || echo false)" +PAYLOAD=$(run_stop_hook "$TAIL_TMP/large.jsonl") +assert_json_field "stop query from the tail" "$PAYLOAD" ".query" "recent prompt" +assert_json_field "stop response from the tail" "$PAYLOAD" ".response" "recent reply" + +{ prompt_line "far prompt"; filler_mb 10; reply_line "far reply"; } > "$TAIL_TMP/far.jsonl" +assert_json_field "prompt beyond the first window is found" "$(run_stop_hook "$TAIL_TMP/far.jsonl")" ".query" "far prompt" + +# The Stop hook must not hold the whole transcript in memory. Linux enforces +# ulimit -v; skip where it does not (macOS). +if (ulimit -v 131072) 2>/dev/null; then + { filler_mb 100; prompt_line "bounded prompt"; reply_line "bounded reply"; } > "$TAIL_TMP/huge.jsonl" + PAYLOAD=$(ulimit -v 131072; run_stop_hook "$TAIL_TMP/huge.jsonl") + assert_json_field "100 MB transcript fits in 128 MB" "$PAYLOAD" ".query" "bounded prompt" +else + echo " - memory bound check skipped (ulimit -v not enforced here)" +fi + # --- Summary --- echo ""