feat(hooks): v2.0.1 — expand claude code hook coverage#27
feat(hooks): v2.0.1 — expand claude code hook coverage#27yigitkonur wants to merge 5 commits intowarpdotdev:mainfrom
Conversation
…e directly Claude Code's Stop hook input now exposes `last_assistant_message` in the JSON payload itself (see https://docs.anthropic.com/en/docs/claude-code/hooks#stop-input). The previous 0.3s sleep plus JSONL transcript tree-walk was guarding against a race that no longer exists — the field is populated before the hook fires. The user's prompt is recovered from a session-scoped temp file written by on-prompt-submit.sh during UserPromptSubmit, which is more reliable than re-parsing the transcript to find the last "user" message that's not a tool-result block. Net effect: on-stop.sh goes from ~40 lines of fragile jq parsing plus a blocking sleep to a direct stdin read. Combined with `async: true` (added in a follow-up commit), the hook becomes non-blocking.
…r context
Extends claude-code-warp from 6 hooks to 16, covering every Claude Code hook
event that has sidebar-relevant meaning. Every handler has a 5s timeout;
Stop, StopFailure, and SubagentStop are async so the session response
isn't blocked on tty writes.
New hooks and the Warp events they emit:
SessionEnd -> session_end (cleans up zombie sidebar entries
after /clear, resume, logout)
StopFailure -> stop (with error field; API errors
no longer leave sidebar on running)
PermissionDenied -> permission_denied (clears blocked state when
auto-mode classifier denies)
SubagentStart -> subagent_start (nested Agent runs visible)
SubagentStop -> subagent_stop (with last_assistant_message
for the nested agent's output)
PostToolUseFailure -> tool_failed (distinguish tool errors from
successes in the sidebar)
PreCompact -> compact_start (sidebar shows compacting
instead of looking frozen)
PostCompact -> compact_end
CwdChanged -> cwd_changed (project label updates on cd
without waiting for next event)
Elicitation -> question_asked (re-uses OpenCode's event, so
MCP elicitation gets existing UI)
Existing hooks enriched with richer context so the sidebar can render
state with high fidelity:
SessionStart: matcher extended to startup|resume|clear|compact, payload
now carries source/model/permission_mode/agent_type. Also
cleans up stale per-session temp files from prior runs.
UserPromptSubmit: persists the full prompt to a session-scoped temp file
so Stop can include "query -> response" without re-parsing
the transcript. Adds permission_mode to payload.
PermissionRequest: tool preview now considers url/query/pattern in
addition to command/file_path. Adds permission_mode.
PostToolUse: matcher narrowed to Bash|Edit|Write|MultiEdit|NotebookEdit|Agent
(the state-transition tools). Cheap read-only tools (Read,
Glob, Grep, WebFetch) were firing this hook 50-100 times
per session, spawning ~500 jq processes for no sidebar gain.
Payload now includes tool_preview and permission_mode.
Notification: passes through title alongside summary.
Stop: now async, emits permission_mode alongside query/response/transcript.
Timeouts on every handler (5s) cap the worst case when /dev/tty blocks
in containerized/CI environments where WARP_CLI_AGENT_PROTOCOL_VERSION
is inherited but the controlling terminal is missing. Previous default
was 600s.
References:
- https://docs.anthropic.com/en/docs/claude-code/hooks
- https://docs.warp.dev/features/notifications
Extends the existing build_payload-centric test suite with: - Shape assertions for every new event (session_end, stop with error, permission_denied, tool_failed, subagent_start/stop, compact_start/end, cwd_changed, question_asked) plus enriched session_start fields. - Routing tests for all 10 new hook scripts — each must exit 0 when WARP_CLI_AGENT_PROTOCOL_VERSION is unset (non-Warp terminals). - End-to-end test of the prompt handoff: on-prompt-submit.sh persists the full prompt to a session-scoped temp file, and on-session-end.sh cleans it up on termination. Total: 86 passed, 0 failed. Preserves the auto-discovery pattern used by .github/workflows/test.yml (any tests/test-*.sh is picked up).
- plugins/warp/.claude-plugin/plugin.json: 2.0.0 -> 3.0.0 - .claude-plugin/marketplace.json: 2.0.0 -> 3.0.0 - README.md: documents all 16 hooks, the Warp events they emit, and the sidebar effect of each. Adds a v3.0.0 compatibility note explaining the new events are forward-compatible — clients that don't recognize them should silently ignore. Per the existing README guidance, bumping the plugin version requires a coordinated update of MINIMUM_PLUGIN_VERSION in the Warp client to surface the outdated-plugin banner for users on old installations. The adapter continues to emit the v2 event set (session_start, prompt_submit, tool_complete, permission_request, idle_prompt, stop) unchanged — no break for existing Warp clients.
There was a problem hiding this comment.
Pull request overview
Expands the Warp Claude Code plugin from partial to full lifecycle hook coverage by registering additional Claude Code hooks and emitting richer, backward-compatible structured events to keep Warp’s sidebar session state accurate.
Changes:
- Extend
hooks.jsonfrom 6 → 16 registered hooks, adding 5s timeouts and async execution for selected end-of-turn hooks. - Add new hook scripts for missing lifecycle/error/subagent/compaction/cwd/elicitation events and enrich existing payloads (model/source/permission_mode/tool_preview, etc.).
- Expand shell test coverage to validate new payload shapes and the prompt temp-file handoff.
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
plugins/warp/hooks/hooks.json |
Registers the full set of Claude Code hooks, adds matcher narrowing, 5s timeouts, and async on stop-related hooks. |
plugins/warp/.claude-plugin/plugin.json |
Bumps plugin version to 3.0.0 and updates plugin description. |
.claude-plugin/marketplace.json |
Updates marketplace metadata (version/description) to 3.0.0. |
plugins/warp/scripts/on-stop.sh |
Uses last_assistant_message and prompt temp-file handoff; includes permission_mode. |
plugins/warp/scripts/on-stop-failure.sh |
Adds StopFailure handling by emitting stop with error fields for backward compatibility. |
plugins/warp/scripts/on-session-start.sh |
Expands matcher coverage (startup/resume/clear/compact), enriches session_start payload, and cleans stale temp state. |
plugins/warp/scripts/on-session-end.sh |
Adds session_end emission and session-scoped temp-file cleanup. |
plugins/warp/scripts/on-prompt-submit.sh |
Emits prompt_submit with permission_mode and persists full prompt to a session-scoped temp file. |
plugins/warp/scripts/on-permission-request.sh |
Enriches permission_request with generalized tool preview and permission_mode. |
plugins/warp/scripts/on-permission-denied.sh |
Adds permission_denied emission with reason + mirrored summary formatting. |
plugins/warp/scripts/on-post-tool-use.sh |
Adds tool_preview + permission_mode to tool_complete for state-transition tools. |
plugins/warp/scripts/on-post-tool-use-failure.sh |
Adds tool_failed emission with error/tool preview and interrupt flag. |
plugins/warp/scripts/on-subagent-start.sh |
Adds subagent_start emission for nested agent visibility. |
plugins/warp/scripts/on-subagent-stop.sh |
Adds subagent_stop emission with nested agent final response (async). |
plugins/warp/scripts/on-pre-compact.sh |
Emits compact_start to surface compaction in the sidebar. |
plugins/warp/scripts/on-post-compact.sh |
Emits compact_end to clear compaction state. |
plugins/warp/scripts/on-cwd-changed.sh |
Emits cwd_changed so the sidebar project label updates immediately. |
plugins/warp/scripts/on-elicitation.sh |
Maps Claude elicitation to OpenCode-compatible question_asked. |
plugins/warp/scripts/on-notification.sh |
Passes through title and uses notification type as event name. |
plugins/warp/tests/test-hooks.sh |
Adds tests for new payload shapes, new hook scripts’ silent-exit behavior, and prompt temp-file lifecycle. |
README.md |
Documents v3 lifecycle coverage, hook inventory, and enriched payload envelope. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fi | ||
|
|
||
| # Short preview of the failed invocation for the sidebar. | ||
| TOOL_PREVIEW=$(echo "$INPUT" | jq -r '(.tool_input | if .command then .command elif .file_path then .file_path elif .url then .url elif .description then .description else "" end) // ""' 2>/dev/null) |
There was a problem hiding this comment.
tool_preview extraction only considers command, file_path, url, and description. Since PostToolUseFailure can fire for tools like WebSearch/Grep that use query/pattern, the preview may be empty even though the input has a good human-readable field. Consider matching the richer heuristic used in permission hooks (include query/pattern and a tostring fallback) to keep previews useful across tool types.
| TOOL_PREVIEW=$(echo "$INPUT" | jq -r '(.tool_input | if .command then .command elif .file_path then .file_path elif .url then .url elif .description then .description else "" end) // ""' 2>/dev/null) | |
| TOOL_PREVIEW=$(echo "$INPUT" | jq -r '(.tool_input | if .command then .command elif .file_path then .file_path elif .url then .url elif .description then .description elif .query then .query elif .pattern then .pattern else tostring end) // ""' 2>/dev/null) |
| The plugin version in `plugins/warp/.claude-plugin/plugin.json` is checked by the Warp client to detect outdated installations. | ||
| When bumping the version here, also update `MINIMUM_PLUGIN_VERSION` in the Warp client. | ||
|
|
||
| Plugin v3.0.0 adds new Warp events (`session_end`, `permission_denied`, `tool_failed`, `subagent_start`, `subagent_stop`, `compact_start`, `compact_end`, `cwd_changed`). Warp clients that don't know these events should silently ignore them; v3-aware clients render them as first-class sidebar states. The adapter continues to emit the existing six events for backward compatibility. |
There was a problem hiding this comment.
The README lists the new Warp events added in v3.0.0 but omits question_asked, even though the hook inventory/table above documents Elicitation mapping to question_asked. For consistency and to avoid confusion for Warp-side routing work, consider including question_asked in that v3.0.0 new-events list (or clarifying that it reuses an existing event name).
| Plugin v3.0.0 adds new Warp events (`session_end`, `permission_denied`, `tool_failed`, `subagent_start`, `subagent_stop`, `compact_start`, `compact_end`, `cwd_changed`). Warp clients that don't know these events should silently ignore them; v3-aware clients render them as first-class sidebar states. The adapter continues to emit the existing six events for backward compatibility. | |
| Plugin v3.0.0 adds new Warp events (`session_end`, `permission_denied`, `tool_failed`, `subagent_start`, `subagent_stop`, `compact_start`, `compact_end`, `cwd_changed`, `question_asked`). Warp clients that don't know these events should silently ignore them; v3-aware clients render them as first-class sidebar states. The adapter continues to emit the existing six events for backward compatibility. |
| SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) | ||
| if [ -n "$SESSION_ID" ] && [ -n "$FULL_QUERY" ]; then | ||
| QUERY_FILE="${TMPDIR:-/tmp}/warp-claude-${SESSION_ID}.query" | ||
| printf '%s' "$FULL_QUERY" > "$QUERY_FILE" 2>/dev/null || true |
There was a problem hiding this comment.
The per-session prompt cache is written to a predictable path in $TMPDIR. In a world-writable temp dir, another local user could pre-create that path as a symlink and cause this script to overwrite an arbitrary file owned by the current user. Consider writing under a per-user 0700 state dir (e.g. $XDG_RUNTIME_DIR/warp-claude-$UID or a securely-created dir) and/or refusing to write if the target exists and is not a regular file you own.
| printf '%s' "$FULL_QUERY" > "$QUERY_FILE" 2>/dev/null || true | |
| FULL_QUERY="$FULL_QUERY" QUERY_FILE="$QUERY_FILE" python3 - <<'PY' 2>/dev/null || true | |
| import os | |
| import stat | |
| import sys | |
| path = os.environ["QUERY_FILE"] | |
| data = os.environ.get("FULL_QUERY", "") | |
| try: | |
| st = os.lstat(path) | |
| except FileNotFoundError: | |
| st = None | |
| except OSError: | |
| sys.exit(1) | |
| if st is not None and (not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid()): | |
| sys.exit(1) | |
| flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | |
| if hasattr(os, "O_NOFOLLOW"): | |
| flags |= os.O_NOFOLLOW | |
| try: | |
| fd = os.open(path, flags, 0o600) | |
| except OSError: | |
| sys.exit(1) | |
| try: | |
| fst = os.fstat(fd) | |
| if not stat.S_ISREG(fst.st_mode) or fst.st_uid != os.getuid(): | |
| sys.exit(1) | |
| os.write(fd, data.encode()) | |
| finally: | |
| os.close(fd) | |
| PY |
| "$SCRIPT_DIR/warp-notify.sh" "warp://cli-agent" "$BODY" | ||
|
|
||
| # Housekeeping: remove the per-session query file written by on-prompt-submit.sh | ||
| SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null) | ||
| if [ -n "$SESSION_ID" ]; then | ||
| rm -f "${TMPDIR:-/tmp}/warp-claude-${SESSION_ID}".* 2>/dev/null || true | ||
| fi |
There was a problem hiding this comment.
Cleanup of the per-session temp files happens after emitting the notification. If the /dev/tty write blocks and the 5s hook timeout kills the script, these files may never be removed. Consider deleting the temp files before notifying, or adding a trap so cleanup runs even if notify fails/timeouts.
|
|
||
| TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"' 2>/dev/null) | ||
| ERROR=$(echo "$INPUT" | jq -r '.error // empty' 2>/dev/null) | ||
| IS_INTERRUPT=$(echo "$INPUT" | jq -r '.is_interrupt // false' 2>/dev/null) |
There was a problem hiding this comment.
IS_INTERRUPT comes from jq -r ... 2>/dev/null; if parsing fails it may be empty, and then --argjson is_interrupt "$IS_INTERRUPT" will cause jq to error and the hook to emit nothing. Ensure IS_INTERRUPT is always a valid JSON boolean (true/false) before passing it to --argjson (e.g., default to false when empty).
| IS_INTERRUPT=$(echo "$INPUT" | jq -r '.is_interrupt // false' 2>/dev/null) | |
| IS_INTERRUPT=$(echo "$INPUT" | jq -r '.is_interrupt // false' 2>/dev/null) | |
| case "$IS_INTERRUPT" in | |
| true|false) | |
| ;; | |
| *) | |
| IS_INTERRUPT=false | |
| ;; | |
| esac |
Downgrades the version tag to signal this is a backward-compatible patch on top of v2.0.0, not a major release. All existing v2.0.0 events are emitted unchanged; the new events (`session_end`, `permission_denied`, `tool_failed`, `subagent_start`, `subagent_stop`, `compact_start`, `compact_end`, `cwd_changed`) are purely additive and older Warp clients will ignore them silently. Keeping the version in patch range avoids tripping marketplace discovery or signalling a breaking change that doesn't exist.
what this is
v2.0.1 — patch on top of v2.0.0 that registers the claude code hooks v2 didn't cover, so the sidebar doesn't stay stuck on
running/blockedwhen the actual agent state has moved on. additive only: every v2.0.0 event is still emitted unchanged, and the new event names (session_end,permission_denied,tool_failed,subagent_start,subagent_stop,compact_start,compact_end,cwd_changed) are silently ignored by older warp clients. marketplace-safe.retagged from the original
v3.0.0after shipping + live testing — the scope is accumulated bugfixes, not a major release.five correctness bugs fixed
Stophook slept 0.3s + re-parsed the jsonl transcript for the last assistant messagelast_assistant_messagedirectly from stdin. drop sleep + jq tree-walkSessionEndhook/clear,--resume, logouton-session-end.shemitssession_endwith areasonfieldStopFailurehookrunningforeveron-stop-failure.shemitsstopwith anerrorfield so old clients still mark done, new clients can render as failedPermissionDeniedhookpermission_requestnever resolves, tab sticks onblockedon-permission-denied.shemitspermission_deniedwith the classifier's reasonSessionStartmatcher wasstartup|resume/clearand/compactregeneratesession_idbut never firesession_start— sidebar shows stale statestartup|resume|clear|compactadditional lifecycle hooks
SubagentStart/SubagentStopsubagent_start/subagent_stopAgentcall used to look like one opaquetool_complete. now nested progress + final message are visiblePostToolUseFailuretool_failedPreCompact/PostCompactcompact_start/compact_endCwdChangedcwd_changedprojectlabel refreshes immediately aftercdElicitationquestion_askedcontext enrichment on existing events
opportunistic fields pulled from hook input and added to the envelope:
permission_modeon events that have it — sidebar can differentiateplan/auto/acceptEdits/bypassPermissionsmodelonsession_start(e.g.claude-sonnet-4-6)sourceonsession_start—startup/resume/clear/compacttool_previewontool_complete/tool_failed— 120-char preview ofcommand/file_path/urlso the sidebar can showEdit: src/auth.tsinstead of justEditwhat this pr does NOT fix
→ #28 — fresh claude tabs render as
⏳ In progressbefore any turn starts. three plugin-side workarounds were tested (emit nothing, emitsession_startonly, emitsession_start+ follow-upstop) and all three produce a wrong sidebar state. the short version: gemini + factory/droid get clean no-pill rows via warp's binary-name auto-detection;agent:"claude"doesn't have that path, and any osc we emit attaches a default pill. detailed reproduction + proposed fixes live in the issue.this pr does not attempt that workaround — the
in progressstate you see after merging is the same one v2.0.0 has today. nothing regresses.testing
test coverage: envelope shape, protocol version negotiation, legacy/structured routing, all new event payloads,
on-prompt-submit.sh→on-stop.shtemp-file handoff, thestop_hook_activere-entry guard on subagent stop.marketplace notes
2.0.0→2.0.1(patch, backward-compat)my fork's
testing/side-by-side-marketplacebranch keeps the full experimental state (plugins/warp-v3/with the unscoped attempts at thein progressfix + per-session osc log + ppid-walk tty detection + more). that won't land here — it's strictly for follow-up exploration.