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-16 20:27:38 +02:00
* - `applyWorkspaceHooks(workspace, install?)` — the ONE install-vs-refresh decision
* point every claude-session create path routes through (see its doc comment)
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`,
2026-08-09 14:04:34 +02:00
* `elicitation_complete`, `elicitation_response`, `stop`, `teammate_idle`,
* `task_completed`
2026-03-03 22:40:18 +01:00
*
2026-08-09 14:04:34 +02:00
* Hook categories: `Notification` (5 matchers), `Stop` (1), `SubagentStop` (1),
2026-08-08 22:31:38 +03:00
* `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-08-09 11:49:29 +02:00
import { randomBytes } from 'node:crypto' ;
2026-08-30 17:31:25 -05:00
import { readFile , writeFile , mkdir , lstat , readdir , realpath , rename , unlink , rmdir , chmod } from 'node:fs/promises' ;
2026-08-14 14:46:23 +02:00
import { homedir } from 'node:os' ;
2026-08-09 03:30:15 +02:00
import { join , dirname } from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
2026-01-24 02:46:24 +01:00
import type { HookEventType } from './types.js' ;
2026-10-11 07:10:41 +02:00
import type { AgentSkillDir , CliCapabilities } from './config/cli-registry/types.js' ;
2026-07-31 08:47:52 +02:00
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js' ;
2026-08-16 20:27:38 +02:00
import { dataPath } from './config/instance.js' ;
2026-09-07 20:37:29 -05:00
import { readJsonConfig , SETTINGS_PATH } from './web/route-helpers.js' ;
2026-10-04 20:30:40 -04:00
import { isNearStalledPath , probePath } from './utils/index.js' ;
2026-10-01 13:58:55 -04:00
/**
2026-10-04 20:30:40 -04:00
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
* which gives up after a timeout and answers "unknown", this waits for the real
* answer: only ENOENT reads as absent, anything else throws, so a
2026-10-01 13:58:55 -04:00
* stalled or unreadable workspace can never be mistaken for an empty one and
* have its settings recreated over the top. It is async, so a dead mount ties
* up a threadpool worker rather than the event loop.
*/
async function pathExistsForWrite ( path : string ) : Promise < boolean > {
try {
await lstat ( path );
return true ;
} catch ( err ) {
if (( err as NodeJS . ErrnoException ). code === 'ENOENT' ) return false ;
throw err ;
}
}
2026-01-24 02:46:24 +01:00
2026-10-05 20:00:03 +02:00
/**
* Probe a path a per-spawn helper is about to touch. An "unknown" that is NOT near a
* stalled probe (the bulk cap refused it, or the stat failed with something other
* than ENOENT) gets ONE more bounded probe past the bulk cap, so a healthy path still
* answers while unrelated mounts are dead. Whatever is still "unknown" after that
* must be skipped by the caller, never touched with an unbounded `lstat`/`readFile`:
* on a dead mount those never settle, and each would hold a threadpool worker the
* probe's ceiling does not count.
*/
async function probeBeforeTouching ( path : string ) {
const state = await probePath ( path );
if ( state !== 'unknown' || isNearStalledPath ( path )) return state ;
return probePath ( path , { pastCap : true });
}
2026-10-04 20:30:40 -04:00
/**
* Whether a READ-side helper should leave `path` alone: it is definitely absent, or
2026-10-05 20:00:03 +02:00
* it did not answer (a mount that is not responding, a refused probe, an unreadable
* path). See `probeBeforeTouching` for why "unknown" is a skip.
2026-10-04 20:30:40 -04:00
*/
async function absentOrUnreachable ( path : string ) : Promise < 'absent' | 'unreachable' | false > {
2026-10-05 20:00:03 +02:00
const state = await probeBeforeTouching ( path );
2026-10-04 20:30:40 -04:00
if ( state === 'absent' ) return 'absent' ;
2026-10-05 20:00:03 +02:00
if ( state === 'unknown' ) return 'unreachable' ;
2026-10-04 20:30:40 -04:00
return false ;
}
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.
2026-08-09 11:49:29 +02:00
*
* The agent-skill injector keys the same map on its skill DIRECTORY, which can never
* collide with a settings-file path, so those writers serialize against each other too.
2026-06-14 07:33:52 +02:00
*/
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-08-10 00:28:50 +02:00
/**
* Why writing into `<casePath>/.claude/settings.local.json` must NOT proceed,
* or null when it is safe.
*
* Case contents can be FOREIGN (a freshly cloned repository, an imported
* tree): `.claude` or the settings file itself can arrive as a symlink
* pointing anywhere on this machine, and `writeFile` follows links, so a
* scaffold write would land outside the case, up to and including replacing
* the user's own `~/.claude/settings.json` (#251 review). Any symlink in the
* chain, or a `.claude` that resolves outside the case, refuses the write.
* A missing `.claude` is fine (the writer creates it).
*/
export async function settingsWriteBlocker ( casePath : string ) : Promise < string | null > {
const claudeDir = join ( casePath , '.claude' );
try {
const dirStat = await lstat ( claudeDir ). catch (() => null );
if ( dirStat ? . isSymbolicLink ()) return 'its .claude is a symlink' ;
if ( dirStat && ! dirStat . isDirectory ()) return 'its .claude is a file, not a directory' ;
if ( dirStat && ( await realpath ( claudeDir )) !== join ( await realpath ( casePath ), '.claude' )) {
return 'its .claude directory resolves outside the case' ;
}
const settingsStat = await lstat ( join ( claudeDir , 'settings.local.json' )). catch (() => null );
if ( settingsStat ? . isSymbolicLink ()) return 'its .claude/settings.local.json is a symlink' ;
} catch ( err ) {
return `its .claude paths could not be verified ( ${ String ( err ) } )` ;
}
return null ;
}
/**
* The ONE gate for writing `<casePath>/.claude/settings.local.json`.
*
* Serializes writers per path (withSettingsLock) and, INSIDE the lock, refuses
* the write when `settingsWriteBlocker` reports the target unsafe. Every
* settings writer in this module must go through here rather than calling
* `writeFile` on the settings path itself, so a repository-controlled symlink
* can never redirect ANY of them outside the case (#251 review: the guard
* originally covered only two writers, and applyStatusLineConfig was shown
* writing through a symlinked settings file). Refusal is a console.warn, not
* a throw: hooks/statusline degrade gracefully and the session still runs.
*/
async function withSafeSettingsWrite (
casePath : string ,
purpose : string ,
fn : ( claudeDir : string , settingsPath : string ) => Promise < void >
) : Promise < void > {
const claudeDir = join ( casePath , '.claude' );
const settingsPath = join ( claudeDir , 'settings.local.json' );
await withSettingsLock ( settingsPath , async () => {
const blocker = await settingsWriteBlocker ( casePath );
if ( blocker ) {
console . warn ( `[hooks-config] Refusing to write ${ purpose } for ${ casePath } : ${ blocker } ` );
return ;
}
await fn ( claudeDir , settingsPath );
});
}
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-09-01 12:33:24 +08:00
const curlCmd = ( event : HookEventType , options : { discardStdout? : boolean } = {}) =>
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.
2026-09-01 12:33:24 +08:00
`curl -sk ${ options . discardStdout ? '-o /dev/null ' : '' } -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
2026-09-01 12:33:24 +08:00
// The same POST with stdout DISCARDED, via curl's own `-o`. UserPromptSubmit is
// one of the hook events whose stdout Claude Code injects into the model's
// context (the CLI's own hook reference: "Exit code 0 - stdout shown to
// Claude"), so an undiscarded curl pastes Codeman's `{"success":true,…}`
// envelope into the user's prompt on every single turn.
// ⚠️ It MUST be curl's flag, not a trailing redirect. `curlCmd` already ends
// `… 2>/dev/null || true`, and in `pipeline || true >/dev/null` the shell binds
// the redirection to `true` — which never runs on the success path — so the
// envelope still reaches stdout. Verified in dash and bash.
// ⚠️ The flag is opt-in so the other events' command text stays byte-identical:
// their stdout feeds the SSE stream harmlessly, and changing it would rewrite
// every workspace's settings file for no gain.
const curlCmdSilent = ( event : HookEventType ) => curlCmd ( event , { discardStdout : 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-08-09 14:04:34 +02:00
// The two dialog-closed notifications resolve Approvals Inbox items the
// moment a question is answered IN the terminal (long before `stop`).
{
matcher : 'elicitation_complete' ,
hooks : [{ type : 'command' , command : curlCmd ( 'elicitation_complete' ), timeout : HOOK_TIMEOUT_SECONDS }],
},
{
matcher : 'elicitation_response' ,
hooks : [{ type : 'command' , command : curlCmd ( 'elicitation_response' ), timeout : HOOK_TIMEOUT_SECONDS }],
},
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-09-01 12:33:24 +08:00
// The pane's LIVE conversation id, reported by the CLI process itself.
// Without it the response viewer has to guess which `<uuid>.jsonl` a pane
// is on after a `/clear`, and the only anchor it can guess from is an
// Enter that went THROUGH Codeman — so a user who attaches to tmux
// directly never gets one and stays pinned to the launch conversation.
UserPromptSubmit : [
{
hooks : [{ type : 'command' , command : curlCmdSilent ( 'prompt_submitted' ), timeout : HOOK_TIMEOUT_SECONDS }],
},
],
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 ;
2026-08-10 00:28:50 +02:00
await withSafeSettingsWrite ( casePath , 'env-key removal' , async ( _claudeDir , settingsPath ) => {
2026-10-04 20:30:40 -04:00
if ( ! ( await pathExistsForWrite ( 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-08-10 00:28:50 +02:00
await withSafeSettingsWrite ( casePath , 'env vars' , async ( claudeDir , settingsPath ) => {
2026-10-01 13:58:55 -04:00
if ( ! ( await pathExistsForWrite ( claudeDir ))) {
2026-06-14 07:33:52 +02:00
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 > {
2026-08-10 00:28:50 +02:00
await withSafeSettingsWrite ( casePath , 'model' , async ( claudeDir , settingsPath ) => {
2026-10-01 13:58:55 -04:00
if ( ! ( await pathExistsForWrite ( claudeDir ))) {
2026-06-14 07:33:52 +02:00
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-08-10 00:07:01 +02:00
* Refuses (with a console.warn, not a throw: hooks degrade to output-based
* idle detection) when `settingsWriteBlocker` reports the target unsafe.
2026-01-24 02:46:24 +01:00
*/
2026-02-18 07:06:34 +01:00
export async function writeHooksConfig ( casePath : string ) : Promise < void > {
2026-08-10 00:28:50 +02:00
await withSafeSettingsWrite ( casePath , 'hooks' , async ( claudeDir , settingsPath ) => {
2026-10-01 13:58:55 -04:00
if ( ! ( await pathExistsForWrite ( claudeDir ))) {
2026-06-14 07:33:52 +02:00
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
/**
2026-08-16 04:46:01 +02:00
* Ensures a workspace Codeman is about to run Claude in has the current Codeman hooks.
2026-08-08 22:31:38 +03:00
*
2026-08-16 04:46:01 +02:00
* Unlike `refreshStaleCodemanHooks`, this may ADD Codeman handlers to a settings
* file that has none (a linked case, a cloned repo, any directory Codeman did not
* scaffold). It merges rather than replaces, so a user's own hook entries survive,
* and a malformed existing file is left untouched rather than replaced.
2026-08-09 11:49:29 +02:00
*
2026-08-16 04:46:01 +02:00
* ⚠️ That "may add" is a deliberate POLICY, adopted 2026-08-15 after the symptom it
* causes was reported: hooks were only ever written when Codeman CREATED a case
* directory, so every session in a linked case ran with no hooks at all and each
* hook-driven surface was silently dead there — an AskUserQuestion dialog blocking
* the pane while the tab and the phone overview both read a calm `idle`, no
* Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn, and
* no `stop`/`blocked` for the agent wait endpoints. The cost of the policy is the
* other direction: a user who DELETES Codeman's hooks from a workspace gets them
* back on the next session create there, because nothing on disk distinguishes
* "removed on purpose" from "never had any".
*
* Called from both session-create paths (`POST /api/sessions`, `POST /api/quick-start`)
* for claude mode, and from `restoreMuxSessions()` so sessions that predate this heal
* on the next server start. Claude Code re-reads the file, so a session ALREADY running
* in the workspace picks the hooks up without a restart (verified live, 2026-08-15).
2026-08-08 22:31:38 +03:00
*/
export async function ensureCodemanHooks ( casePath : string ) : Promise < void > {
2026-08-10 00:28:50 +02:00
await withSafeSettingsWrite ( casePath , 'hooks (ensure)' , async ( claudeDir , settingsPath ) => {
2026-10-01 13:58:55 -04:00
if ( ! ( await pathExistsForWrite ( claudeDir ))) {
2026-08-08 22:31:38 +03:00
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-10-04 20:30:40 -04:00
if ( await absentOrUnreachable ( join ( casePath , '.claude' , 'settings.local.json' ))) return ;
2026-08-10 00:28:50 +02:00
await withSafeSettingsWrite ( casePath , 'hooks (refresh)' , async ( _claudeDir , settingsPath ) => {
2026-06-14 22:35:29 +02:00
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 14:04:34 +02:00
// Approvals Inbox needs the elicitation_complete/elicitation_response
// matchers; their absence marks a pre-inbox hooks block.
const hasElicitationComplete = hooksJson . includes ( 'elicitation_complete' );
2026-09-01 12:33:24 +08:00
// The UserPromptSubmit event is what gives a tmux-driven pane a first-hand
// conversation id; its absence marks a pre-prompt_submitted hooks block.
// ⚠️ No surrounding quotes: `hooksJson` is JSON.stringify'd, so the marker
// inside the command reads \"prompt_submitted\" and a quoted needle never
// matches — which would make this gate permanently false and rewrite every
// workspace's settings file on every Claude spawn. The sibling markers are
// quote-free for the same reason.
const hasPromptSubmit = hooksJson . includes ( 'prompt_submitted' );
2026-08-09 14:04:34 +02:00
if (
! isOurs ||
2026-09-01 12:33:24 +08:00
( hasSecret &&
hasBackgroundWake &&
hasSubagentStopGuard &&
hasElicitationComplete &&
hasPromptSubmit &&
! hasTlsFlaglessCurl )
2026-08-09 14:04:34 +02:00
)
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-08-16 20:27:38 +02:00
/**
* Hooks for the workspace a Claude session is about to run in. ONE decision point,
* shared by every claude-session create path — the interactive routes, quick-start,
* cron fires, legacy scheduled runs, the plan-orchestrator one-shots, and the boot
* recovery sweep — so the `workspaceHooksEnabled` setting cannot apply to some of
* them only.
*
* ON (the default): INSTALL Codeman's hooks block (`ensureCodemanHooks`), merging so
* a user's own hook entries and every other settings key survive. Hooks used to be
* written only when Codeman CREATED the case DIRECTORY, so a linked case or any
* pre-existing repo — where most sessions actually run — had none, and every
* hook-driven surface was silently dead there (full history on `ensureCodemanHooks`).
*
* OFF: the older, narrower behavior. A Codeman block that is already there is still
* refreshed when stale (COD-91: a pre-secret block 401s once the hook-secret gate
* went unconditional), but one is never added, so Codeman leaves the repo alone.
*
* `install` overrides the setting read: route handlers resolve it through their
* ConfigPort (`ctx.getWorkspaceHooksEnabled()`, which tests stub), and the boot sweep
* passes `true` after checking the setting once for its whole batch. Every other
* caller omits it and the synced setting is read from settings.json here — default ON
* when the key is absent or the file unreadable, matching the server's resolver.
*
* Callers gate on their own context (claude mode only; local — never a remote
* workingDir, which is a path on ANOTHER host, and never a docker case that opted
* out of hooks). The guards EVERY caller needs live here instead:
* - a workspace that does not exist is skipped — `ensureCodemanHooks` mkdir -p's,
* so a deleted repo whose tmux session survived would otherwise be resurrected
* as an empty directory tree holding only `.claude/settings.local.json`;
* - errors are swallowed — a session create must never fail on hooks.
*/
export async function applyWorkspaceHooks ( workspace : string , install? : boolean ) : Promise < void > {
try {
2026-10-05 20:00:03 +02:00
// "absent" stays absent: the install below would mkdir -p a deleted repo back
// into being. "unknown" is skipped too, never asked again with an unbounded
// lstat (see probeBeforeTouching).
const state = await probeBeforeTouching ( workspace );
2026-10-05 09:51:27 -04:00
if ( state === 'absent' ) return ;
if ( state === 'unknown' ) {
2026-10-05 20:00:03 +02:00
console . warn (
`[hooks] ${ workspace } is not responding or not readable (unreachable mount?); Codeman hooks not checked or installed`
);
return ;
2026-10-04 20:30:40 -04:00
}
2026-08-16 20:27:38 +02:00
const shouldInstall = install ?? ( await readWorkspaceHooksEnabled ());
await ( shouldInstall ? ensureCodemanHooks ( workspace ) : refreshStaleCodemanHooks ( workspace ));
} catch {
// Best-effort by contract (see doc comment): hooks degrade to output-based
// idle detection; the create goes ahead.
}
}
/**
* The synced `workspaceHooksEnabled` app setting, read straight from settings.json
* for callers that live outside the web layer (cron, scheduled runs, the plan
* orchestrator). Default ON: an absent key means a user who has never seen the
* setting, and OFF for them would mean no tab alerts, no Approvals Inbox and no
* respawn idle signals in every workspace Codeman did not scaffold itself.
*/
async function readWorkspaceHooksEnabled () : Promise < boolean > {
try {
const parsed = JSON . parse ( await readFile ( dataPath ( 'settings.json' ), 'utf-8' )) as Record < string , unknown >;
return parsed . workspaceHooksEnabled !== false ;
} catch {
return true ;
}
}
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-09-14 15:56:41 +02:00
// `curl -sfk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
2026-06-14 05:44:54 +02:00
// production setup; without -k curl returns 000 and the statusline shows
2026-09-14 15:56:41 +02:00
// nothing. -k is safe here (loopback only); -f keeps an HTTP error body off
// the statusline. On any failure it prints NOTHING: the old `|| echo codeman`
// is the bare word that a hand-run `claude` in a managed repo rendered, and
// that reads as a broken config (discussion #405).
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-09-14 15:56:41 +02:00
`curl -sfk -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-09-14 15:56:41 +02:00
`--data @- 2>/dev/null || true`
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 > {
2026-08-10 00:28:50 +02:00
await withSafeSettingsWrite ( casePath , 'statusLine' , async ( claudeDir , settingsPath ) => {
2026-06-14 07:33:52 +02:00
let existing : Record < string , unknown > = {};
2026-10-01 13:58:55 -04:00
if ( await pathExistsForWrite ( settingsPath )) {
2026-06-14 07:33:52 +02:00
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
2026-10-01 13:58:55 -04:00
if ( ! ( await pathExistsForWrite ( claudeDir ))) await mkdir ( claudeDir , { recursive : true });
2026-06-14 07:33:52 +02:00
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
}
2026-08-09 03:30:15 +02:00
2026-08-30 17:31:25 -05:00
/**
* Version-agnostic marker embedded as a comment in the generated exporter
* SCRIPT (see ensureStatusLineExporterScript) — bump the numeric suffix
* whenever the script content changes so `ensureStatusLineExporterScript`'s
* content comparison rewrites stale copies on next use.
*/
2026-09-14 15:56:41 +02:00
const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V4' ;
2026-08-30 17:31:25 -05:00
function statusLineExporterScriptContent () : string {
2026-09-07 20:37:29 -05:00
// Where the telemetry POST runs depends on who owns the footer. When the pane's
// env carries CODEMAN_USER_STATUSLINE_CMD (set via tmux setenv by TmuxManager
// when findEffectiveUserStatusLineCommand found the user's own REAL statusLine —
// see that function's doc comment), the user's command owns the footer, so the
// POST runs in a BACKGROUND subshell with stdin/stdout/stderr all closed
// (`>/dev/null 2>&1 </dev/null &`) — closing stdout/stderr keeps it from adding
// latency or leaking into the visible statusline, and closing stdin too is what
// lets a host reading this script's own stdout to EOF (`sh script | cat`) see
// that EOF promptly: without it the backgrounded curl keeps the pipe's write end
// open until IT exits, so the reader blocks for however long curl takes (measured
// ~5s with a stand-in) instead of the ~9ms it takes once stdin is closed too.
// Absent a user statusline, NOTHING else will print the footer, so the POST runs
// in the FOREGROUND and ITS OWN stdout becomes the footer — `/api/status-telemetry`
// returns formatSessionStatusText(...) (model/tokens/context %) precisely so this
2026-09-14 15:56:41 +02:00
// can happen. If curl itself fails (refused/unreachable Codeman, or an HTTP
// error, which `-f` keeps off stdout) the footer is simply EMPTY (`|| true`):
// the old `|| echo codeman` rendered a bare brand word that reads as a broken
// config, the symptom discussion #405 opened with. `--max-time` bounds a
2026-09-07 20:37:29 -05:00
// HUNG (not just refused) Codeman so it cannot wedge the render indefinitely.
const post =
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
2026-09-14 15:56:41 +02:00
`curl -sfk --max-time 5 -X POST "$CODEMAN_API_URL ${ STATUSLINE_MARKER } " ` +
2026-09-07 20:37:29 -05:00
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @-` ;
2026-08-30 17:31:25 -05:00
return (
`#!/bin/sh \ n` +
`# ${ STATUSLINE_EXPORTER_SCRIPT_MARKER } — auto-generated by Codeman; safe to delete, regenerated on demand. \ n` +
`INPUT=$(cat 2>/dev/null || echo '{}') \ n` +
2026-08-30 18:56:32 -05:00
`if [ -n "$CODEMAN_USER_STATUSLINE_CMD" ]; then \ n` +
2026-09-07 20:37:29 -05:00
` ( ${ post } ) >/dev/null 2>&1 </dev/null & \ n` +
2026-08-30 18:56:32 -05:00
` printf '%s' "$INPUT" | sh -c "$CODEMAN_USER_STATUSLINE_CMD" \ n` +
`else \ n` +
2026-09-14 15:56:41 +02:00
` ${ post } 2>/dev/null || true \ n` +
2026-08-30 18:56:32 -05:00
`fi \ n`
2026-08-30 17:31:25 -05:00
);
}
2026-08-30 18:56:32 -05:00
async function readStatusLineCommandFromFile ( settingsPath : string ) : Promise < string | undefined > {
2026-10-04 20:30:40 -04:00
if ( await absentOrUnreachable ( settingsPath )) return undefined ;
2026-08-30 18:56:32 -05:00
try {
const parsed = JSON . parse ( await readFile ( settingsPath , 'utf-8' ));
const current = parsed . statusLine as { command? : unknown } | undefined ;
return current && typeof current . command === 'string' ? current.command : undefined ;
} catch {
return undefined ; // Malformed — treat as absent, same posture as applyStatusLineConfig.
}
}
/**
* Walk Claude Code's OWN settings precedence for `workingDir` to find whatever
* statusLine command is ACTUALLY effective there right now: project-local
* `.claude/settings.local.json` > project-shared `.claude/settings.json` >
* the user's global `~/.claude/settings.json`. Returns undefined when none of
* the three configures one.
*
* A legacy Codeman-marked entry in the project's OWN settings.local.json
* (written by an older build's disk-based mechanism) is never treated as a
* real user command — resolveStatusLineCliCommand strips it before this ever
* runs, so ordinarily this function never even sees one; the marker check
* here is a second, defensive guard in case something else wrote a copy in
* between, and precedence simply continues to the next layer instead of
* stopping on it.
*/
export async function findEffectiveUserStatusLineCommand ( workingDir : string ) : Promise < string | undefined > {
const projectLocal = await readStatusLineCommandFromFile ( join ( workingDir , '.claude' , 'settings.local.json' ));
if ( projectLocal && ! projectLocal . includes ( STATUSLINE_MARKER )) return projectLocal ;
const projectShared = await readStatusLineCommandFromFile ( join ( workingDir , '.claude' , 'settings.json' ));
if ( projectShared ) return projectShared ;
return readStatusLineCommandFromFile ( join ( homedir (), '.claude' , 'settings.json' ));
}
2026-08-30 17:31:25 -05:00
/**
* Write (or refresh) the SHARED, single exporter script every claude session
* points its ephemeral --settings statusLine flag at, and return its absolute
* path. Idempotent: only rewrites when the marker-versioned content differs.
*
* This is the fix for a real bug found live 2026-08-31: the exporter's
* command string legitimately depends on `$CODEMAN_SESSION_ID`,
* `$CODEMAN_API_URL`, `$CODEMAN_HOOK_SECRET_FILE`, and its own internal
* `$INPUT` — all meant to be expanded ONLY when Claude Code itself finally
* executes the statusLine command, using the PANE's tmux-setenv'd
* environment. Passing that command as literal TEXT through
* `--settings '...'` routes it through this server's OWN spawn-time shell
* layers first (tmux respawn-pane's `bash -c "..."`, itself invoked via
* execSync's implicit `/bin/sh -c`) — and POSIX double quotes do NOT
* suppress `$` expansion, so those vars got expanded there and then, against
* the SERVER process's environment (where they are unset), producing a
* mangled curl call that posted malformed JSON and printed the server's raw
* error response as the statusline text itself. A bare file PATH has no `$`,
* quotes, or pipes for any of those intermediate shells to mangle — the
* script's own content (containing the real `$VAR`s) is never touched by a
* shell until Claude Code executes the file itself, at which point the
* pane's real environment is in scope. This mirrors the existing #208 fix in
* tmux-manager.ts (never embed a literal `$SHELL` meant for later
* expansion — resolve it, or in this case reference a file, instead).
*/
export async function ensureStatusLineExporterScript () : Promise < string > {
const scriptPath = dataPath ( 'statusline-exporter.sh' );
const desired = statusLineExporterScriptContent ();
let current : string | null = null ;
try {
current = await readFile ( scriptPath , 'utf-8' );
} catch {
// Doesn't exist yet.
}
if ( current !== desired ) {
2026-09-14 15:55:17 +02:00
// Temp file + rename: live sessions execute this script on every statusline
// render, and a truncate-then-write (plus a chmod AFTER the write) opened two
// windows in which Claude Code could run an empty or non-executable file.
// rename() swaps the complete, already-executable file in atomically.
2026-10-04 21:36:44 +02:00
// ⚠️ The temp name must be unique per CALL, not per millisecond: sessions created
// concurrently (spawn_workers, a multi-tab Run) refresh this together, a shared
// name let the first rename consume the others' temp file, and their ENOENT
// dropped those sessions from tmux to the direct-PTY fallback.
const tmpPath = ` ${ scriptPath } . ${ process . pid } . ${ randomBytes ( 6 ). toString ( 'hex' ) } .tmp` ;
2026-09-14 15:55:17 +02:00
await writeFile ( tmpPath , desired );
await chmod ( tmpPath , 0 o755 );
await rename ( tmpPath , scriptPath );
2026-08-30 17:31:25 -05:00
}
return scriptPath ;
}
2026-09-07 20:37:29 -05:00
/**
* Whether plan-usage telemetry collection is CURRENTLY wanted — read FRESH
* from the persisted `showPlanUsageLimits` setting on every call, never
* cached and never per-session. Reusing that setting rather than inventing a
* second persisted flag: it's the SAME boolean the App Settings chip checkbox
* already writes (see `planUsageChipEnabled()` in settings-ui.js).
*
* This is what lets the on/off decision survive a Codeman restart (there is
* no per-session state to lose — see the now-removed `Session._statusLineTelemetry`,
* which WAS such a per-session field and went stale on every restart) and
* apply uniformly across every claude session-creation path — interactive
* create, cron, the Ralph Loop API, quick-start — with none of them needing
* to thread a request-time flag through: they all already construct a
* session via TmuxManager.createSession/respawnPane, which reads this at
* spawn time.
2026-09-14 15:53:25 +02:00
*
* An ABSENT key means ON, mirroring readWorkspaceHooksEnabled() above: the
* client shows the chip and its checkbox as already on for a desktop that has
* never touched the setting (planUsageChipEnabled() in settings-ui.js), and
* the exporter only ever posts to THIS Codeman over loopback, so the honest
* default for an install that never said otherwise is the one the user can
* see. Resolving the default here, in the reader, is what lets
* `GET /api/settings` stay a plain read: a reconcile write there ran on every
* page load and could replace an unreadable settings.json with a one-key
* file. Only an explicit `false` (a save that flipped the chip off on some
* device) turns collection off.
2026-09-07 20:37:29 -05:00
*/
export async function readPlanUsageTelemetryEnabled () : Promise < boolean > {
const settings = await readJsonConfig < Record < string , unknown >>( SETTINGS_PATH , 'settings.json' , {});
2026-09-14 15:53:25 +02:00
return settings . showPlanUsageLimits !== false ;
2026-09-07 20:37:29 -05:00
}
2026-08-30 17:31:25 -05:00
/**
* Resolve the statusLine command to pass as an EPHEMERAL `claude --settings`
2026-09-07 20:37:29 -05:00
* CLI flag for this one process (see buildSpawnCommandFromRegistry in
* session-cli-registry-bridge.ts) — never written to disk. This supersedes
* the old applyStatusLineConfig(path, true) disk-write: a file-based
* statusLine leaked into any plain `claude` run in that directory outside
* Codeman entirely (it took precedence over the user's own global/project
2026-08-30 17:31:25 -05:00
* statusline with no disclosure and no way to remove it — found live
* 2026-08-31).
*
* Also self-heals: if an OLDER Codeman build already wrote its marked
* exporter into this workspace's settings.local.json, it is stripped here
* (isOurs-guarded, same as applyStatusLineConfig's removal branch) so every
* workspace migrates off the disk-based mechanism the first time a session
2026-09-07 20:37:29 -05:00
* starts there again — no manual cleanup required. This self-heal runs
* regardless of `telemetryEnabled`, so a legacy leftover is cleaned up even
* while the setting is currently off.
2026-08-30 17:31:25 -05:00
*
2026-09-07 20:37:29 -05:00
* Returns undefined when telemetry isn't currently enabled (see
* readPlanUsageTelemetryEnabled), or when the workspace already has its OWN
* hand-configured statusLine (never override a real one).
2026-08-30 17:31:25 -05:00
*/
export async function resolveStatusLineCliCommand (
casePath : string ,
2026-09-07 20:37:29 -05:00
telemetryEnabled : boolean
2026-08-30 17:31:25 -05:00
) : Promise < string | undefined > {
const settingsPath = join ( casePath , '.claude' , 'settings.local.json' );
let userHasOwnStatusLine = false ;
2026-10-04 20:30:40 -04:00
const skip = await absentOrUnreachable ( settingsPath );
// Unreachable: whether the user configured their own statusLine there cannot be
// told, and this must never override a real one, so inject nothing.
if ( skip === 'unreachable' ) return undefined ;
if ( ! skip ) {
let raw : string ;
2026-08-30 17:31:25 -05:00
try {
2026-10-04 20:30:40 -04:00
raw = await readFile ( settingsPath , 'utf-8' );
} catch ( err ) {
// Gone since the probe: nothing to respect. Unreadable: same reason as above.
if (( err as NodeJS . ErrnoException ). code !== 'ENOENT' ) return undefined ;
raw = '' ;
}
try {
const existing = raw ? JSON . parse ( raw ) : {};
2026-08-30 17:31:25 -05:00
const current = existing . statusLine as { command? : unknown } | undefined ;
if ( current && typeof current . command === 'string' ) {
if ( current . command . includes ( STATUSLINE_MARKER )) {
await applyStatusLineConfig ( casePath , false ); // strip legacy disk-written exporter
} else {
userHasOwnStatusLine = true ;
}
}
} catch {
// Malformed — leave it alone, same guard applyStatusLineConfig itself uses.
}
}
2026-09-07 20:37:29 -05:00
if ( ! telemetryEnabled || userHasOwnStatusLine ) return undefined ;
2026-08-30 17:31:25 -05:00
return ensureStatusLineExporterScript ();
}
2026-08-09 03:30:15 +02:00
// ─── Agent skill injection ───────────────────────────────────────────────────
/**
* Version-agnostic ownership prefix for the injected agent skill, same pattern as
* `BACKGROUND_WAKE_MARKER_PREFIX`: ownership is decided on the prefix so a wording
* change in the full marker cannot disown every previously injected copy.
*/
const AGENT_SKILL_MARKER_PREFIX = '<!-- codeman-managed-agent-skill' ;
/**
* Marker appended to the injected SKILL.md. Its presence is what makes a copy OURS:
* install/refresh/remove all refuse to touch a `skills/codeman` whose SKILL.md lacks
* it, so a user's hand-authored or hand-edited-and-de-marked skill is never clobbered.
*/
const AGENT_SKILL_MARKER = ` ${ AGENT_SKILL_MARKER_PREFIX } : installed by Codeman; edits are overwritten while the agent-skill setting is on -->` ;
/**
* Packaged source of the skill: `skills/codeman/` at the package root. Resolved
* relative to this module so it works from `src/` (tsx dev), `dist/` (tsc build),
* and an npm install (`files` includes `skills`), all of which sit one level below
* the package root.
*/
function agentSkillSourceDir () : string {
return join ( dirname ( fileURLToPath ( import . meta . url )), '..' , 'skills' , 'codeman' );
}
interface AgentSkillFile {
/** Path relative to the target skill dir (e.g. `reference/endpoints.md`). */
relPath : string ;
content : string ;
}
/**
* Read the packaged skill: SKILL.md (marker appended) plus every markdown file
* under `reference/`. Enumerated from disk rather than a hardcoded manifest so a
* new reference file ships without touching this module.
*/
async function readAgentSkillSource () : Promise < AgentSkillFile [] > {
const src = agentSkillSourceDir ();
2026-10-11 07:10:41 +02:00
const skillMd = await readFile ( join ( src , 'SKILL.md' ), 'utf-8' );
const files : AgentSkillFile [] = [{ relPath : 'SKILL.md' , content : ` ${ skillMd . trimEnd () } \ n \ n ${ AGENT_SKILL_MARKER } \ n` }];
2026-08-09 03:30:15 +02:00
let referenceNames : string [] = [];
try {
referenceNames = ( await readdir ( join ( src , 'reference' ))). filter (( name ) => name . endsWith ( '.md' )). sort ();
} catch {
// no reference dir in the source; SKILL.md alone is still a valid skill
}
for ( const name of referenceNames ) {
files . push ({ relPath : join ( 'reference' , name ), content : await readFile ( join ( src , 'reference' , name ), 'utf-8' ) });
}
return files ;
}
2026-08-09 11:49:29 +02:00
/**
* Publish one skill file with a temp + rename, never a bare overwrite.
*
* Claude Code reads SKILL.md whole when it loads the skill, so an in-place rewrite of
* the file (20KB+, several write() syscalls) lets a load that lands mid-write see a
* TRUNCATED skill. rename() swaps the finished file in one step, so a
* reader sees either the old copy or the new one. The pid+random temp name matters
* because `codeman skill install` writes these same paths from a DIFFERENT process than
* the server, where the in-process lock cannot help: a shared temp name would let the
* two tear each other's payload (same reasoning as user-store.ts).
*/
async function writeSkillFileAtomic ( target : string , content : string ) : Promise < void > {
// `.tmp` last, so a leftover temp is never picked up as a `.md` skill file.
const tmpPath = ` ${ target } . ${ process . pid } . ${ randomBytes ( 6 ). toString ( 'hex' ) } .tmp` ;
try {
await writeFile ( tmpPath , content );
await rename ( tmpPath , target );
} catch ( err ) {
await unlink ( tmpPath ). catch (() => {});
throw err ;
}
}
2026-08-09 03:30:15 +02:00
async function isSymlink ( path : string ) : Promise < boolean > {
try {
return ( await lstat ( path )). isSymbolicLink ();
} catch {
return false ;
}
}
/** What an install/remove actually did, so callers (CLI, logs) can say so. */
export type AgentSkillApplyResult =
| 'installed' // fresh copy written
| 'refreshed' // our copy was stale and got rewritten
| 'unchanged' // our copy already matches the packaged source
| 'removed' // our copy deleted
| 'absent' // nothing there to remove
| 'foreign' // a copy exists but is not ours; left untouched
| 'symlink' ; // the skill dir (or its parent) is a symlink; left untouched
/**
* Install or refresh the Codeman agent skill into `skillDir` (a `.../codeman`
2026-10-11 07:10:41 +02:00
* directory, e.g. `<case>/.claude/skills/codeman`, `~/.claude/skills/codeman` or
* `<case>/.agents/skills/codeman`).
2026-08-09 03:30:15 +02:00
*
* Refuses two shapes rather than writing through them:
* - a SYMLINK at the skill dir or its `skills/` parent: this repo's own dogfooding
* layout (`.claude/skills/codeman -> ../../skills/codeman`) would otherwise have
* the injector overwrite the repo source through the link;
* - a FOREIGN copy (SKILL.md present without our marker): that is the user's own
* skill, and per the statusLine rule we never clobber what we did not write.
*
* Idempotent and cheap: unchanged files are not rewritten, so calling on every
* session create causes no mtime churn.
2026-08-09 11:49:29 +02:00
*
* Serialized on the skill dir through the same lock the settings writers use: two
* sessions created at once in one repo both inject this skill, and interleaving their
* ownership read with the other's write reports a bogus result (an 'unchanged' for a
* copy the other writer had not finished). Writes go out via temp + rename, which is
* what protects a concurrent skill LOAD, in this process or the CLI's.
2026-08-09 03:30:15 +02:00
*/
export async function installAgentSkillInto ( skillDir : string ) : Promise < AgentSkillApplyResult > {
2026-08-09 11:49:29 +02:00
return withSettingsLock ( skillDir , async () => {
if (( await isSymlink ( dirname ( skillDir ))) || ( await isSymlink ( skillDir ))) return 'symlink' ;
2026-08-09 03:30:15 +02:00
2026-08-09 11:49:29 +02:00
let existing : string | null = null ;
2026-08-09 03:30:15 +02:00
try {
2026-08-09 11:49:29 +02:00
existing = await readFile ( join ( skillDir , 'SKILL.md' ), 'utf-8' );
2026-08-09 03:30:15 +02:00
} catch {
2026-08-09 11:49:29 +02:00
// absent: fresh install
2026-08-09 03:30:15 +02:00
}
2026-08-09 11:49:29 +02:00
if ( existing !== null && ! existing . includes ( AGENT_SKILL_MARKER_PREFIX )) return 'foreign' ;
const files = await readAgentSkillSource ();
let changed = false ;
for ( const file of files ) {
const target = join ( skillDir , file . relPath );
let current : string | null = null ;
try {
current = await readFile ( target , 'utf-8' );
} catch {
// missing: will be written
}
if ( current === file . content ) continue ;
await mkdir ( dirname ( target ), { recursive : true });
await writeSkillFileAtomic ( target , file . content );
changed = true ;
}
if ( ! changed ) return 'unchanged' ;
return existing === null ? 'installed' : 'refreshed' ;
});
2026-08-09 03:30:15 +02:00
}
2026-08-14 14:46:23 +02:00
/**
2026-10-11 07:10:41 +02:00
* Seed a session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.sh`,
2026-08-14 14:46:23 +02:00
* default `~/.cache/`) from the packaged `skills/codeman/preamble.sh`, so the agent
* skill's §0 bootstrap collapses to a two-line loader instead of a ~150-line block the
* model has to type out (measured live: that paste alone cost a spawn run ~47 s of
* generation time). The path formula must match the skill's
* `${XDG_CACHE_HOME:-$HOME/.cache}` exactly; sessions inherit the server's env, so
* reading the server's own XDG_CACHE_HOME keeps the two in agreement (`||` mirrors the
2026-10-11 07:10:41 +02:00
* shell's `:-`, treating empty as unset). Callers gate to LOCAL sessions whose CLI gets
* the skill (`agentSkillDir()` non-null, any CLI: the preamble is bash + curl + jq, nothing
* claude-specific; a remote or in-container HOME is not this filesystem) and treat it as
* best-effort: the skill's §0 fallback block self-heals a missing or stale file. Codex
* caveat: codex 0.162 runs tool commands in a shared app-server daemon whose
* `CODEMAN_SESSION_ID` is the starting session's, so its loader looks for that session's
* file and its self id is wrong; the skill tells the agent how to notice.
2026-08-14 14:46:23 +02:00
*/
export async function seedAgentSessionPreamble ( sessionId : string ) : Promise < void > {
const content = await readFile ( join ( agentSkillSourceDir (), 'preamble.sh' ), 'utf-8' );
2026-09-07 19:09:24 +02:00
await mkdir ( agentPreambleCacheDir (), { recursive : true });
await writeFile ( agentPreamblePath ( sessionId ), content , { mode : 0o600 });
}
/** Where the preamble caches live. One formula, shared by seed / remove / prune. */
function agentPreambleCacheDir () : string {
return process . env . XDG_CACHE_HOME || join ( homedir (), '.cache' );
}
/** `codeman-agent-<sessionId>.sh` in that directory. */
function agentPreamblePath ( sessionId : string ) : string {
return join ( agentPreambleCacheDir (), `codeman-agent- ${ sessionId } .sh` );
}
/** Matches exactly what seedAgentSessionPreamble writes, and nothing else in ~/.cache. */
const AGENT_PREAMBLE_FILE_PATTERN = /^codeman-agent-(.+)\.sh$/ ;
/** How long a preamble cache with no live session behind it is kept before the sweep takes it. */
export const AGENT_PREAMBLE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 ;
/**
* Drop one session's preamble cache. Called when a session is deleted, which is the
* precise counterpart to seeding it at create: one file per claude session was being
* written and nothing ever removed them (236 leftovers measured on a working machine,
* the oldest three weeks old). Best-effort — a file that will not delete is litter,
* never a reason to fail a teardown.
*/
export async function removeAgentSessionPreamble ( sessionId : string ) : Promise < void > {
await unlink ( agentPreamblePath ( sessionId )). catch (() => {});
}
/**
* Sweep preamble caches left by sessions that are gone: the delete path above covers
* an orderly teardown, and this covers everything else (a crash, a killed server, a
* session deleted by an older build, another instance's leftovers).
*
* ⚠️ Two guards, and both matter: a file whose session is in `keepSessionIds` is never
* touched however old it is, and everything else needs `maxAgeMs` of age on top. A live
* session's cache is load-bearing — remove it and the skill's two-line loader fails its
* version check mid-run — and the age floor is what keeps a session belonging to
* ANOTHER instance (whose ids this process cannot see) out of the blast radius. Losing
* one is degradation rather than breakage: the §0 fallback block rewrites it.
*
* Returns how many it removed. Best-effort throughout; a missing cache dir is 0.
*/
export async function pruneAgentSessionPreambles (
keepSessionIds : Iterable < string >,
maxAgeMs : number = AGENT_PREAMBLE_MAX_AGE_MS
) : Promise < number > {
const cacheDir = agentPreambleCacheDir ();
const keep = new Set ( keepSessionIds );
const cutoff = Date . now () - maxAgeMs ;
let removed = 0 ;
let entries : string [];
try {
entries = await readdir ( cacheDir );
} catch {
return 0 ;
}
for ( const entry of entries ) {
const sessionId = AGENT_PREAMBLE_FILE_PATTERN . exec ( entry ) ? .[ 1 ];
if ( ! sessionId || keep . has ( sessionId )) continue ;
const path = join ( cacheDir , entry );
try {
if (( await lstat ( path )). mtimeMs > cutoff ) continue ;
await unlink ( path );
removed ++ ;
} catch {
/* best-effort — a vanished or unreadable file is not our problem */
}
}
return removed ;
2026-08-14 14:46:23 +02:00
}
/**
* Refresh the USER-LEVEL skill copy (`~/.claude/skills/codeman`) IF one exists and is
* Codeman-managed. `codeman skill install` (no `--case`) writes that copy once, and
* unlike per-case copies (re-installed on every session create) nothing ever refreshed
* it, so it stayed at whatever version installed it. That matters because Claude Code
* loads the USER-LEVEL copy over a case's fresh one when both carry the name `codeman`:
* observed live 2026-08-14, an Aug 9 user copy (pre fast-path, pre lineage header)
* shadowed the current per-case injections, so every agent-driven spawn ran the old
* recipes, spawned workers serially, and lost their lineage arcs.
*
* Refresh-ONLY: an absent copy is not installed (the user never asked for a global
* copy), and foreign/symlink copies are refused by installAgentSkillInto itself.
*/
export async function refreshUserAgentSkill () : Promise < AgentSkillApplyResult | 'absent' > {
const skillDir = join ( homedir (), '.claude' , 'skills' , 'codeman' );
try {
const existing = await readFile ( join ( skillDir , 'SKILL.md' ), 'utf-8' );
if ( ! existing . includes ( AGENT_SKILL_MARKER_PREFIX )) return 'foreign' ;
} catch {
return 'absent' ;
}
return installAgentSkillInto ( skillDir );
}
2026-08-09 03:30:15 +02:00
/**
* Remove a Codeman-managed skill copy from `skillDir`. Same ownership and symlink
* refusals as the install path. Deletes only files the packaged source would have
* written (never `rm -rf`, so a user's extra files in the directory survive), then
* prunes the directories bottom-up if they emptied.
2026-08-09 11:49:29 +02:00
*
* Shares the install path's per-dir lock so an uninstall can't run between an install's
* ownership read and its writes, which would leave half the skill back on disk.
2026-08-09 03:30:15 +02:00
*/
export async function removeAgentSkillFrom ( skillDir : string ) : Promise < AgentSkillApplyResult > {
2026-08-09 11:49:29 +02:00
return withSettingsLock ( skillDir , async () => {
if (( await isSymlink ( dirname ( skillDir ))) || ( await isSymlink ( skillDir ))) return 'symlink' ;
2026-08-09 03:30:15 +02:00
2026-08-09 11:49:29 +02:00
let existing : string | null = null ;
try {
existing = await readFile ( join ( skillDir , 'SKILL.md' ), 'utf-8' );
} catch {
return 'absent' ;
}
if ( ! existing . includes ( AGENT_SKILL_MARKER_PREFIX )) return 'foreign' ;
2026-08-09 03:30:15 +02:00
2026-08-09 11:49:29 +02:00
// Manifest-based, with SKILL.md as the fallback when the packaged source is
// unreadable: removal must still work on an install whose skills/ dir went missing.
const files = await readAgentSkillSource (). catch (() : AgentSkillFile [] => [{ relPath : 'SKILL.md' , content : '' }]);
for ( const file of files ) {
await unlink ( join ( skillDir , file . relPath )). catch (() => {});
}
await rmdir ( join ( skillDir , 'reference' )). catch (() => {}); // fails when non-empty, fine
await rmdir ( skillDir ). catch (() => {});
2026-10-11 07:10:41 +02:00
await rmdir ( dirname ( skillDir )). catch (() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty
2026-08-09 11:49:29 +02:00
return 'removed' ;
});
2026-08-09 03:30:15 +02:00
}
/**
2026-10-11 07:10:41 +02:00
* Claude Code's project skills directory, and the meaning `agentSkillInjection: true`
* had before `capabilities.agentSkillDir` existed.
*/
export const CLAUDE_AGENT_SKILL_DIR : AgentSkillDir = '.claude/skills' ;
/**
* The workspace-relative skills directory this CLI scans, i.e. where its sessions get
* the skill, or `null` when they get none. Pure registry data, so no call site
* branches on a CLI id.
*/
export function agentSkillDir (
capabilities : Pick < CliCapabilities , 'agentSkillInjection' | 'agentSkillDir' > | undefined
) : AgentSkillDir | null {
if ( ! capabilities ? . agentSkillInjection ) return null ;
return capabilities . agentSkillDir ?? CLAUDE_AGENT_SKILL_DIR ;
}
/** `<casePath>/<dir>/codeman`, the directory the skill's SKILL.md lives in. */
export function agentSkillDirFor ( casePath : string , dir : AgentSkillDir ) : string {
return join ( casePath , dir , 'codeman' );
}
/**
* Add or remove the Codeman agent skill in `<case>/<dir>/codeman`, where `dir` is the
* skills directory the session's CLI scans (default: Claude Code's `.claude/skills`;
* callers resolve it with `agentSkillDir()`), mirroring `applyStatusLineConfig`'s
* shape. Gated by the synced `agentSkillEnabled` app setting (default OFF).
2026-08-09 03:30:15 +02:00
*
* Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or
2026-10-11 07:10:41 +02:00
* skip the call), for the statusLine reason: sessions in a repo share one workspace,
* so a single create while the setting is off must not yank the skill out from
2026-08-09 03:30:15 +02:00
* under other live sessions.
*
* ⚠️ Consequence: turning `agentSkillEnabled` OFF sweeps nothing. There is deliberately
* no server-side toggle-off sweep (it would have to walk every case, including ones
2026-10-11 07:10:41 +02:00
* with live sessions, and would hit exactly the shared-workspace hazard above), so
2026-08-09 03:30:15 +02:00
* already-injected copies stay on disk until removed per case with
2026-10-11 07:10:41 +02:00
* `codeman skill uninstall --case <name>`, which removes the Codeman-managed copy from
* every skills directory the registry could have written. The `enabled: false` branch
* here backs that CLI and the tests; it has no server call site. Keep the README's
* Agent Skill note in sync if this ever changes.
2026-08-09 03:30:15 +02:00
*/
2026-10-11 07:10:41 +02:00
export async function applyAgentSkill (
casePath : string ,
enabled : boolean ,
dir : AgentSkillDir = CLAUDE_AGENT_SKILL_DIR
) : Promise < AgentSkillApplyResult > {
const skillDir = agentSkillDirFor ( casePath , dir );
2026-08-09 03:30:15 +02:00
return enabled ? installAgentSkillInto ( skillDir ) : removeAgentSkillFrom ( skillDir );
}