diff --git a/CHANGELOG.md b/CHANGELOG.md index 6014807..86bc4a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **A finished Claude Code session no longer goes back to busy on its own.** A + subagent's tool calls fired the session's own tool hooks, so a background + agent, or the prompt suggestion Claude Code works out after each turn, put the + card back on a spinner naming a tool nobody ran, until the next prompt. The + card now reports only the session's own tool calls. + ## [0.20.3] - 2026-10-15 ### Added diff --git a/docs/session-state.md b/docs/session-state.md index 6c0b477..183dc5d 100644 --- a/docs/session-state.md +++ b/docs/session-state.md @@ -224,3 +224,23 @@ the same tool call for the second half of its reason: Without `jq` (Windows, usually) the tool name still goes out, read with `sed` like the session id is; the detail does not, because a hand-built body cannot escape arbitrary text. + +### A subagent's tool calls + +Claude Code fires the session's own `PreToolUse` and `PostToolUse` for every +tool a subagent runs, with the session's `session_id` and an `agent_id` the main +conversation's payloads never carry (measured on 2.1.302). Some of those tools +run after the session's `Stop`: a background agent still working (measured), +and most likely the fork Claude Code starts right after `Stop` to write the next +prompt suggestion, which is a subagent (its `SubagentStop` follows every `Stop` +by ~1.3s) and was seen once putting a card back on `busy` 2s after `Stop`, with +a command no transcript holds. Reported, each put a finished card back on a +spinner naming a tool nobody ran, until the next turn. + +So `report-tool.sh`, and `report-state.sh` reporting `busy`, drop a payload that +carries `agent_id`. Inside a turn this changes nothing on the card but its tool +line, which keeps naming the `Agent` call while the subagent works. The other +states are left alone: a turn still opens, blocks and ends on the session's own +events. No other harness was seen sending the field. Without `jq` the match is +not anchored to the top level, so a tool input carrying its own `agent_id` goes +unreported too. diff --git a/hooks/conversation-id.sh b/hooks/conversation-id.sh index e9f52bd..9f2046b 100644 --- a/hooks/conversation-id.sh +++ b/hooks/conversation-id.sh @@ -1,6 +1,6 @@ # Sourced by the report scripts, not run: conversation_id reads a hook payload on # stdin and prints the provider's id for the conversation that fired it, or -# nothing. Antigravity names it `conversationId`, every other harness +# nothing; from_subagent succeeds when a subagent inside it did. Antigravity names it `conversationId`, every other harness # `session_id`. Prefers jq; falls back to sed so it works on Windows, where jq is # usually absent but sed (Git Bash) is not. conversation_id() { @@ -11,3 +11,16 @@ conversation_id() { -e 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 fi } + +# Claude Code fires the session's tool hooks for its subagents too, adding +# `agent_id` (measured on 2.1.302), and their tools keep running after the +# session's Stop. Without jq the match is not anchored to the top level, so a +# tool input carrying its own `agent_id` reads as a subagent's call: one +# unreported tool, against a card stuck on busy. +from_subagent() { + if command -v jq >/dev/null 2>&1; then + jq -e 'has("agent_id")' >/dev/null 2>&1 + else + grep -q '"agent_id"[[:space:]]*:[[:space:]]*"' + fi +} diff --git a/hooks/report-state.sh b/hooks/report-state.sh index a6d0ae2..3927c0d 100755 --- a/hooks/report-state.sh +++ b/hooks/report-state.sh @@ -18,6 +18,7 @@ if [ -r "${0%/*}/conversation-id.sh" ]; then else echo "lich-plugin: hooks/conversation-id.sh missing; reporting without provider_session_id" >&2 conversation_id() { :; } + from_subagent() { return 1; } fi state=$1 @@ -32,6 +33,10 @@ exec 3<&0 payload=$(cat <&3 & reader=$!; (exec >/dev/null 2>&1; sleep 1 && kill "$reader") & wait "$reader") exec 3<&- +# Only the tool events a subagent fires are dropped: a turn still opens, blocks +# and ends on the session's own events. +[ "$state" = busy ] && printf '%s' "$payload" | from_subagent && exit 0 + # `reason` says what the agent is blocked on, and belongs to `waiting` alone — # lich drops it on every other state, so no other branch looks for one. It is # optional in both directions: an absent one still rings the bell, and a value diff --git a/hooks/report-tool.sh b/hooks/report-tool.sh index 24d5108..af652cc 100755 --- a/hooks/report-tool.sh +++ b/hooks/report-tool.sh @@ -23,9 +23,11 @@ if [ -r "${0%/*}/conversation-id.sh" ]; then else echo "lich-plugin: hooks/conversation-id.sh missing; reporting without provider_session_id" >&2 conversation_id() { :; } + from_subagent() { return 1; } fi payload=$(cat) +printf '%s' "$payload" | from_subagent && exit 0 command -v jq >/dev/null 2>&1 && has_jq=1 || has_jq= # Prefer jq; fall back to sed so the hook works on Windows, where jq is usually diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index 831dd0e..9d90bf7 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -716,6 +716,39 @@ for (const [script, argument] of [['report-state.sh', 'busy'], ['report-tool.sh' }) } +// A subagent's tool calls fire the session's own PreToolUse and PostToolUse, +// carrying `agent_id` (measured on Claude Code 2.1.302). Its tools still run +// after the session's Stop: a background agent, and the fork Claude Code runs to +// suggest the next prompt. Reported, they put a finished card back on busy. +for (const [script, argument] of [['report-state.sh', 'busy'], ['report-tool.sh', '']]) { + const command = `"${ROOT}/hooks/${script}" ${argument}` + const subagentCall = JSON.stringify({ + ...JSON.parse(stdinFor({ provider: 'claude', event: 'PreToolUse' })), + agent_id: 'aab6524dbdaf8ca5a', + agent_type: 'Explore', + }) + + for (const [how, env] of [['', {}], [' without jq', { PATH: binWithout('jq') }]]) { + test(`${script} leaves a subagent's tool call unreported${how}`, async () => { + await withStub(async (stub) => { + const result = await runHook(command, { env: { ...lichEnv(stub.port), ...env }, stdin: subagentCall }) + assertHookSucceeded(result) + assert.equal(stub.requests.length, 0, `reported a subagent's call: ${stub.requests[0]?.raw}`) + }) + }) + } + + test(`${script} still reports a call whose input names an agent_id`, async () => { + await withStub(async (stub) => { + const payload = JSON.parse(stdinFor({ provider: 'claude', event: 'PreToolUse' })) + payload.tool_input = { agent_id: 'not-the-caller' } + const result = await runHook(command, { env: lichEnv(stub.port), stdin: JSON.stringify(payload) }) + assertHookSucceeded(result) + assert.equal(stub.requests.length, 1) + }) + }) +} + // lich writes the scripts of a file-shipped harness one by one (Antigravity, // Kiro), so an install can carry them without conversation-id.sh. Each says so // on stderr and reports what it can without it.