From e979d290f09ec48ef30c9e1624d950c80413f5ad Mon Sep 17 00:00:00 2001 From: codevibesmatter Date: Thu, 16 Apr 2026 07:16:49 -0400 Subject: [PATCH] fix(hook): ignore stale unmatched Agent tool_uses in stop-conditions Stop hook was returning decision: "allow" for the rest of a session once any Agent tool_use went unmatched in the transcript. In SDK-driven sessions, some Agent calls never get a tool_result the scanner expects, so unmatched IDs from minutes/hours ago poisoned the hook indefinitely. hasActiveBackgroundAgents now records each Agent tool_use's timestamp and only counts unmatched IDs issued within the last 2 minutes (configurable via { recencyWindowMs, nowMs }). Older entries are treated as completed/abandoned. Closes #60 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 6 +++ package.json | 2 +- src/commands/hook.test.ts | 89 +++++++++++++++++++++++++++++++++++++++ src/commands/hook.ts | 31 +++++++++++--- 4 files changed, 120 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 694ac3f..e6dd866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.5.1 (2026-04-16) + +### Fixes + +- **stop hook**: `hasActiveBackgroundAgents` now ignores unmatched `Agent` tool_uses older than 2 minutes. Previously, stale IDs from earlier in the session would poison the Stop hook for the rest of the session, allowing premature exit despite pending tasks, uncommitted changes, or unpushed commits. (#60) + ## 0.4.0 (2026-04-12) ### Breaking Changes diff --git a/package.json b/package.json index 2a66a94..05df831 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codevibesmatter/kata", - "version": "0.5.0", + "version": "0.5.1", "description": "Structured workflow kata for Claude Code — modes, phases, tasks, and stop hooks for disciplined AI coding sessions", "type": "module", "bin": { diff --git a/src/commands/hook.test.ts b/src/commands/hook.test.ts index db19a1a..cb04487 100644 --- a/src/commands/hook.test.ts +++ b/src/commands/hook.test.ts @@ -523,4 +523,93 @@ describe('hasActiveBackgroundAgents', () => { const path = writeTranscript([]) expect(hasActiveBackgroundAgents(path)).toBe(false) }) + + it('returns false when only unmatched Agents are older than recency window', async () => { + const { hasActiveBackgroundAgents } = await import('./hook.js') + const path = writeTranscript([ + { + type: 'assistant', + timestamp: '2026-04-16T10:00:00.000Z', + message: { content: [{ type: 'tool_use', name: 'Agent', id: 'agent-1' }] }, + }, + ]) + expect( + hasActiveBackgroundAgents(path, { nowMs: Date.parse('2026-04-16T10:10:00.000Z') }), + ).toBe(false) + }) + + it('returns true for unmatched Agent within recency window', async () => { + const { hasActiveBackgroundAgents } = await import('./hook.js') + const path = writeTranscript([ + { + type: 'assistant', + timestamp: '2026-04-16T10:00:00.000Z', + message: { content: [{ type: 'tool_use', name: 'Agent', id: 'agent-1' }] }, + }, + ]) + expect( + hasActiveBackgroundAgents(path, { nowMs: Date.parse('2026-04-16T10:00:30.000Z') }), + ).toBe(true) + }) + + it('reproduces issue #60: 3 stale unmatched Agents from >5min ago + recent matched Agent → false', async () => { + const { hasActiveBackgroundAgents } = await import('./hook.js') + const path = writeTranscript([ + { + type: 'assistant', + timestamp: '2026-04-16T02:54:00.000Z', + message: { content: [{ type: 'tool_use', name: 'Agent', id: 'stale-1' }] }, + }, + { + type: 'assistant', + timestamp: '2026-04-16T02:57:00.000Z', + message: { content: [{ type: 'tool_use', name: 'Agent', id: 'stale-2' }] }, + }, + { + type: 'assistant', + timestamp: '2026-04-16T03:01:00.000Z', + message: { content: [{ type: 'tool_use', name: 'Agent', id: 'stale-3' }] }, + }, + { + type: 'assistant', + timestamp: '2026-04-16T10:19:00.000Z', + message: { content: [{ type: 'tool_use', name: 'Agent', id: 'recent-1' }] }, + }, + { + type: 'user', + timestamp: '2026-04-16T10:19:10.000Z', + message: { content: [{ type: 'tool_result', tool_use_id: 'recent-1' }] }, + }, + ]) + expect( + hasActiveBackgroundAgents(path, { nowMs: Date.parse('2026-04-16T10:19:17.000Z') }), + ).toBe(false) + }) + + it('returns true when current-turn Agent has stale older Agents alongside it', async () => { + const { hasActiveBackgroundAgents } = await import('./hook.js') + const path = writeTranscript([ + { + type: 'assistant', + timestamp: '2026-04-16T02:54:00.000Z', + message: { content: [{ type: 'tool_use', name: 'Agent', id: 'stale-1' }] }, + }, + { + type: 'assistant', + timestamp: '2026-04-16T10:19:00.000Z', + message: { content: [{ type: 'tool_use', name: 'Agent', id: 'recent-1' }] }, + }, + ]) + expect( + hasActiveBackgroundAgents(path, { nowMs: Date.parse('2026-04-16T10:19:30.000Z') }), + ).toBe(true) + }) + + it('falls back to nowMs when transcript line has no timestamp', async () => { + const { hasActiveBackgroundAgents } = await import('./hook.js') + const path = writeTranscript([ + { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Agent', id: 'agent-1' }] } }, + ]) + expect(hasActiveBackgroundAgents(path, { nowMs: 1000, recencyWindowMs: 5000 })).toBe(true) + }) }) diff --git a/src/commands/hook.ts b/src/commands/hook.ts index ec93f41..f452acc 100644 --- a/src/commands/hook.ts +++ b/src/commands/hook.ts @@ -513,24 +513,38 @@ function resolveTranscriptPath(sessionId: string): string | undefined { } } +const AGENT_RECENCY_WINDOW_MS = 120_000 // 2 minutes + /** * Check if there are active background agents by scanning the session transcript. * An Agent tool_use without a matching tool_result means the agent is still running. - * Returns true if at least one background agent is active. + * + * Returns true only when an unmatched Agent tool_use was issued within the recency + * window (default 2 minutes). Stale unmatched IDs (from earlier turns where the SDK + * didn't emit a tool_result the scanner recognises) are treated as completed/abandoned. + * See issue #60. */ -export function hasActiveBackgroundAgents(transcriptPath: string | undefined): boolean { +export function hasActiveBackgroundAgents( + transcriptPath: string | undefined, + options: { recencyWindowMs?: number; nowMs?: number } = {}, +): boolean { if (!transcriptPath) return false + const recencyWindowMs = options.recencyWindowMs ?? AGENT_RECENCY_WINDOW_MS + const now = options.nowMs ?? Date.now() try { const content = readFileSync(transcriptPath, 'utf-8') const lines = content.split('\n').filter((l) => l.trim()) - // Track Agent tool_use IDs and match against tool_result IDs - const agentToolUseIds = new Set() + // Track Agent tool_use IDs → timestamp ms; match and remove on tool_result + const agentToolUses = new Map() for (const line of lines) { try { const msg = JSON.parse(line) as Record + const parsed = typeof msg.timestamp === 'string' ? Date.parse(msg.timestamp) : NaN + const ts = Number.isFinite(parsed) ? parsed : now + if (msg.type === 'assistant') { const message = (msg.message as Record) ?? msg const contentBlocks = (message.content as Array>) ?? [] @@ -540,7 +554,7 @@ export function hasActiveBackgroundAgents(transcriptPath: string | undefined): b block.name === 'Agent' && typeof block.id === 'string' ) { - agentToolUseIds.add(block.id) + agentToolUses.set(block.id, ts) } } } @@ -550,7 +564,7 @@ export function hasActiveBackgroundAgents(transcriptPath: string | undefined): b const contentBlocks = (message.content as Array>) ?? [] for (const block of contentBlocks) { if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') { - agentToolUseIds.delete(block.tool_use_id) + agentToolUses.delete(block.tool_use_id) } } } @@ -559,7 +573,10 @@ export function hasActiveBackgroundAgents(transcriptPath: string | undefined): b } } - return agentToolUseIds.size > 0 + for (const ts of agentToolUses.values()) { + if (now - ts < recencyWindowMs) return true + } + return false } catch { // Transcript unreadable — assume no active agents return false