Summary
The stop-hook escape hatch hasActiveBackgroundAgents() (src/commands/hook.ts:610-671) is silently broken for SDK-driven sessions (entrypoint: "sdk-ts" — duraclaw, eval harness, anything using @anthropic-ai/claude-agent-sdk). The staleness heuristic added in PR #61 for issue #60 never fires because SDK sessions encode typed user prompts as a bare string on message.content, not as an [{type:'text', text:'...'}] array. Unmatched Agent tool_use IDs persist forever and the guard false-allows exit for the rest of the session — the exact symptom #60 was filed to fix, still present.
Findings
The guard assumes array-of-blocks shape — src/commands/hook.ts:640-659:
const contentBlocks = (message.content as Array<...>) ?? []
for (const block of contentBlocks) {
if (block.type === 'text' && block.text?.trim()) hasUserText = true
...
}
if (hasUserText && !hasToolResult) agentToolUseIds.clear() // #61 staleness clear
SDK sessions produce string-form content:
{"type":"user","message":{"role":"user","content":"fix the button position"}}
When message.content is a string, for...of iterates characters. Each char is a one-letter primitive with no .type → hasUserText stays false → agentToolUseIds.clear() never runs → stale IDs persist forever.
Empirical data — one representative SDK transcript (~/.claude/projects/-data-projects-duraclaw-dev2/8d76757f-....jsonl, 594 lines, version: 2.1.98, entrypoint: sdk-ts):
user message.content shape |
count |
| STRING (typed prompt) |
5 |
ARRAY with [0].type == 'tool_result' |
138 |
ARRAY with [0].type == 'text' |
0 |
A comparable CLI-entrypoint session in the same tree had 4 ARRAY_text entries — that's where #61's heuristic works today. SDK sessions literally never produce the shape that triggers it.
Secondary issue: tool-spawn tool name differs across CC versions. Guard only matches block.name === 'Agent' (hook.ts:631), but 2.1.50 sessions emit "name":"Task". Recent 2.1.98+ SDK/CLI sessions use Agent. The eval harness allowedTools whitelist includes Task (eval/harness.ts:310).
Follow-up
Order by smallest-diff-first:
Evidence
Research doc: planning/research/2026-04-23-sdk-stop-hook-guard-failure.md (commit 1e4e30e).
Related: #60 (closed by #61 — incomplete fix for SDK sessions).
Summary
The stop-hook escape hatch
hasActiveBackgroundAgents()(src/commands/hook.ts:610-671) is silently broken for SDK-driven sessions (entrypoint: "sdk-ts"— duraclaw, eval harness, anything using@anthropic-ai/claude-agent-sdk). The staleness heuristic added in PR #61 for issue #60 never fires because SDK sessions encode typed user prompts as a bare string onmessage.content, not as an[{type:'text', text:'...'}]array. UnmatchedAgenttool_use IDs persist forever and the guard false-allows exit for the rest of the session — the exact symptom #60 was filed to fix, still present.Findings
The guard assumes array-of-blocks shape —
src/commands/hook.ts:640-659:SDK sessions produce string-form content:
{"type":"user","message":{"role":"user","content":"fix the button position"}}When
message.contentis a string,for...ofiterates characters. Each char is a one-letter primitive with no.type→hasUserTextstays false →agentToolUseIds.clear()never runs → stale IDs persist forever.Empirical data — one representative SDK transcript (
~/.claude/projects/-data-projects-duraclaw-dev2/8d76757f-....jsonl, 594 lines,version: 2.1.98, entrypoint: sdk-ts):message.contentshape[0].type == 'tool_result'[0].type == 'text'A comparable CLI-entrypoint session in the same tree had 4
ARRAY_textentries — that's where #61's heuristic works today. SDK sessions literally never produce the shape that triggers it.Secondary issue: tool-spawn tool name differs across CC versions. Guard only matches
block.name === 'Agent'(hook.ts:631), but2.1.50sessions emit"name":"Task". Recent2.1.98+SDK/CLI sessions useAgent. The eval harness allowedTools whitelist includesTask(eval/harness.ts:310).Follow-up
Order by smallest-diff-first:
[{type:'text',text:content}]. Single-line fix, restores fix(hook): ignore stale unmatched Agent tool_uses in stop-conditions #61 staleness clear for SDK sessions.block.name === 'Task'too for older CC/SDK compatibility.src/commands/hook.test.ts:"name":"Task"→ guard detects when fresh, ignores when staleEvidence
Research doc:
planning/research/2026-04-23-sdk-stop-hook-guard-failure.md(commit 1e4e30e).Related: #60 (closed by #61 — incomplete fix for SDK sessions).