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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
89 changes: 89 additions & 0 deletions src/commands/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
31 changes: 24 additions & 7 deletions src/commands/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
// Track Agent tool_use IDs → timestamp ms; match and remove on tool_result
const agentToolUses = new Map<string, number>()

for (const line of lines) {
try {
const msg = JSON.parse(line) as Record<string, unknown>

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<string, unknown>) ?? msg
const contentBlocks = (message.content as Array<Record<string, unknown>>) ?? []
Expand All @@ -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)
}
}
}
Expand All @@ -550,7 +564,7 @@ export function hasActiveBackgroundAgents(transcriptPath: string | undefined): b
const contentBlocks = (message.content as Array<Record<string, unknown>>) ?? []
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)
}
}
}
Expand All @@ -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
Expand Down
Loading