diff --git a/CLAUDE.md b/CLAUDE.md index 9c7d767a..327f6924 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,7 +208,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) -**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in session-routes.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from both create paths and from `restoreMuxSessions()` for sessions recovered on server start. Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one. +**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one. **Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`. diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index 93370d44..c8e27aa5 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -11,6 +11,7 @@ import { v4 as uuidv4 } from 'uuid'; import { readFile } from 'node:fs/promises'; import { statSync, realpathSync } from 'node:fs'; import { Session } from '../session.js'; +import { applyWorkspaceHooks } from '../hooks-config.js'; import { SseEvent } from '../web/sse-events.js'; import { CronJobSchema } from '../web/schemas.js'; import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js'; @@ -401,6 +402,15 @@ export class CronService { // clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own // spawn default is what would otherwise apply). const { geminiConfig, piConfig } = clampCronExternalCliConfigs(mode, ownerGranted); + // Workspace hooks (see applyWorkspaceHooks in hooks-config): cron jobs are + // always local (workingDir was stat-validated above) but used to bypass the + // shared install-vs-refresh decision, so a job firing in a linked case that + // never had an interactive session ran hook-blind — no `stop` for the + // completion detection, no tab alert on a blocking dialog. Claude mode only + // (nothing else reads `.claude` hooks); best-effort inside the helper. + if (mode === 'claude') { + await applyWorkspaceHooks(job.workingDir); + } session = new Session({ workingDir: job.workingDir, mode, diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 39164046..534993f0 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -10,8 +10,9 @@ * Key exports: * - `generateHooksConfig()` — returns hooks object for settings.local.json * - `writeHooksConfig(casePath)` — writes hooks + env config to disk + * - `applyWorkspaceHooks(workspace, install?)` — the ONE install-vs-refresh decision + * point every claude-session create path routes through (see its doc comment) * - `ensureCodemanHooks(casePath)` — safely installs/updates hooks for a managed case - * (no production call site yet; see its doc comment before wiring one) * - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings * * Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`, @@ -37,6 +38,7 @@ import { fileURLToPath } from 'node:url'; import type { HookEventType } from './types.js'; import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js'; +import { dataPath } from './config/instance.js'; /** * Serializes read-modify-write access to a `settings.local.json` path. Every @@ -747,6 +749,64 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise }); } +/** + * 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 { + try { + if (!existsSync(workspace)) return; + 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 { + try { + const parsed = JSON.parse(await readFile(dataPath('settings.json'), 'utf-8')) as Record; + return parsed.workspaceHooksEnabled !== false; + } catch { + return true; + } +} + /** Unique marker identifying Codeman's own statusLine command (vs a user's). */ const STATUSLINE_MARKER = '/api/status-telemetry'; diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index 4a5f7db5..fe714ecb 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -20,6 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js'; +import { applyWorkspaceHooks } from './hooks-config.js'; import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js'; // Re-export for backward compatibility @@ -429,6 +430,11 @@ export class PlanOrchestrator { detail: 'Researching...', }); + // Workspace hooks for the case this plan targets (see applyWorkspaceHooks in + // hooks-config): claude-mode, local workingDir, and the helper itself skips a + // vanished dir + swallows failures — the plan run must never fail on hooks. + await applyWorkspaceHooks(this.workingDir); + const session = new Session({ workingDir: this.workingDir, mux: this.mux, @@ -591,6 +597,10 @@ export class PlanOrchestrator { detail: 'Generating plan...', }); + // Workspace hooks: same rationale as the research one-shot above (idempotent — + // the helper short-circuits when the hooks block is already current). + await applyWorkspaceHooks(this.workingDir); + const session = new Session({ workingDir: this.workingDir, mux: this.mux, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index c4a00c57..98ea4e7c 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -84,7 +84,7 @@ import { applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, - ensureCodemanHooks, + applyWorkspaceHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; @@ -626,31 +626,12 @@ async function injectAgentSkill(casePath: string): Promise { } } -/** - * Hooks for the workspace a Claude session is about to run in. ONE decision point, - * shared by every create path, so the setting cannot apply to some of them only. - * - * ON (`workspaceHooksEnabled`, the default): INSTALL Codeman's hooks block, merging - * so a user's own hook entries and every other settings key survive. Hooks were - * previously 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: no tab alert or phone-overview row - * when a dialog blocks the pane, no Approvals Inbox item, no push, no definitive - * `stop`/`idle_prompt` for respawn, and no `stop`/`blocked` for the wait endpoints. - * Measured 2026-08-15 in a linked case: an AskUserQuestion dialog on screen with the - * tab reporting a calm `idle`. Claude Code re-reads the file, so a session already - * running in that workspace starts firing hooks without a restart (verified live). - * - * 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. - * - * Best-effort either way: a refusal or a thrown error must never fail the create. - */ -async function applyWorkspaceHooks(ctx: ConfigPort, workspace: string): Promise { - const install = await ctx.getWorkspaceHooksEnabled(); - await (install ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace)).catch(() => {}); -} +// Workspace hooks: the install-vs-refresh decision core moved to +// `applyWorkspaceHooks` in hooks-config.ts (imported above) so the non-route +// claude create paths — cron fires, legacy scheduled runs, the plan-orchestrator +// one-shots, the boot recovery sweep — share the SAME decision instead of +// bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the +// setting through the ConfigPort (tests stub it) and pass it as the second arg. export function registerSessionRoutes( app: FastifyInstance, @@ -788,7 +769,14 @@ export function registerSessionRoutes( // chip's data feed for everyone. The exporter is benign when the chip is off // (the footer just shows session status). isOurs-guarded so a user's own // statusLine is never touched. - if ((body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) { + // + // Same guard as the hooks call below (499d355): never for a remote attach + // (workingDir is a user@host:session pseudo-path — the mkdir inside + // applyStatusLineConfig would create it as a junk local dir), and only when + // the caller named a workingDir — the process-cwd fallback is $HOME under + // installer-created services, and a statusLine materializing in + // ~/.claude/settings.local.json was never asked for. + if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) { await applyStatusLineConfig(workingDir, true); } @@ -799,7 +787,7 @@ export function registerSessionRoutes( // process-cwd fallback is $HOME under installer-created services, and hooks // materializing in ~/.claude/settings.local.json was never asked for. if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') { - await applyWorkspaceHooks(ctx, workingDir); + await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled()); // Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared- // .claude rationale as the statusLine above: a create must never remove the // skill from under other live sessions in the repo. Marker-guarded, so a @@ -2936,7 +2924,7 @@ export function registerSessionRoutes( // of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that // doesn't exist on the local filesystem. if (mode === 'claude') { - await applyWorkspaceHooks(ctx, resolvedCasePath); + await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled()); } else { await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); } @@ -2954,16 +2942,11 @@ export function registerSessionRoutes( // Docker cases: the workspace is a REAL host dir bind-mounted into the container. // Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and // hook-idle detection fire (decision: wire hooks now). Never clobbers an existing - // configured project. Skipped for external CLIs (they use their own systems). - if ( - docker && - docker.hooksEnabled && - mode !== 'opencode' && - mode !== 'codex' && - mode !== 'gemini' && - mode !== 'antigravity' && - mode !== 'pi' - ) { + // configured project. Claude mode ONLY — only claude reads `.claude` hooks, so a + // shell or external-CLI quick-start must not author a block of its own (the same + // rule the existing-case branch above states; this branch used to exclude just + // the five external CLIs and let `shell` through). + if (docker && docker.hooksEnabled && mode === 'claude') { try { if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) { const templatePath = await ctx.getDefaultClaudeMdPath(); @@ -2975,7 +2958,7 @@ export function registerSessionRoutes( // A settings file with no hooks in it is the same dead-surface case as a // linked case. This branch is already gated on `docker.hooksEnabled`, and // applyWorkspaceHooks adds the user-level gate on top. - await applyWorkspaceHooks(ctx, resolvedCasePath); + await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled()); } } catch { /* non-fatal — the session still runs, hooks may be degraded */ diff --git a/src/web/server.ts b/src/web/server.ts index 49a4a503..a2dd30c2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -76,7 +76,7 @@ import { RunSummaryTracker } from '../run-summary.js'; import { PlanOrchestrator } from '../plan-orchestrator.js'; import { OrchestratorLoop } from '../orchestrator-loop.js'; import { getLifecycleLog } from '../session-lifecycle-log.js'; -import { ensureCodemanHooks } from '../hooks-config.js'; +import { applyWorkspaceHooks } from '../hooks-config.js'; import { PushSubscriptionStore } from '../push-store.js'; import webpush from 'web-push'; import { SseStreamManager } from './sse-stream-manager.js'; @@ -1819,6 +1819,14 @@ export class WebServer extends EventEmitter { let session: Session | null = null; try { + // Workspace hooks for this iteration's session — legacy scheduled runs are + // always claude-mode and always local, and used to bypass the shared decision + // entirely: a scheduled run firing in a linked case that never had an + // interactive session ran hook-blind (see applyWorkspaceHooks in hooks-config; + // it reads the `workspaceHooksEnabled` setting itself, skips a vanished + // workingDir, and swallows failures — a run must never fail on hooks). + await applyWorkspaceHooks(run.workingDir); + // Create a session for this iteration. if (isMultiUserMode()) { // §6.3: resolve the permission mode with the RUN OWNER (a non-granted user @@ -2886,6 +2894,11 @@ export class WebServer extends EventEmitter { * Skipped entirely when `workspaceHooksEnabled` is OFF: that setting exists so a * user can keep Codeman out of their repos, and a boot-time sweep is the last * place that should ignore it. + * + * A workspace that no longer EXISTS is skipped by applyWorkspaceHooks: a tmux + * session can outlive its deleted repo, and `ensureCodemanHooks` mkdir -p's, so + * the sweep used to resurrect the directory as an empty tree holding only + * `.claude/settings.local.json`. */ private async ensureHooksForRecoveredWorkspaces(): Promise { if (!(await this.getWorkspaceHooksEnabled())) return; @@ -2896,7 +2909,9 @@ export class WebServer extends EventEmitter { if (session.workingDir) workspaces.add(session.workingDir); } for (const workspace of workspaces) { - await ensureCodemanHooks(workspace).catch(() => {}); + // install=true: the setting was already resolved ON above for the whole batch + // (OFF skips the sweep wholesale, keeping its documented semantics). + await applyWorkspaceHooks(workspace, true); } } diff --git a/test/routes/session-routes-workspace-hooks.test.ts b/test/routes/session-routes-workspace-hooks.test.ts index eac6685e..c6038aae 100644 --- a/test/routes/session-routes-workspace-hooks.test.ts +++ b/test/routes/session-routes-workspace-hooks.test.ts @@ -10,6 +10,13 @@ * * Asserts bytes on disk (the real `ensureCodemanHooks`), not a spy call. * Uses app.inject(), so no real HTTP port is needed. + * + * Also covers the post-#304 follow-ups: the quick-start existing-case branch, the + * docker branch's claude-only gate (a shell quick-start used to author a hooks + * block of its own), and the shared decision core `applyWorkspaceHooks` in + * hooks-config.ts — the function the non-route create paths (cron, scheduled runs, + * plan one-shots, the boot recovery sweep) go through, tested directly here + * including the sweep's deleted-workspace guard. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -22,8 +29,9 @@ import { tmpdir } from 'node:os'; import { createMockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; -import { generateHooksConfig } from '../../src/hooks-config.js'; +import { generateHooksConfig, applyWorkspaceHooks } from '../../src/hooks-config.js'; import { getDataDir } from '../../src/config/instance.js'; +import { CASES_DIR } from '../../src/web/route-helpers.js'; interface HooksFile { hooks?: Record }>>; @@ -141,11 +149,14 @@ describe('POST /api/sessions workspace hooks', () => { it('leaves the server cwd alone when workingDir is omitted', async () => { // workingDir falls back to process.cwd(), which is $HOME under installer-created - // services — hooks must not materialize in ~/.claude/settings.local.json. + // services — neither hooks NOR the statusLine exporter (same mkdir-into-cwd + // exposure, closed in the #304 follow-ups) may materialize in + // ~/.claude/settings.local.json. const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json'); const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null; - expect((await createSession({ name: 'hooks-no-dir', mode: 'claude' })).statusCode).toBe(200); + const res = await createSession({ name: 'hooks-no-dir', mode: 'claude', statusLineTelemetry: true }); + expect(res.statusCode).toBe(200); const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null; expect(after).toBe(before); @@ -154,7 +165,8 @@ describe('POST /api/sessions workspace hooks', () => { it('never writes hooks for a remote attach (workingDir is a user@host pseudo-path)', async () => { // A claude-mode attachRemoteSession create overwrites workingDir with // `user@host:session` — locally a RELATIVE path, so a mkdir would create it - // as a junk directory under the server cwd. + // as a junk directory under the server cwd. statusLineTelemetry rides along: + // applyStatusLineConfig mkdirs the same way and used to run for remote attaches. await mkdir(getDataDir(), { recursive: true }); await writeFile( join(getDataDir(), 'remote-hosts.json'), @@ -164,6 +176,7 @@ describe('POST /api/sessions workspace hooks', () => { const res = await createSession({ name: 'hooks-remote', mode: 'claude', + statusLineTelemetry: true, attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' }, }); expect(res.statusCode).toBe(200); @@ -207,3 +220,157 @@ describe('POST /api/sessions workspace hooks', () => { expect(Object.keys(written).sort()).toEqual(Object.keys(generateHooksConfig().hooks).sort()); }); }); + +describe('POST /api/quick-start workspace hooks', () => { + let app: FastifyInstance; + + const quickStart = (payload: Record) => + app.inject({ method: 'POST', url: '/api/quick-start', payload }); + + const hooksFileIn = (dir: string) => join(dir, '.claude', 'settings.local.json'); + + beforeEach(async () => { + app = Fastify({ logger: false }); + await app.register(fastifyCookie); + registerSessionRoutes(app, createMockRouteContext()); + installRouteErrorHandler(app); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + // Docker fixtures + case dirs must not leak into the next test. + await rm(join(getDataDir(), 'docker-hosts.json'), { force: true }); + await rm(join(getDataDir(), 'docker-cases.json'), { force: true }); + await rm(CASES_DIR, { recursive: true, force: true }); + }); + + it('installs hooks into an EXISTING case directory (a linked case / cloned repo)', async () => { + // The scaffold branch (writeHooksConfig) only runs when quick-start CREATES the + // directory; a pre-existing case takes the applyWorkspaceHooks branch instead. + const casePath = join(CASES_DIR, 'existingcase'); + await mkdir(casePath, { recursive: true }); + + const res = await quickStart({ caseName: 'existingcase', mode: 'claude' }); + expect(res.statusCode).toBe(200); + + const raw = await readFile(hooksFileIn(casePath), 'utf-8'); + expect(raw).toContain('X-Codeman-Hook-Secret'); + expect(raw).toContain('/api/hook-event'); + }); + + /** Minimal docker host + case fixtures (docker IO is no-op'd under vitest). */ + const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string) => { + await mkdir(getDataDir(), { recursive: true }); + await writeFile( + join(getDataDir(), 'docker-hosts.json'), + JSON.stringify([{ id: 'd1', label: 'box', image: 'codeman/agent:base' }]) + ); + await writeFile( + join(getDataDir(), 'docker-cases.json'), + JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath }]) + ); + }; + + it('docker branch scaffolds hooks for a claude session', async () => { + // Companion to the shell test below: proves the docker fixture path is live, + // so the shell assertion cannot pass vacuously. + const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-claude-')); + try { + await writeDockerFixtures('dockclaude', ws); + + expect((await quickStart({ caseName: 'dockclaude', mode: 'claude' })).statusCode).toBe(200); + expect(await readFile(hooksFileIn(ws), 'utf-8')).toContain('X-Codeman-Hook-Secret'); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); + + it('docker branch authors NO hooks block for a shell session', async () => { + // The branch used to exclude only the five external CLIs, so a shell + // quick-start into a docker case wrote a `.claude` block of its own — + // contradicting the existing-case branch's rule that only claude reads it. + const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-shell-')); + try { + await writeDockerFixtures('dockshell', ws); + + expect((await quickStart({ caseName: 'dockshell', mode: 'shell' })).statusCode).toBe(200); + expect(existsSync(join(ws, '.claude'))).toBe(false); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); +}); + +describe('applyWorkspaceHooks (the shared decision core in hooks-config)', () => { + // The non-route claude create paths — cron fires, legacy scheduled runs, the + // plan-orchestrator one-shots, the boot recovery sweep — call this function + // directly, so its contract is tested here rather than by spinning those up. + let workspace: string; + + const appSettingsPath = () => join(getDataDir(), 'settings.json'); + const wsSettingsPath = () => join(workspace, '.claude', 'settings.local.json'); + + const setWorkspaceHooksSetting = async (enabled: boolean) => { + await mkdir(getDataDir(), { recursive: true }); + await writeFile(appSettingsPath(), JSON.stringify({ workspaceHooksEnabled: enabled })); + }; + + beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'codeman-hooks-core-')); + }); + + afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); + await rm(appSettingsPath(), { force: true }); + }); + + it('installs hooks with no settings.json at all (absent key = default ON)', async () => { + await applyWorkspaceHooks(workspace); + + const raw = await readFile(wsSettingsPath(), 'utf-8'); + expect(raw).toContain('X-Codeman-Hook-Secret'); + expect(raw).toContain('curl -sk -X POST'); + }); + + it('never ADDS a hooks block when workspaceHooksEnabled is OFF', async () => { + await setWorkspaceHooksSetting(false); + + await applyWorkspaceHooks(workspace); + expect(existsSync(wsSettingsPath())).toBe(false); + }); + + it('still heals a stale Codeman block when the setting is OFF (COD-91 self-heal)', async () => { + await setWorkspaceHooksSetting(false); + await mkdir(join(workspace, '.claude'), { recursive: true }); + await writeFile(wsSettingsPath(), JSON.stringify({ model: 'opus', hooks: staleCodemanHooks() })); + + await applyWorkspaceHooks(workspace); + + const settings: HooksFile = JSON.parse(await readFile(wsSettingsPath(), 'utf-8')); + expect(settings.model).toBe('opus'); + expect(JSON.stringify(settings.hooks)).toContain('X-Codeman-Hook-Secret'); + }); + + it('leaves a malformed settings file untouched rather than replacing it', async () => { + await mkdir(join(workspace, '.claude'), { recursive: true }); + await writeFile(wsSettingsPath(), '{ not json'); + + await applyWorkspaceHooks(workspace); + expect(await readFile(wsSettingsPath(), 'utf-8')).toBe('{ not json'); + }); + + it('skips a workspace that no longer exists (the boot-sweep resurrection bug)', async () => { + // ensureCodemanHooks mkdir -p's, so the sweep used to recreate a DELETED repo + // as an empty directory tree holding only .claude/settings.local.json. + const gone = join(workspace, 'deleted-repo'); + + // install=true mirrors the boot sweep's call shape (setting pre-resolved ON). + await applyWorkspaceHooks(gone, true); + expect(existsSync(gone)).toBe(false); + + // The setting-driven shape must skip it too. + await applyWorkspaceHooks(gone); + expect(existsSync(gone)).toBe(false); + }); +});