Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 14 additions & 1 deletion hooks/conversation-id.sh
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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
}
5 changes: 5 additions & 0 deletions hooks/report-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions hooks/report-tool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading