2026-01-24 02:46:24 +01:00
/**
2026-03-03 22:40:18 +01:00
* @fileoverview Claude Code hooks configuration generator.
2026-01-24 02:46:24 +01:00
*
2026-03-03 22:40:18 +01:00
* Generates `.claude/settings.local.json` with hook definitions that POST
* to Codeman's `/api/hook-event` endpoint when Claude Code fires hooks.
2026-06-10 22:31:09 +02:00
* Uses `$CODEMAN_API_URL`, `$CODEMAN_SESSION_ID`, and `$CODEMAN_HOOK_SECRET_FILE`
* env vars (set on every managed session) so the config is static per case
* directory and free of secret values.
2026-03-03 22:40:18 +01:00
*
* Key exports:
* - `generateHooksConfig()` — returns hooks object for settings.local.json
* - `writeHooksConfig(casePath)` — writes hooks + env config to disk
2026-08-08 22:31:38 +03:00
* - `ensureCodemanHooks(casePath)` — safely installs/updates hooks for a managed case
2026-03-03 22:40:18 +01:00
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
*
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
* `stop`, `teammate_idle`, `task_completed`
*
2026-08-08 22:31:38 +03:00
* Hook categories: `Notification` (3 matchers), `Stop` (1), `SubagentStop` (1),
* `TeammateIdle` (1), `TaskCompleted` (1), `PostToolUse` (1 self-contained
* background Bash rewake)
2026-03-03 22:40:18 +01:00
*
2026-07-31 08:47:52 +02:00
* @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_SECONDS)
2026-03-03 22:40:18 +01:00
* @consumedby web/server (session creation), session-cli-builder (env setup)
*
* @module hooks-config
2026-01-24 02:46:24 +01:00
*/
2026-02-18 07:06:34 +01:00
import { existsSync } from 'node:fs' ;
import { readFile , writeFile , mkdir } from 'node:fs/promises' ;
2026-01-24 02:46:24 +01:00
import { join } from 'node:path' ;
import type { HookEventType } from './types.js' ;
2026-07-31 08:47:52 +02:00
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js' ;
2026-01-24 02:46:24 +01:00
2026-06-14 07:33:52 +02:00
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
* writer in this module (hooks, env, model, statusLine) shares this map, so
* concurrent updates to the SAME file — e.g. session-create writing hooks/model
* while an App-Settings toggle injects the statusLine into the same repo — can't
* lose each other's changes through interleaved read-then-write. Per-path chains
* are independent; the map self-prunes when a path's chain goes idle.
*/
const settingsWriteLocks = new Map < string , Promise < unknown >>();
2026-07-31 08:47:52 +02:00
/**
* Version-agnostic ownership prefix: every rewake script version embeds a marker
* starting with this, and `isCodemanHookHandler` matches on the prefix. That way a
* version bump replaces the old handler instead of duplicating it (matching on the
* full versioned marker would disown every older script).
*/
const BACKGROUND_WAKE_MARKER_PREFIX = 'CODEMAN_BACKGROUND_REWAKE_V' ;
/**
* Current script version. Bump the suffix whenever `generateBackgroundWakeScript`
* changes: `refreshStaleCodemanHooks` treats the absence of the CURRENT marker as
* stale, so healed cases pick up the new script on next launch.
*/
2026-08-08 22:31:38 +03:00
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` ;
2026-07-28 23:16:37 +03:00
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
2026-08-08 22:31:38 +03:00
* 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).
2026-07-28 23:16:37 +03:00
* 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.
2026-07-31 08:47:52 +02:00
*
* Self-terminating: Claude Code enforces the hook timeout, but the helper does not
* rely on it. It exits on its own deadline (same budget) and when orphaned
* (`ppid === 1`), so a dead session cannot leave a poller stat-ing the transcript
* forever. The ppid check misses subreaper setups; the deadline is the backstop.
2026-07-28 23:16:37 +03:00
*/
export function generateBackgroundWakeScript () : string {
return [
"const fs = require('node:fs');" ,
2026-08-08 22:31:38 +03:00
"const path = require('node:path');" ,
2026-07-28 23:16:37 +03:00
`const ${ BACKGROUND_WAKE_MARKER } = true;` ,
2026-07-31 08:47:52 +02:00
`const deadline = Date.now() + ${ BACKGROUND_WAKE_TIMEOUT_SECONDS } * 1000;` ,
2026-08-08 22:31:38 +03:00
"const RESULT_BEGIN = '=== CODEMAN_RESULT_BEGIN ===';" ,
"const RESULT_END = '=== CODEMAN_RESULT_END ===';" ,
'const MAX_RESULT_CHARS = 65536;' ,
2026-07-28 23:16:37 +03:00
'let input = {};' ,
"try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }" ,
'function findTaskId(value) {' ,
" const idKeys = new Set(['taskId', 'task_id', 'shellId', 'shell_id', 'backgroundTaskId', 'background_task_id']);" ,
' const stack = [value];' ,
' const seen = new Set();' ,
' while (stack.length > 0) {' ,
' const current = stack.pop();' ,
" if (!current || typeof current !== 'object' || seen.has(current)) continue;" ,
' seen.add(current);' ,
' for (const [key, nested] of Object.entries(current)) {' ,
" if (idKeys.has(key) && typeof nested === 'string' && /^[A-Za-z0-9_-]+$/.test(nested)) return nested;" ,
" if (nested && typeof nested === 'object') stack.push(nested);" ,
' }' ,
' }' ,
" const serialized = JSON.stringify(value ?? '');" ,
' const messageMatch = serialized.match(/Command running in background with ID:\\s*([A-Za-z0-9_-]+)/i);' ,
' if (messageMatch) return messageMatch[1];' ,
' const pathMatch = serialized.match(/[\\\\/]tasks[\\\\/]([A-Za-z0-9_-]+)\\.output/i);' ,
' return pathMatch ? pathMatch[1] : null;' ,
'}' ,
'const taskId = findTaskId(input.tool_response);' ,
"const transcriptPath = typeof input.transcript_path === 'string' ? input.transcript_path : '';" ,
'if (!taskId || !transcriptPath) process.exit(0);' ,
2026-08-08 22:31:38 +03:00
'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<codeman-background-result>\\n' + result + '\\n</codeman-background-result>';" ,
" } catch { return ''; }" ,
'}' ,
2026-07-28 23:16:37 +03:00
'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; }' ,
2026-08-08 22:31:38 +03:00
" if (entry.type !== 'queue-operation' || entry.operation !== 'enqueue' || typeof entry.content !== 'string') continue;" ,
2026-07-28 23:16:37 +03:00
" if (!entry.content.includes('<task-id>' + taskId + '</task-id>')) continue;" ,
' const status = entry.content.match(/<status>(completed|failed|killed|error)<\\/status>/i);' ,
' if (!status) continue;' ,
' const output = entry.content.match(/<output-file>([^<]+)<\\/output-file>/i);' ,
2026-08-08 22:31:38 +03:00
" 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);" ,
2026-07-28 23:16:37 +03:00
' process.exit(2);' ,
' }' ,
'}' ,
2026-08-08 22:31:38 +03:00
'function pollTranscript(transcript) {' ,
2026-07-28 23:16:37 +03:00
' try {' ,
2026-08-08 22:31:38 +03:00
' 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);' ,
2026-07-28 23:16:37 +03:00
' const buffer = Buffer.allocUnsafe(length);' ,
2026-08-08 22:31:38 +03:00
" const fd = fs.openSync(transcript.path, 'r');" ,
' const bytes = fs.readSync(fd, buffer, 0, length, transcript.position);' ,
2026-07-28 23:16:37 +03:00
' fs.closeSync(fd);' ,
2026-08-08 22:31:38 +03:00
' transcript.position += bytes;' ,
" transcript.carry = (transcript.carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);" ,
' inspect(transcript.carry);' ,
2026-07-28 23:16:37 +03:00
' }' ,
' } catch {}' ,
2026-08-08 22:31:38 +03:00
'}' ,
'function poll() {' ,
' if (Date.now() > deadline || process.ppid === 1) process.exit(0);' ,
' for (const transcript of transcripts) pollTranscript(transcript);' ,
2026-07-28 23:16:37 +03:00
' setTimeout(poll, 1000);' ,
'}' ,
'poll();' ,
]. join ( '\n' );
}
2026-08-08 22:31:38 +03:00
/**
* 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('<task-notification>')) return;" ,
' for (const match of value.matchAll(/<task-notification>([\\s\\S]*?)<\\/task-notification>/gi)) {' ,
' const body = match[1];' ,
' const id = body.match(/<task-id>([^<]+)<\\/task-id>/i);' ,
' const status = body.match(/<status>(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' );
}
2026-06-14 07:33:52 +02:00
function withSettingsLock < T >( path : string , fn : () => Promise < T >) : Promise < T > {
const prev = settingsWriteLocks . get ( path ) ?? Promise . resolve ();
const run = prev . then ( fn , fn ); // run after the prior writer, regardless of its outcome
// Tail never rejects, so a failed write doesn't poison subsequent writers.
const tail = run . then (
() => {},
() => {}
);
settingsWriteLocks . set ( path , tail );
void tail . then (() => {
if ( settingsWriteLocks . get ( path ) === tail ) settingsWriteLocks . delete ( path );
});
return run ;
}
2026-01-24 02:46:24 +01:00
/**
* Generates the hooks section for .claude/settings.local.json
*
2026-01-24 04:35:32 +01:00
* The hook commands read stdin JSON from Claude Code (contains tool_name,
2026-02-26 16:44:34 +01:00
* tool_input, etc.) and forward it as the `data` field to Codeman's API.
2026-01-24 04:35:32 +01:00
* Env vars are resolved at runtime by the shell, so the config is static
* per case directory.
2026-01-24 02:46:24 +01:00
*/
export function generateHooksConfig () : { hooks : Record < string , unknown [] > } {
2026-01-24 04:35:32 +01:00
// Read Claude Code's stdin JSON and forward it as the data field.
// Falls back to empty object if stdin is unavailable or malformed.
2026-06-10 22:31:09 +02:00
// COD-54: present the per-instance hook secret so the bypass keeps working while
// a tunnel is running. The value is read from the secret file AT EXECUTION TIME
// (path via $CODEMAN_HOOK_SECRET_FILE, set in every managed session's env), so it
// never lands in this config and rotation needs no respawn. If the var/file is
// missing the header is empty — the middleware then allows the request only on
// the plain loopback bypass (tunnel down), same as pre-secret behavior.
2026-01-24 02:46:24 +01:00
const curlCmd = ( event : HookEventType ) =>
2026-01-24 04:35:32 +01:00
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
2026-02-28 01:35:37 +01:00
`printf '{"event":" ${ event } ","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
2026-08-09 01:18:03 +02:00
// `-k`, same as the statusline exporter: CODEMAN_API_URL is loopback HTTPS with
// a self-signed cert on --https/tailscale installs. Without it curl exits 60,
// the `|| true` swallows it, and ALL SIX hook events die silently: respawn loses
// its definitive idle signals and the wait endpoints lose stop/blocked.
`curl -sk -X POST "$CODEMAN_API_URL/api/hook-event" ` +
2026-01-24 02:46:24 +01:00
`-H 'Content-Type: application/json' ` +
2026-06-10 22:31:09 +02:00
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
2026-02-28 01:35:37 +01:00
`--data @- ` +
2026-01-24 04:35:32 +01:00
`2>/dev/null || true` ;
2026-01-24 02:46:24 +01:00
return {
hooks : {
Notification : [
{
matcher : 'idle_prompt' ,
2026-07-31 08:47:52 +02:00
hooks : [{ type : 'command' , command : curlCmd ( 'idle_prompt' ), timeout : HOOK_TIMEOUT_SECONDS }],
2026-01-24 02:46:24 +01:00
},
{
matcher : 'permission_prompt' ,
2026-07-31 08:47:52 +02:00
hooks : [{ type : 'command' , command : curlCmd ( 'permission_prompt' ), timeout : HOOK_TIMEOUT_SECONDS }],
2026-01-24 02:46:24 +01:00
},
2026-01-24 03:10:02 +01:00
{
matcher : 'elicitation_dialog' ,
2026-07-31 08:47:52 +02:00
hooks : [{ type : 'command' , command : curlCmd ( 'elicitation_dialog' ), timeout : HOOK_TIMEOUT_SECONDS }],
2026-01-24 03:10:02 +01:00
},
2026-01-24 02:46:24 +01:00
],
Stop : [
{
2026-07-31 08:47:52 +02:00
hooks : [{ type : 'command' , command : curlCmd ( 'stop' ), timeout : HOOK_TIMEOUT_SECONDS }],
2026-01-24 02:46:24 +01:00
},
],
2026-08-08 22:31:38 +03:00
SubagentStop : [
{
hooks : [
{
type : 'command' ,
command : 'node' ,
args : [ '-e' , generateSubagentStopGuardScript ()],
timeout : HOOK_TIMEOUT_SECONDS ,
},
],
},
],
2026-02-12 08:34:51 +01:00
TeammateIdle : [
{
2026-07-31 08:47:52 +02:00
hooks : [{ type : 'command' , command : curlCmd ( 'teammate_idle' ), timeout : HOOK_TIMEOUT_SECONDS }],
2026-02-12 08:34:51 +01:00
},
],
TaskCompleted : [
{
2026-07-31 08:47:52 +02:00
hooks : [{ type : 'command' , command : curlCmd ( 'task_completed' ), timeout : HOOK_TIMEOUT_SECONDS }],
2026-02-12 08:34:51 +01:00
},
],
2026-07-28 23:16:37 +03:00
PostToolUse : [
{
matcher : 'Bash' ,
hooks : [
{
type : 'command' ,
command : 'node' ,
args : [ '-e' , generateBackgroundWakeScript ()],
asyncRewake : true ,
timeout : BACKGROUND_WAKE_TIMEOUT_SECONDS ,
},
],
},
],
2026-01-24 02:46:24 +01:00
},
};
}
2026-07-28 23:16:37 +03:00
function isCodemanHookHandler ( value : unknown ) : boolean {
try {
const serialized = JSON . stringify ( value );
2026-08-08 22:31:38 +03:00
// 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 )
);
2026-07-28 23:16:37 +03:00
} catch {
return false ;
}
}
/**
* Replace only Codeman-owned command handlers while preserving user events,
* matcher entries, and sibling handlers in mixed entries.
*/
function mergeCodemanHooks ( existingValue : unknown , generated : Record < string , unknown [] >) : Record < string , unknown [] > {
const existing =
existingValue && typeof existingValue === 'object' && ! Array . isArray ( existingValue )
? ( existingValue as Record < string , unknown >)
: {};
const merged : Record < string , unknown [] > = {};
for ( const eventName of new Set ([... Object . keys ( existing ), ... Object . keys ( generated )])) {
const existingEntries = Array . isArray ( existing [ eventName ]) ? ( existing [ eventName ] as unknown []) : [];
const generatedEntries = generated [ eventName ];
if ( ! generatedEntries ) {
merged [ eventName ] = existingEntries ;
continue ;
}
const entries : unknown [] = [];
let insertedGenerated = false ;
for ( const entry of existingEntries ) {
if ( ! entry || typeof entry !== 'object' || Array . isArray ( entry )) {
if ( ! isCodemanHookHandler ( entry )) entries . push ( entry );
continue ;
}
const record = entry as Record < string , unknown >;
if ( ! Array . isArray ( record . hooks )) {
if ( isCodemanHookHandler ( record )) {
if ( ! insertedGenerated ) {
entries . push (... generatedEntries );
insertedGenerated = true ;
}
} else {
entries . push ( entry );
}
continue ;
}
const retainedHandlers = record . hooks . filter (( handler ) => ! isCodemanHookHandler ( handler ));
const removedCodemanHandler = retainedHandlers . length !== record . hooks . length ;
if ( removedCodemanHandler && ! insertedGenerated ) {
entries . push (... generatedEntries );
insertedGenerated = true ;
}
if ( retainedHandlers . length > 0 || ! removedCodemanHandler ) {
entries . push ( retainedHandlers . length === record . hooks . length ? entry : { ... record , hooks : retainedHandlers });
}
}
if ( ! insertedGenerated ) entries . push (... generatedEntries );
merged [ eventName ] = entries ;
}
return merged ;
}
2026-04-22 00:01:35 +08:00
/**
* Remove a subset of env keys from .claude/settings.local.json.env if present.
* Used during the disk→tmux-setenv migration: when the caller is actively setting
* a fresh value for a Codeman-managed key, any stale disk entry for THAT KEY is
* superseded and should be removed. Keys NOT in `keysToRemove` are left alone
* (they may be user-managed). No-op if the file/keys don't exist.
*/
export async function stripCaseEnvKeys ( casePath : string , keysToRemove : readonly string []) : Promise < void > {
if ( keysToRemove . length === 0 ) return ;
const settingsPath = join ( casePath , '.claude' , 'settings.local.json' );
2026-06-14 07:33:52 +02:00
await withSettingsLock ( settingsPath , async () => {
if ( ! existsSync ( settingsPath )) return ;
2026-04-22 00:01:35 +08:00
2026-06-14 07:33:52 +02:00
let existing : Record < string , unknown >;
try {
existing = JSON . parse ( await readFile ( settingsPath , 'utf-8' ));
} catch {
return ; // Malformed — don't rewrite it
2026-04-22 00:01:35 +08:00
}
2026-06-14 07:33:52 +02:00
const env = existing . env as Record < string , string > | undefined ;
if ( ! env ) return ;
let changed = false ;
for ( const key of keysToRemove ) {
if ( key in env ) {
delete env [ key ];
changed = true ;
}
}
if ( ! changed ) return ;
existing . env = env ;
await writeFile ( settingsPath , JSON . stringify ( existing , null , 2 ) + '\n' );
});
2026-04-22 00:01:35 +08:00
}
2026-02-12 08:34:51 +01:00
/**
* Updates env vars in .claude/settings.local.json for the given case path.
* Merges with existing env field; removes vars set to empty string.
*/
2026-02-18 07:06:34 +01:00
export async function updateCaseEnvVars ( casePath : string , envVars : Record < string , string >) : Promise < void > {
2026-02-12 08:34:51 +01:00
const claudeDir = join ( casePath , '.claude' );
const settingsPath = join ( claudeDir , 'settings.local.json' );
2026-06-14 07:33:52 +02:00
await withSettingsLock ( settingsPath , async () => {
if ( ! existsSync ( claudeDir )) {
await mkdir ( claudeDir , { recursive : true });
2026-02-12 08:34:51 +01:00
}
2026-06-14 07:33:52 +02:00
let existing : Record < string , unknown > = {};
try {
existing = JSON . parse ( await readFile ( settingsPath , 'utf-8' ));
} catch {
existing = {};
}
const currentEnv = ( existing . env as Record < string , string >) || {};
for ( const [ key , value ] of Object . entries ( envVars )) {
if ( value ) {
currentEnv [ key ] = value ;
} else {
delete currentEnv [ key ];
}
}
existing . env = currentEnv ;
await writeFile ( settingsPath , JSON . stringify ( existing , null , 2 ) + '\n' );
});
2026-02-12 08:34:51 +01:00
}
2026-03-28 16:49:55 +01:00
/**
* Updates the `model` field in .claude/settings.local.json for the given case path.
* Pass a non-empty string to set, or empty/null to remove.
*/
export async function updateCaseModel ( casePath : string , model : string | null ) : Promise < void > {
const claudeDir = join ( casePath , '.claude' );
const settingsPath = join ( claudeDir , 'settings.local.json' );
2026-06-14 07:33:52 +02:00
await withSettingsLock ( settingsPath , async () => {
if ( ! existsSync ( claudeDir )) {
await mkdir ( claudeDir , { recursive : true });
}
2026-03-28 16:49:55 +01:00
2026-06-14 07:33:52 +02:00
let existing : Record < string , unknown > = {};
try {
existing = JSON . parse ( await readFile ( settingsPath , 'utf-8' ));
} catch {
existing = {};
}
2026-03-28 16:49:55 +01:00
2026-06-14 07:33:52 +02:00
if ( model ) {
existing . model = model ;
} else {
delete existing . model ;
}
2026-03-28 16:49:55 +01:00
2026-06-14 07:33:52 +02:00
await writeFile ( settingsPath , JSON . stringify ( existing , null , 2 ) + '\n' );
});
2026-03-28 16:49:55 +01:00
}
2026-01-24 02:46:24 +01:00
/**
* Writes hooks config to .claude/settings.local.json in the given case path.
* Merges with existing file content, only touching the `hooks` key.
*/
2026-02-18 07:06:34 +01:00
export async function writeHooksConfig ( casePath : string ) : Promise < void > {
2026-01-24 02:46:24 +01:00
const claudeDir = join ( casePath , '.claude' );
const settingsPath = join ( claudeDir , 'settings.local.json' );
2026-06-14 07:33:52 +02:00
await withSettingsLock ( settingsPath , async () => {
if ( ! existsSync ( claudeDir )) {
await mkdir ( claudeDir , { recursive : true });
}
2026-01-24 02:46:24 +01:00
2026-06-14 07:33:52 +02:00
let existing : Record < string , unknown > = {};
try {
existing = JSON . parse ( await readFile ( settingsPath , 'utf-8' ));
} catch {
// If file is malformed or doesn't exist, start fresh
existing = {};
}
2026-01-24 02:46:24 +01:00
2026-06-14 07:33:52 +02:00
const hooksConfig = generateHooksConfig ();
2026-07-28 23:16:37 +03:00
const merged = {
... existing ,
hooks : mergeCodemanHooks ( existing . hooks , hooksConfig . hooks ),
};
2026-01-24 02:46:24 +01:00
2026-06-14 07:33:52 +02:00
await writeFile ( settingsPath , JSON . stringify ( merged , null , 2 ) + '\n' );
});
2026-01-24 02:46:24 +01:00
}
2026-06-14 05:00:28 +02:00
2026-08-08 22:31:38 +03:00
/**
* 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 < void > {
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 < string , unknown > = {};
try {
const parsed : unknown = JSON . parse ( await readFile ( settingsPath , 'utf-8' ));
if ( ! parsed || typeof parsed !== 'object' || Array . isArray ( parsed )) return ;
existing = parsed as Record < string , unknown >;
} 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' );
});
}
2026-06-14 22:35:29 +02:00
/**
2026-07-28 23:16:37 +03:00
* Self-heal a case's Codeman-owned hooks block.
2026-06-14 22:35:29 +02:00
*
* `writeHooksConfig` only runs when a case is first CREATED. Cases created before the
* 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
2026-07-28 23:16:37 +03:00
* gate requires it unconditionally (COD-91), silently 401 on a password-protected install.
2026-08-09 02:21:52 +02:00
* 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.
2026-06-14 22:35:29 +02:00
*
* Deliberately surgical: regenerates ONLY when settings.local.json already contains
2026-07-28 23:16:37 +03:00
* Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op
* when the file/hooks are absent (we never impose hooks on a user who removed them) or
* when the hooks aren't ours, so it is cheap enough to call on every Claude spawn.
2026-06-14 22:35:29 +02:00
*/
2026-07-28 23:16:37 +03:00
export async function refreshStaleCodemanHooks ( casePath : string ) : Promise < void > {
2026-06-14 22:35:29 +02:00
const settingsPath = join ( casePath , '.claude' , 'settings.local.json' );
if ( ! existsSync ( settingsPath )) return ;
await withSettingsLock ( settingsPath , async () => {
let existing : Record < string , unknown >;
try {
existing = JSON . parse ( await readFile ( settingsPath , 'utf-8' ));
} catch {
return ; // malformed — leave it untouched (case-create owns the happy path)
}
const hooksJson = JSON . stringify ( existing . hooks ?? null );
const isOurs = hooksJson . includes ( '/api/hook-event' );
// The generated curl carries this header literal (see generateHooksConfig); its
// absence on our own hooks means they predate COD-54 and need regenerating.
const hasSecret = hooksJson . includes ( 'X-Codeman-Hook-Secret' );
2026-07-28 23:16:37 +03:00
const hasBackgroundWake = hooksJson . includes ( BACKGROUND_WAKE_MARKER );
2026-08-09 01:18:03 +02:00
// The pre--k curl shape: `curl -sk -X POST` does not contain `curl -s -X POST`
// 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' );
2026-08-08 22:31:38 +03:00
const hasSubagentStopGuard = hooksJson . includes ( SUBAGENT_STOP_GUARD_MARKER );
2026-08-09 02:21:52 +02:00
if ( ! isOurs || ( hasSecret && hasBackgroundWake && hasSubagentStopGuard && ! hasTlsFlaglessCurl )) return ;
2026-07-28 23:16:37 +03:00
const generated = generateHooksConfig ();
const merged = {
... existing ,
hooks : mergeCodemanHooks ( existing . hooks , generated . hooks ),
};
2026-06-14 22:35:29 +02:00
await writeFile ( settingsPath , JSON . stringify ( merged , null , 2 ) + '\n' );
});
}
2026-06-14 05:00:28 +02:00
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
const STATUSLINE_MARKER = '/api/status-telemetry' ;
/**
* The plan-usage statusLine exporter command. Mirrors the hook `curlCmd` pattern:
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
* (present in every managed session via tmux setenv), so the config is static.
*/
export function generateStatusLineCommand () : string {
2026-06-14 05:44:54 +02:00
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
// production setup; without -k curl returns 000 and the statusline shows
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
// footer is never blank if Codeman is unreachable.
2026-06-14 05:00:28 +02:00
return (
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
2026-06-14 05:44:54 +02:00
`curl -sk -X POST "$CODEMAN_API_URL ${ STATUSLINE_MARKER } " ` +
2026-06-14 05:00:28 +02:00
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
2026-06-14 05:44:54 +02:00
`--data @- 2>/dev/null || echo codeman`
2026-06-14 05:00:28 +02:00
);
}
/**
* Add or remove Codeman's plan-usage statusLine exporter in
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
* (command targets `/api/status-telemetry`), so a user's hand-authored
2026-06-14 07:33:52 +02:00
* statusLine is never removed OR overwritten — on both the enable and disable
* paths we bail out when an existing statusLine isn't ours. Callers gate on
* Claude mode. Merges, preserving all other keys (hooks, env, model).
2026-06-14 05:00:28 +02:00
*/
export async function applyStatusLineConfig ( casePath : string , enabled : boolean ) : Promise < void > {
const claudeDir = join ( casePath , '.claude' );
const settingsPath = join ( claudeDir , 'settings.local.json' );
2026-06-14 07:33:52 +02:00
await withSettingsLock ( settingsPath , async () => {
let existing : Record < string , unknown > = {};
if ( existsSync ( settingsPath )) {
try {
existing = JSON . parse ( await readFile ( settingsPath , 'utf-8' ));
} catch {
return ; // Malformed — don't rewrite it
}
2026-06-14 05:00:28 +02:00
}
2026-06-14 07:33:52 +02:00
const current = existing . statusLine as { command? : unknown } | undefined ;
const isOurs = !! current && typeof current . command === 'string' && current . command . includes ( STATUSLINE_MARKER );
2026-06-14 05:00:28 +02:00
2026-06-14 07:33:52 +02:00
if ( enabled ) {
const desired = generateStatusLineCommand ();
if ( isOurs && current ? . command === desired ) return ; // already current — skip rewrite
if ( current && ! isOurs ) return ; // user has their OWN statusLine — never clobber it
if ( ! existsSync ( claudeDir )) await mkdir ( claudeDir , { recursive : true });
existing . statusLine = { type : 'command' , command : desired }; // add, or update an out-of-date ours
} else {
if ( ! isOurs ) return ; // nothing of ours to remove (leave a user's own statusLine alone)
delete existing . statusLine ;
}
2026-06-14 05:00:28 +02:00
2026-06-14 07:33:52 +02:00
await writeFile ( settingsPath , JSON . stringify ( existing , null , 2 ) + '\n' );
});
2026-06-14 05:00:28 +02:00
}