diff --git a/CLAUDE.md b/CLAUDE.md index 2093820f..747b8e0f 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** (`ensureCodemanHooks`, 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. Do not restore the old "never add" policy. ⚠️ 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 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. **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/web/ports/config-port.ts b/src/web/ports/config-port.ts index 322a60ad..024b966f 100644 --- a/src/web/ports/config-port.ts +++ b/src/web/ports/config-port.ts @@ -19,6 +19,8 @@ export interface ConfigPort { getTerminalHistoryConfig(): Promise; /** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */ getAgentSkillEnabled(): Promise; + /** Synced `workspaceHooksEnabled` app setting (default ON); gates INSTALLING hooks into a session's workspace. */ + getWorkspaceHooksEnabled(): Promise; /** Synced `claudeVoiceEnabled` app setting (default OFF); gates the Claude voice dictation relay. */ getClaudeVoiceEnabled(): Promise; getDefaultClaudeMdPath(): Promise; diff --git a/src/web/public/index.html b/src/web/public/index.html index 7c6b5079..bf216291 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1984,6 +1984,13 @@ +
+
+ Workspace Hooks + Install Codeman's hooks in each Claude workspace, so tab alerts, the Approvals Inbox and idle detection also work in linked cases and existing repos. Off leaves your repos untouched. +
+ +
Remote auto-reconnect diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 8eb8d869..f159e815 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -409,6 +409,9 @@ Object.assign(CodemanApp.prototype, { // Claude Permissions settings document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false; + // Default ON: an absent key is a user who has never seen this setting, and OFF + // for them means no tab alerts in any workspace Codeman did not scaffold. + document.getElementById('appSettingsWorkspaceHooks').checked = settings.workspaceHooksEnabled !== false; document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? ''; document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false; document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true; @@ -2017,6 +2020,7 @@ Object.assign(CodemanApp.prototype, { // Claude Permissions settings agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked, agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked, + workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked, claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked, claudeModel: document.getElementById('appSettingsClaudeModel').value, opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 89a1f5e3..5174221f 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -626,6 +626,32 @@ 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(() => {}); +} + export function registerSessionRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort @@ -766,21 +792,10 @@ export function registerSessionRoutes( await applyStatusLineConfig(workingDir, true); } - // Hooks for the workspace this session runs in. ADD-ONLY and merge-based: - // Codeman's own handlers are (re)written, a user's own hook entries are kept. - // - // This used to be `refreshStaleCodemanHooks`, which deliberately never ADDS — - // and `writeHooksConfig` only runs when Codeman CREATES a case directory. So a - // session in a LINKED case or any pre-existing repo (where most sessions live) - // got no hooks block at all, and every hook-driven surface was silently dead - // there: no permission/question tab alert, no Approvals Inbox item, no push, - // no definitive `stop`/`idle_prompt` for respawn, and no `stop`/`blocked` for - // the agent wait endpoints. Measured 2026-08-15 on a linked case: an - // AskUserQuestion dialog sat on screen with the tab showing plain `idle`. - // Claude Code re-reads the file, so a session already running in that - // workspace starts firing hooks too (verified live, same day). + // Hooks for the workspace this session runs in (install vs refresh-only is the + // `workspaceHooksEnabled` setting; see applyWorkspaceHooks). if ((body.mode ?? 'claude') === 'claude') { - await ensureCodemanHooks(workingDir).catch(() => {}); + await applyWorkspaceHooks(ctx, workingDir); // 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 @@ -2911,15 +2926,13 @@ export function registerSessionRoutes( } } else if (!remote && !docker && mode !== 'opencode') { // EXISTING case directory (a linked case, a cloned repo, anything Codeman did - // not scaffold). Claude mode INSTALLS the hooks block when it is missing and - // refreshes ours when it is stale — a linked case never got one otherwise, which - // left every hook-driven surface dead there (see POST /api/sessions above). - // Other modes keep the narrower COD-91 self-heal: only claude reads `.claude` - // hooks, so a shell/codex quick-start should not author a block of its own. - // Skipped for remote cases — resolvedCasePath is a REMOTE path that doesn't - // exist on the local filesystem. + // not scaffold): install-or-refresh per the setting (see applyWorkspaceHooks). + // Other modes keep the narrower COD-91 self-heal unconditionally: only claude + // reads `.claude` hooks, so a shell/codex quick-start should not author a block + // of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that + // doesn't exist on the local filesystem. if (mode === 'claude') { - await ensureCodemanHooks(resolvedCasePath).catch(() => {}); + await applyWorkspaceHooks(ctx, resolvedCasePath); } else { await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); } @@ -2956,9 +2969,9 @@ export function registerSessionRoutes( await writeHooksConfig(resolvedCasePath); } else { // 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`, so - // install ours rather than only refreshing an existing block. - await ensureCodemanHooks(resolvedCasePath).catch(() => {}); + // linked case. This branch is already gated on `docker.hooksEnabled`, and + // applyWorkspaceHooks adds the user-level gate on top. + await applyWorkspaceHooks(ctx, resolvedCasePath); } } catch { /* non-fatal — the session still runs, hooks may be degraded */ diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 1f2366c3..d01d39bd 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -906,6 +906,17 @@ export const SettingsUpdateSchema = z * add-only at create; a marker keeps user-authored copies untouched. */ agentSkillEnabled: z.boolean().optional(), + /** + * Install Codeman's hooks block into the workspace of every Claude session, + * not only into cases Codeman scaffolded itself. SYNCED, default ON: without + * it a linked case or an existing repo runs with no hooks at all, and each + * hook-driven surface is silently dead there (tab alert, Approvals Inbox, + * push, respawn's definitive idle signals, the wait endpoints' stop/blocked). + * Turning it OFF restores the older, narrower behavior — a Codeman hooks + * block that is already present is still refreshed when stale, but one is + * never added — for a user who wants Codeman to leave their repos alone. + */ + workspaceHooksEnabled: z.boolean().optional(), /** * Let browser dictation transcribe through this machine's Claude Code login, * the same speech-to-text service the CLI's own `/voice` mode uses diff --git a/src/web/server.ts b/src/web/server.ts index 189d4db9..62f1acec 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -637,6 +637,7 @@ export class WebServer extends EventEmitter { getClaudeModeConfig: this.getClaudeModeConfig.bind(this), getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this), getAgentSkillEnabled: this.getAgentSkillEnabled.bind(this), + getWorkspaceHooksEnabled: this.getWorkspaceHooksEnabled.bind(this), getClaudeVoiceEnabled: this.getClaudeVoiceEnabled.bind(this), getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this), getLightState: this.getLightState.bind(this), @@ -1711,6 +1712,16 @@ export class WebServer extends EventEmitter { return settings.agentSkillEnabled === true; } + // Whether a Claude session installs Codeman's hooks block into its workspace + // (synced `workspaceHooksEnabled` setting). Default ON — an absent key means a + // user who has never seen this 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. + private async getWorkspaceHooksEnabled(): Promise { + const settings = await this.readSettings(); + return settings.workspaceHooksEnabled !== false; + } + // Whether browser dictation may use this machine's Claude Code credentials // (synced `claudeVoiceEnabled` setting, default OFF; docs/claude-voice-plan.md). // OFF by default because turning it on spends the operator's Claude subscription @@ -2866,8 +2877,13 @@ export class WebServer extends EventEmitter { * Failures are swallowed per workspace: `ensureCodemanHooks` already refuses * unsafe targets with a warning, and a workspace we cannot write to must not * stop the rest of recovery. + * + * 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. */ private async ensureHooksForRecoveredWorkspaces(): Promise { + if (!(await this.getWorkspaceHooksEnabled())) return; const workspaces = new Set(); for (const session of this.sessions.values()) { if (session.mode !== 'claude' || session.remote) continue; diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index 60751abc..e7b60419 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -19,6 +19,7 @@ export function createMockRouteContext(options?: { sessionId?: string; agentSkillEnabled?: boolean; claudeVoiceEnabled?: boolean; + workspaceHooksEnabled?: boolean; }) { const sessionId = options?.sessionId ?? 'test-session-1'; const session = createMockSession(sessionId); @@ -96,6 +97,9 @@ export function createMockRouteContext(options?: { getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false), // Default OFF mirrors the shipped setting: no test opens a voice relay by accident. getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false), + // Default ON mirrors the shipped setting, so a route test sees what a user sees. + // Writes land in the test's temp working dir, never in a real repo. + getWorkspaceHooksEnabled: vi.fn(async () => options?.workspaceHooksEnabled ?? true), getDefaultClaudeMdPath: vi.fn(async () => undefined), getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })), getLightSessionsState: vi.fn(() => { diff --git a/test/routes/session-routes-workspace-hooks.test.ts b/test/routes/session-routes-workspace-hooks.test.ts index c1960cf8..e1eb8130 100644 --- a/test/routes/session-routes-workspace-hooks.test.ts +++ b/test/routes/session-routes-workspace-hooks.test.ts @@ -22,6 +22,7 @@ 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'; interface HooksFile { hooks?: Record }>>; @@ -29,6 +30,32 @@ interface HooksFile { model?: unknown; } +/** + * A faithful PRE-SECRET Codeman hooks block (what a case created before COD-54 + * contains): it targets /api/hook-event, so it is recognisably ours, but carries + * no X-Codeman-Hook-Secret header and no -k. Used to prove the self-heal still + * runs with the setting OFF. + */ +function staleCodemanHooks() { + return { + Stop: [ + { + matcher: '', + hooks: [ + { + type: 'command', + command: + "HOOK_DATA=$(cat 2>/dev/null || echo '{}'); " + + 'printf \'{"event":"stop","sessionId":"%s","data":%s}\' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ' + + 'curl -s -X POST "$CODEMAN_API_URL/api/hook-event" -H \'Content-Type: application/json\' --data @- 2>/dev/null || true', + timeout: 5, + }, + ], + }, + ], + }; +} + describe('POST /api/sessions workspace hooks', () => { let app: FastifyInstance; let workingDir: string; @@ -39,6 +66,16 @@ describe('POST /api/sessions workspace hooks', () => { const createSession = (payload: Record) => app.inject({ method: 'POST', url: '/api/sessions', payload }); + /** Rebuild the app with the `workspaceHooksEnabled` gate in a given position. */ + const useApp = async (workspaceHooksEnabled: boolean) => { + await app?.close(); + app = Fastify({ logger: false }); + await app.register(fastifyCookie); + registerSessionRoutes(app, createMockRouteContext({ workspaceHooksEnabled })); + installRouteErrorHandler(app); + await app.ready(); + }; + beforeEach(async () => { workingDir = await mkdtemp(join(tmpdir(), 'codeman-workspace-hooks-')); app = Fastify({ logger: false }); @@ -108,4 +145,33 @@ describe('POST /api/sessions workspace hooks', () => { expect((await createSession({ name: 'hooks-malformed', mode: 'claude', workingDir })).statusCode).toBe(200); expect(await readFile(settingsPath(), 'utf-8')).toBe('{ not json'); }); + + it('adds nothing when workspaceHooksEnabled is OFF', async () => { + await useApp(false); + + expect((await createSession({ name: 'hooks-off', mode: 'claude', workingDir })).statusCode).toBe(200); + expect(existsSync(settingsPath())).toBe(false); + }); + + it('still heals a stale Codeman block when workspaceHooksEnabled is OFF', async () => { + // The setting turns off ADDING hooks, not the COD-91 self-heal: a pre-secret + // block 401s against the now-unconditional hook-secret gate, so a workspace that + // already opted in must not be left with hooks that silently fail. + await useApp(false); + await mkdir(join(workingDir, '.claude'), { recursive: true }); + await writeFile(settingsPath(), JSON.stringify({ model: 'opus', hooks: staleCodemanHooks() })); + + expect((await createSession({ name: 'hooks-off-stale', mode: 'claude', workingDir })).statusCode).toBe(200); + + const settings = await readSettings(); + expect(settings.model).toBe('opus'); + expect(JSON.stringify(settings.hooks)).toContain('X-Codeman-Hook-Secret'); + }); + + it('writes the hooks the generator produces, so the two cannot drift', async () => { + expect((await createSession({ name: 'hooks-parity', mode: 'claude', workingDir })).statusCode).toBe(200); + + const written = (await readSettings()).hooks ?? {}; + expect(Object.keys(written).sort()).toEqual(Object.keys(generateHooksConfig().hooks).sort()); + }); });