diff --git a/docs/claude-code-hooks-reference.md b/docs/claude-code-hooks-reference.md index ec309406..8bd76dfc 100644 --- a/docs/claude-code-hooks-reference.md +++ b/docs/claude-code-hooks-reference.md @@ -149,9 +149,17 @@ to Claude as a system reminder. This implies `"async": true`; ordinary async hooks do not wake an idle turn, and their output waits for the next interaction. Codeman uses this on `PostToolUse(Bash)`: a self-contained Node helper extracts -the background task ID from the Bash result, watches the session transcript for -the matching completion notification, and exits 2. It does not send terminal -input, so it cannot submit a user's partially written prompt. +the background task ID from the Bash result, watches the originating transcript +and, for subagents, the top-level parent transcript for the matching completion +notification, and exits 2. Claude records a subagent's Bash result in its +`subagents/agent-*.jsonl` file but queues completion in the lead session JSONL. +The task ID keeps each wake targeted. The helper does not send terminal input, +so it cannot submit a user's partially written prompt. + +For script-dispatched Codex work, `codex-run.sh` writes the final response +between `CODEMAN_RESULT_BEGIN/END` markers in the background task output. The +rewake helper includes a maximum of 64 KiB of that report in its feedback. UI +subagent discovery and dispatcher result delivery are separate contracts. ### Notification @@ -219,6 +227,16 @@ Or to allow exit: **Use Cases**: Control nested loops, verify subagent output. +The hook input includes `agent_id`, `agent_transcript_path`, and +`last_assistant_message`. Like `Stop`, a command hook can return +`{"decision":"block","reason":"..."}` to keep the subagent running and feed +the reason back to it. + +Codeman uses this to prevent premature reports from workers that still own live +Monitor or background-Bash processes. It derives candidate task IDs from the +subagent transcript, but requires a matching live Linux process descriptor for +`tasks/.output`; historical task text by itself is not treated as active. + ### TeammateIdle **When**: When an agent-team teammate is about to go idle. diff --git a/src/hooks-config.ts b/src/hooks-config.ts index f78279cb..715fa360 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -10,13 +10,15 @@ * Key exports: * - `generateHooksConfig()` — returns hooks object for settings.local.json * - `writeHooksConfig(casePath)` — writes hooks + env config to disk + * - `ensureCodemanHooks(casePath)` — safely installs/updates hooks for a managed case * - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings * * Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`, * `stop`, `teammate_idle`, `task_completed` * - * Hook categories: `Notification` (3 matchers), `Stop` (1), `TeammateIdle` (1), - * `TaskCompleted` (1), `PostToolUse` (1 self-contained background Bash rewake) + * Hook categories: `Notification` (3 matchers), `Stop` (1), `SubagentStop` (1), + * `TeammateIdle` (1), `TaskCompleted` (1), `PostToolUse` (1 self-contained + * background Bash rewake) * * @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_SECONDS) * @consumedby web/server (session creation), session-cli-builder (env setup) @@ -52,15 +54,19 @@ const BACKGROUND_WAKE_MARKER_PREFIX = 'CODEMAN_BACKGROUND_REWAKE_V'; * changes: `refreshStaleCodemanHooks` treats the absence of the CURRENT marker as * stale, so healed cases pick up the new script on next launch. */ -const BACKGROUND_WAKE_MARKER = `${BACKGROUND_WAKE_MARKER_PREFIX}2`; +const BACKGROUND_WAKE_MARKER = `${BACKGROUND_WAKE_MARKER_PREFIX}3`; +const SUBAGENT_STOP_GUARD_MARKER_PREFIX = 'CODEMAN_SUBAGENT_STOP_GUARD_V'; +const SUBAGENT_STOP_GUARD_MARKER = `${SUBAGENT_STOP_GUARD_MARKER_PREFIX}1`; const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60; /** * Inline Node helper for Claude Code's `asyncRewake` hook. * * A background Bash tool returns immediately with a task ID, then Claude writes - * its completion as a queue-operation in the transcript. Watching that durable - * record avoids injecting terminal input (which could submit a user's draft). + * its completion as a queue-operation in the top-level transcript. Subagent hooks + * receive their own transcript path even though their completion is parent-owned, + * so the helper watches both paths. Watching durable records avoids injecting + * terminal input (which could submit a user's draft). * The helper is embedded in settings via `node -e`, so it has no script path * that can go stale after an install or plugin-cache cleanup. * @@ -72,8 +78,12 @@ const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60; export function generateBackgroundWakeScript(): string { return [ "const fs = require('node:fs');", + "const path = require('node:path');", `const ${BACKGROUND_WAKE_MARKER} = true;`, `const deadline = Date.now() + ${BACKGROUND_WAKE_TIMEOUT_SECONDS} * 1000;`, + "const RESULT_BEGIN = '=== CODEMAN_RESULT_BEGIN ===';", + "const RESULT_END = '=== CODEMAN_RESULT_END ===';", + 'const MAX_RESULT_CHARS = 65536;', 'let input = {};', "try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }", 'function findTaskId(value) {', @@ -98,46 +108,164 @@ export function generateBackgroundWakeScript(): string { 'const taskId = findTaskId(input.tool_response);', "const transcriptPath = typeof input.transcript_path === 'string' ? input.transcript_path : '';", 'if (!taskId || !transcriptPath) process.exit(0);', - 'let position = 0;', - 'try { position = Math.max(0, fs.statSync(transcriptPath).size - 262144); } catch { process.exit(0); }', - "let carry = '';", + 'const transcriptPaths = [transcriptPath];', + 'const sessionDir = path.dirname(path.dirname(transcriptPath));', + "if (typeof input.agent_id === 'string' && path.basename(path.dirname(transcriptPath)) === 'subagents' &&", + " typeof input.session_id === 'string' && path.basename(sessionDir) === input.session_id) {", + " transcriptPaths.push(sessionDir + '.jsonl');", + '}', + 'const transcripts = [...new Set(transcriptPaths)].map((transcript) => {', + ' let position = 0;', + ' try { position = Math.max(0, fs.statSync(transcript).size - 262144); } catch {}', + " return { path: transcript, position, carry: '' };", + '});', + 'if (!transcripts.some((transcript) => fs.existsSync(transcript.path))) process.exit(0);', + 'function readMarkedResult(outputPath) {', + " if (!outputPath || !path.isAbsolute(outputPath) || path.basename(outputPath) !== taskId + '.output') return '';", + " if (path.basename(path.dirname(outputPath)) !== 'tasks') return '';", + ' try {', + ' const size = fs.statSync(outputPath).size;', + ' const length = Math.min(size, MAX_RESULT_CHARS * 2);', + ' const buffer = Buffer.allocUnsafe(length);', + " const fd = fs.openSync(outputPath, 'r');", + ' const bytes = fs.readSync(fd, buffer, 0, length, size - length);', + ' fs.closeSync(fd);', + " const text = buffer.subarray(0, bytes).toString('utf8');", + ' const begin = text.lastIndexOf(RESULT_BEGIN);', + ' const end = text.indexOf(RESULT_END, begin + RESULT_BEGIN.length);', + " if (begin < 0 || end < 0) return '';", + ' let result = text.slice(begin + RESULT_BEGIN.length, end).trim();', + " if (!result) return '';", + ' if (result.length > MAX_RESULT_CHARS) {', + ' const half = Math.floor(MAX_RESULT_CHARS / 2);', + " result = result.slice(0, half) + '\\n\\n[report truncated by Codeman]\\n\\n' + result.slice(-half);", + ' }', + " return '\\n\\nCompleted task report:\\n\\n' + result + '\\n';", + " } catch { return ''; }", + '}', 'function inspect(text) {', ' for (const line of text.split(/\\r?\\n/)) {', ' if (!line.includes(taskId)) continue;', ' let entry;', ' try { entry = JSON.parse(line); } catch { continue; }', - " if (entry.type !== 'queue-operation' || typeof entry.content !== 'string') continue;", + " if (entry.type !== 'queue-operation' || entry.operation !== 'enqueue' || typeof entry.content !== 'string') continue;", " if (!entry.content.includes('' + taskId + '')) continue;", ' const status = entry.content.match(/(completed|failed|killed|error)<\\/status>/i);', ' if (!status) continue;', ' const output = entry.content.match(/([^<]+)<\\/output-file>/i);', - " const location = output ? ' Read ' + output[1] + ' and' : '';", - " console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.');", + " const outputPath = output ? output[1].trim() : '';", + " const location = outputPath ? ' Read ' + outputPath + ' and' : '';", + ' const result = readMarkedResult(outputPath);', + " console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.' + result);", ' process.exit(2);', ' }', '}', - 'function poll() {', - ' if (Date.now() > deadline || process.ppid === 1) process.exit(0);', + 'function pollTranscript(transcript) {', ' try {', - ' const size = fs.statSync(transcriptPath).size;', - " if (size < position) { position = 0; carry = ''; }", - ' if (size > position) {', - ' const length = Math.min(size - position, 1048576);', + ' const size = fs.statSync(transcript.path).size;', + " if (size < transcript.position) { transcript.position = 0; transcript.carry = ''; }", + ' if (size > transcript.position) {', + ' const length = Math.min(size - transcript.position, 1048576);', ' const buffer = Buffer.allocUnsafe(length);', - " const fd = fs.openSync(transcriptPath, 'r');", - ' const bytes = fs.readSync(fd, buffer, 0, length, position);', + " const fd = fs.openSync(transcript.path, 'r');", + ' const bytes = fs.readSync(fd, buffer, 0, length, transcript.position);', ' fs.closeSync(fd);', - ' position += bytes;', - " carry = (carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);", - ' inspect(carry);', + ' transcript.position += bytes;', + " transcript.carry = (transcript.carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);", + ' inspect(transcript.carry);', ' }', ' } catch {}', + '}', + 'function poll() {', + ' if (Date.now() > deadline || process.ppid === 1) process.exit(0);', + ' for (const transcript of transcripts) pollTranscript(transcript);', ' setTimeout(poll, 1000);', '}', 'poll();', ].join('\n'); } +/** + * Keep a Claude subagent alive while its Monitor or background Bash work is live. + * Claude otherwise can publish the worker's last progress sentence as an Agent + * result when one watcher ends, even if other tracked tasks are still running. + */ +export function generateSubagentStopGuardScript(): string { + return [ + "const fs = require('node:fs');", + `const ${SUBAGENT_STOP_GUARD_MARKER} = true;`, + 'let input = {};', + "try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }", + "const transcriptPath = typeof input.agent_transcript_path === 'string' ? input.agent_transcript_path : '';", + 'if (!transcriptPath) process.exit(0);', + 'let text;', + 'try {', + ' const size = fs.statSync(transcriptPath).size;', + ' const length = Math.min(size, 16 * 1024 * 1024);', + ' const buffer = Buffer.allocUnsafe(length);', + " const fd = fs.openSync(transcriptPath, 'r');", + ' const bytes = fs.readSync(fd, buffer, 0, length, size - length);', + ' fs.closeSync(fd);', + " text = buffer.subarray(0, bytes).toString('utf8');", + '} catch { process.exit(0); }', + 'const launched = new Set();', + 'const finished = new Set();', + 'function inspectToolResult(value) {', + " const serialized = typeof value === 'string' ? value : JSON.stringify(value ?? '');", + ' for (const match of serialized.matchAll(/Command running in background with ID:\\s*([A-Za-z0-9_-]+)/gi)) launched.add(match[1]);', + ' for (const match of serialized.matchAll(/Monitor started \\(task ([A-Za-z0-9_-]+)/gi)) launched.add(match[1]);', + '}', + 'function inspectNotifications(value) {', + " if (typeof value !== 'string' || !value.includes('')) return;", + ' for (const match of value.matchAll(/([\\s\\S]*?)<\\/task-notification>/gi)) {', + ' const body = match[1];', + ' const id = body.match(/([^<]+)<\\/task-id>/i);', + ' const status = body.match(/(completed|failed|killed|error)<\\/status>/i);', + ' if (id && status) finished.add(id[1].trim());', + ' }', + '}', + 'for (const line of text.split(/\\r?\\n/)) {', + ' let entry;', + ' try { entry = JSON.parse(line); } catch { continue; }', + ' const content = entry && entry.message ? entry.message.content : undefined;', + ' if (Array.isArray(content)) {', + ' for (const block of content) {', + " if (block && block.type === 'tool_result') inspectToolResult(block.content);", + " if (block && block.type === 'text') inspectNotifications(block.text);", + ' }', + ' } else {', + ' inspectNotifications(content);', + ' }', + ' inspectNotifications(entry && entry.content);', + '}', + 'function findLiveTasks(candidates) {', + ' const live = new Set();', + " if (candidates.size === 0 || !fs.existsSync('/proc')) return live;", + ' let processIds;', + " try { processIds = fs.readdirSync('/proc').filter((name) => /^\\d+$/.test(name)); } catch { return live; }", + ' for (const processId of processIds) {', + " for (const descriptor of ['0', '1', '2']) {", + ' let target;', + " try { target = fs.readlinkSync('/proc/' + processId + '/fd/' + descriptor); } catch { continue; }", + ' const match = target.match(/[\\/]tasks[\\/]([A-Za-z0-9_-]+)\\.output(?: \\(deleted\\))?$/);', + ' if (match && candidates.has(match[1])) live.add(match[1]);', + ' }', + ' if (live.size === candidates.size) break;', + ' }', + ' return live;', + '}', + 'const unfinished = new Set([...launched].filter((taskId) => !finished.has(taskId)));', + 'const active = [...findLiveTasks(unfinished)];', + 'if (active.length === 0) process.exit(0);', + 'const shown = active.slice(0, 8);', + "const suffix = active.length > shown.length ? ' and ' + (active.length - shown.length) + ' more' : '';", + 'process.stdout.write(JSON.stringify({', + " decision: 'block',", + " reason: 'You still own active background work (' + shown.join(', ') + suffix + '). Do not return an intermediate progress message as your final report. Process the task notifications or keep actively polling until every task completes, then return one complete summary.',", + '}));', + ].join('\n'); +} + function withSettingsLock(path: string, fn: () => Promise): Promise { const prev = settingsWriteLocks.get(path) ?? Promise.resolve(); const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome @@ -204,6 +332,18 @@ export function generateHooksConfig(): { hooks: Record } { hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_SECONDS }], }, ], + SubagentStop: [ + { + hooks: [ + { + type: 'command', + command: 'node', + args: ['-e', generateSubagentStopGuardScript()], + timeout: HOOK_TIMEOUT_SECONDS, + }, + ], + }, + ], TeammateIdle: [ { hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: HOOK_TIMEOUT_SECONDS }], @@ -235,8 +375,12 @@ export function generateHooksConfig(): { hooks: Record } { function isCodemanHookHandler(value: unknown): boolean { try { const serialized = JSON.stringify(value); - // Prefix, not the versioned marker: older script versions must still be ours. - return serialized.includes('/api/hook-event') || serialized.includes(BACKGROUND_WAKE_MARKER_PREFIX); + // Prefixes, not versioned markers: older script versions must still be ours. + return ( + serialized.includes('/api/hook-event') || + serialized.includes(BACKGROUND_WAKE_MARKER_PREFIX) || + serialized.includes(SUBAGENT_STOP_GUARD_MARKER_PREFIX) + ); } catch { return false; } @@ -430,6 +574,39 @@ export async function writeHooksConfig(casePath: string): Promise { }); } +/** + * Ensures an explicitly managed case has the current Codeman hooks. + * + * Unlike `refreshStaleCodemanHooks`, this may add Codeman handlers to a valid + * user-owned settings file. It is therefore reserved for case quick-starts, + * where the user has explicitly asked Codeman to manage that workspace. A + * malformed existing file is left untouched rather than replaced. + */ +export async function ensureCodemanHooks(casePath: string): Promise { + const claudeDir = join(casePath, '.claude'); + const settingsPath = join(claudeDir, 'settings.local.json'); + await withSettingsLock(settingsPath, async () => { + if (!existsSync(claudeDir)) { + await mkdir(claudeDir, { recursive: true }); + } + + let existing: Record = {}; + try { + const parsed: unknown = JSON.parse(await readFile(settingsPath, 'utf-8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; + existing = parsed as Record; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return; + } + + const generated = generateHooksConfig(); + const hooks = mergeCodemanHooks(existing.hooks, generated.hooks); + if (JSON.stringify(existing.hooks ?? {}) === JSON.stringify(hooks)) return; + + await writeFile(settingsPath, JSON.stringify({ ...existing, hooks }, null, 2) + '\n'); + }); +} + /** * Self-heal a case's Codeman-owned hooks block. * @@ -437,10 +614,11 @@ export async function writeHooksConfig(casePath: string): Promise { * X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their * settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the * gate requires it unconditionally (COD-91), silently 401 on a password-protected install. - * Older Codeman blocks also lack the background Bash async-rewake hook. A third stale - * shape: hook curls without `-k`, which exit 60 on every --https/tailscale install (the - * cert is self-signed), swallowed by the hooks' own `|| true` — all six hook events die - * silently. Refresh any of these stale shapes on launch so existing cases heal. + * Older Codeman blocks also lack the current background Bash async-rewake hook or the + * SubagentStop guard. A further stale shape: hook curls without `-k`, which exit 60 on + * every --https/tailscale install (the cert is self-signed), swallowed by the hooks' + * own `|| true` — all six hook events die silently. Refresh any of these stale shapes + * on launch so existing cases heal. * * Deliberately surgical: regenerates ONLY when settings.local.json already contains * Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op @@ -467,7 +645,8 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise // as a substring, so this cleanly identifies hook curls that die with exit 60 // on a self-signed HTTPS install. const hasTlsFlaglessCurl = hooksJson.includes('curl -s -X POST'); - if (!isOurs || (hasSecret && hasBackgroundWake && !hasTlsFlaglessCurl)) return; + const hasSubagentStopGuard = hooksJson.includes(SUBAGENT_STOP_GUARD_MARKER); + if (!isOurs || (hasSecret && hasBackgroundWake && hasSubagentStopGuard && !hasTlsFlaglessCurl)) return; const generated = generateHooksConfig(); const merged = { ...existing, diff --git a/test/hook-secret-selfheal.test.ts b/test/hook-secret-selfheal.test.ts index 7890cb31..a11c18ac 100644 --- a/test/hook-secret-selfheal.test.ts +++ b/test/hook-secret-selfheal.test.ts @@ -127,7 +127,7 @@ describe('refreshStaleCodemanHooks', () => { const after = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER); - expect(JSON.stringify(after.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V'); + expect(JSON.stringify(after.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V3'); expect(JSON.stringify(after.hooks.Stop)).toContain('./notify-user.sh'); expect(after.hooks.PostToolUse).toEqual(expect.arrayContaining([customPostToolUse])); expect(after.hooks.CustomEvent).toEqual(customEvent); diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 149ffabe..1f751905 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -6,13 +6,15 @@ */ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; -import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawn } from 'node:child_process'; import { + ensureCodemanHooks, generateBackgroundWakeScript, generateHooksConfig, + generateSubagentStopGuardScript, refreshStaleCodemanHooks, writeHooksConfig, } from '../src/hooks-config.js'; @@ -35,6 +37,20 @@ describe('generateHooksConfig', () => { expect(config.hooks.Stop).toHaveLength(1); }); + it('should guard subagent stops while their background work is active', () => { + const config = generateHooksConfig(); + const subagentHooks = config.hooks.SubagentStop as Array<{ + hooks: Array<{ type: string; command: string; args: string[]; timeout: number }>; + }>; + + expect(subagentHooks).toHaveLength(1); + expect(subagentHooks[0].hooks[0]).toMatchObject({ + type: 'command', + command: 'node', + args: ['-e', generateSubagentStopGuardScript()], + }); + }); + it('should configure a self-contained Bash background-task rewake hook', () => { const config = generateHooksConfig(); const postToolHooks = config.hooks.PostToolUse as Array<{ @@ -210,7 +226,8 @@ describe('writeHooksConfig', () => { const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(parsed.hooks.PostToolUse).toHaveLength(1); - expect(JSON.stringify(parsed.hooks.PostToolUse)).toContain('CODEMAN_BACKGROUND_REWAKE_V'); + expect(JSON.stringify(parsed.hooks.PostToolUse)).toContain('CODEMAN_BACKGROUND_REWAKE_V3'); + expect(JSON.stringify(parsed.hooks.SubagentStop)).toContain('CODEMAN_SUBAGENT_STOP_GUARD_V1'); }); it('should replace an older rewake script version without duplicating it', async () => { @@ -242,10 +259,29 @@ describe('writeHooksConfig', () => { const serialized = JSON.stringify(parsed.hooks.PostToolUse); expect(parsed.hooks.PostToolUse).toHaveLength(1); expect(parsed.hooks.PostToolUse[0].hooks).toHaveLength(1); - expect(serialized).toContain('CODEMAN_BACKGROUND_REWAKE_V2'); + expect(serialized).toContain('CODEMAN_BACKGROUND_REWAKE_V3'); expect(serialized).not.toContain('CODEMAN_BACKGROUND_REWAKE_V1'); }); + it('replaces the V2 background hook without duplicating it', async () => { + const claudeDir = join(testDir, '.claude'); + const settingsPath = join(claudeDir, 'settings.local.json'); + mkdirSync(claudeDir, { recursive: true }); + const oldSettings = JSON.stringify({ hooks: generateHooksConfig().hooks }, null, 2).replaceAll( + 'CODEMAN_BACKGROUND_REWAKE_V3', + 'CODEMAN_BACKGROUND_REWAKE_V2' + ); + writeFileSync(settingsPath, oldSettings); + + await refreshStaleCodemanHooks(testDir); + + const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); + const postToolUse = JSON.stringify(parsed.hooks.PostToolUse); + expect(parsed.hooks.PostToolUse).toHaveLength(1); + expect(postToolUse).toContain('CODEMAN_BACKGROUND_REWAKE_V3'); + expect(postToolUse).not.toContain('CODEMAN_BACKGROUND_REWAKE_V2'); + }); + it('should not add rewake hooks to a user-owned hook configuration', async () => { const claudeDir = join(testDir, '.claude'); const settingsPath = join(claudeDir, 'settings.local.json'); @@ -278,6 +314,35 @@ describe('writeHooksConfig', () => { expect(parsed.hooks.Notification).toBeDefined(); }); + it('should safely add Codeman hooks to an existing managed-case settings file', async () => { + const claudeDir = join(testDir, '.claude'); + const settingsPath = join(claudeDir, 'settings.local.json'); + mkdirSync(claudeDir, { recursive: true }); + const userHooks = { + PostToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: './format.sh' }] }], + }; + writeFileSync(settingsPath, JSON.stringify({ hooks: userHooks, permissions: { allow: ['Read'] } }, null, 2)); + + await ensureCodemanHooks(testDir); + + const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(parsed.permissions).toEqual({ allow: ['Read'] }); + expect(parsed.hooks.PostToolUse).toEqual(expect.arrayContaining(userHooks.PostToolUse)); + expect(JSON.stringify(parsed.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V3'); + expect(JSON.stringify(parsed.hooks)).toContain('CODEMAN_SUBAGENT_STOP_GUARD_V1'); + }); + + it('should not replace a malformed managed-case settings file', async () => { + const claudeDir = join(testDir, '.claude'); + const settingsPath = join(claudeDir, 'settings.local.json'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(settingsPath, '{ malformed'); + + await ensureCodemanHooks(testDir); + + expect(readFileSync(settingsPath, 'utf-8')).toBe('{ malformed'); + }); + it('should handle malformed existing settings.local.json', async () => { const claudeDir = join(testDir, '.claude'); mkdirSync(claudeDir, { recursive: true }); @@ -370,6 +435,210 @@ describe('background task rewake helper', () => { expect(result.stderr).toContain('completed'); expect(result.stderr).toContain('/tmp/bg-test-1.output'); }); + + it('rewakes a subagent when Claude queues completion in the parent transcript', async () => { + const sessionId = '7148e9de-7673-48b8-bf38-6799e52c346a'; + const sessionDir = join(testDir, sessionId); + const subagentDir = join(sessionDir, 'subagents'); + const parentTranscriptPath = `${sessionDir}.jsonl`; + const subagentTranscriptPath = join(subagentDir, 'agent-afacts-class2.jsonl'); + mkdirSync(subagentDir, { recursive: true }); + writeFileSync(parentTranscriptPath, ''); + writeFileSync(subagentTranscriptPath, ''); + + const resultPromise = runHelper({ + session_id: sessionId, + agent_id: 'afacts-class2', + transcript_path: subagentTranscriptPath, + tool_response: { + backgroundTaskId: 'bg-subagent-1', + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + writeFileSync( + parentTranscriptPath, + JSON.stringify({ + type: 'queue-operation', + operation: 'enqueue', + content: + '\nbg-subagent-1\ncompleted\n' + + '/tmp/bg-subagent-1.output\n', + }) + '\n' + ); + + const result = await resultPromise; + expect(result.code).toBe(2); + expect(result.stderr).toContain('bg-subagent-1'); + expect(result.stderr).toContain('/tmp/bg-subagent-1.output'); + }); + + it('includes a marked background report in the wake feedback', async () => { + const transcriptPath = join(testDir, 'transcript.jsonl'); + const tasksDir = join(testDir, 'tasks'); + const outputPath = join(tasksDir, 'bg-report-1.output'); + mkdirSync(tasksDir, { recursive: true }); + writeFileSync(transcriptPath, ''); + writeFileSync( + outputPath, + [ + 'launcher output', + '=== CODEMAN_RESULT_BEGIN ===', + 'Summary line', + 'Detail after the old 30-line preview boundary', + '=== CODEMAN_RESULT_END ===', + ].join('\n') + ); + + const resultPromise = runHelper({ + transcript_path: transcriptPath, + tool_response: { + stdout: `Command running in background with ID: bg-report-1. Output is being written to: ${outputPath}.`, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + writeFileSync( + transcriptPath, + JSON.stringify({ + type: 'queue-operation', + operation: 'enqueue', + content: + '\nbg-report-1\ncompleted\n' + + `${outputPath}\n`, + }) + '\n' + ); + + const result = await resultPromise; + expect(result.code).toBe(2); + expect(result.stderr).toContain(''); + expect(result.stderr).toContain('Summary line'); + expect(result.stderr).toContain('Detail after the old 30-line preview boundary'); + }); +}); + +describe('subagent stop guard helper', () => { + const testDir = join(tmpdir(), 'codeman-subagent-stop-guard-test-' + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + function runGuard(transcriptLines: unknown[]): Promise<{ code: number | null; stdout: string; stderr: string }> { + const transcriptPath = join(testDir, 'agent-test.jsonl'); + writeFileSync(transcriptPath, transcriptLines.map((line) => JSON.stringify(line)).join('\n') + '\n'); + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['-e', generateSubagentStopGuardScript()], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(JSON.stringify({ agent_transcript_path: transcriptPath })); + }); + } + + async function withLiveTask(taskId: string, action: () => Promise): Promise { + const tasksDir = join(testDir, 'tasks'); + mkdirSync(tasksDir, { recursive: true }); + const outputFd = openSync(join(tasksDir, `${taskId}.output`), 'a'); + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 10000)'], { + stdio: ['ignore', outputFd, outputFd], + }); + await new Promise((resolve, reject) => { + child.once('spawn', resolve); + child.once('error', reject); + }); + closeSync(outputFd); + + try { + return await action(); + } finally { + const closed = new Promise((resolve) => child.once('close', () => resolve())); + child.kill(); + await closed; + } + } + + const monitorResult = (taskId: string) => ({ + type: 'user', + message: { + content: [ + { + type: 'tool_result', + content: `Monitor started (task ${taskId}, pid 123).`, + }, + ], + }, + }); + + const completion = (taskId: string) => ({ + type: 'user', + message: { + content: + `\n${taskId}\n` + 'completed\n', + }, + }); + + it('blocks an intermediate subagent stop while a sibling monitor is active', async () => { + const result = await withLiveTask('monitor-still-live', () => + runGuard([monitorResult('monitor-first'), monitorResult('monitor-still-live'), completion('monitor-first')]) + ); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ decision: 'block' }); + expect(result.stdout).toContain('monitor-still-live'); + expect(result.stdout).not.toContain('monitor-first,'); + }); + + it('allows a subagent to stop after all of its monitored work finishes', async () => { + const result = await runGuard([ + monitorResult('monitor-first'), + monitorResult('monitor-second'), + completion('monitor-first'), + completion('monitor-second'), + ]); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + }); + + it('also recognizes background Bash task ownership', async () => { + const result = await withLiveTask('bash-live-1', () => + runGuard([ + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + content: 'Command running in background with ID: bash-live-1. Output is being written to a task file.', + }, + ], + }, + }, + ]) + ); + + expect(JSON.parse(result.stdout)).toMatchObject({ decision: 'block' }); + expect(result.stdout).toContain('bash-live-1'); + }); }); // ========== Hook Event API Integration Tests ==========