From 98fa8c00d14d430885c1ec7844027ebabc4cbe40 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 04:46:01 +0200 Subject: [PATCH 1/5] fix: install Codeman hooks into every claude workspace, not just cases Codeman created A session in a linked case (or any pre-existing repo) ran with no hooks block at all: writeHooksConfig only fires when Codeman CREATES the case directory, and refreshStaleCodemanHooks deliberately never adds one. Every hook-driven surface was therefore dead in exactly the place most sessions run: no tab alert or phone-overview NEEDS YOU 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 agent wait endpoints. Both session-create paths and restoreMuxSessions() now call ensureCodemanHooks(), an add-only merge that keeps a user's own handlers and leaves a malformed settings file untouched. Claude Code re-reads settings.local.json, so a session already running in the workspace starts firing hooks without a restart. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- src/hooks-config.ts | 34 +++--- src/web/routes/session-routes.ts | 42 +++++-- src/web/server.ts | 34 ++++++ .../session-routes-workspace-hooks.test.ts | 111 ++++++++++++++++++ 5 files changed, 198 insertions(+), 25 deletions(-) create mode 100644 test/routes/session-routes-workspace-hooks.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index a65f2017..2093820f 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`. +**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. **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/hooks-config.ts b/src/hooks-config.ts index c9365de6..39164046 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -646,22 +646,28 @@ export async function writeHooksConfig(casePath: string): Promise { } /** - * Ensures an explicitly managed case has the current Codeman hooks. + * Ensures a workspace Codeman is about to run Claude in has the current Codeman hooks. * - * Unlike `refreshStaleCodemanHooks`, this may add Codeman handlers to a valid - * user-owned settings file. It is therefore reserved for case quick-starts, - * where the user has explicitly asked Codeman to manage that workspace. A - * malformed existing file is left untouched rather than replaced. + * 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. * - * ⚠️ It has NO production call site: PR #233 landed it with the hook scripts and never - * wired it up, and knip can't flag it (`test/**` are entry points, so its tests count as - * a use). Kept anyway, because it is redundant with neither sibling: `writeHooksConfig` - * REPLACES a malformed settings file and rewrites unconditionally, and - * `refreshStaleCodemanHooks` deliberately never adds hooks to a case that has none. The - * one place it fits is quick-start's existing-case branch in session-routes.ts, and - * moving that branch onto this function is a POLICY change (hooks would come back for a - * user who deleted them from their case, and linked cases would start getting a hooks - * block they have never had), so that call is left to the owner rather than made here. + * ⚠️ 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). */ export async function ensureCodemanHooks(casePath: string): Promise { await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index cb5ccb2e..89a1f5e3 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -84,6 +84,7 @@ import { applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, + ensureCodemanHooks, refreshStaleCodemanHooks, } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; @@ -765,11 +766,21 @@ export function registerSessionRoutes( await applyStatusLineConfig(workingDir, true); } - // COD-91 self-heal: refresh a pre-secret hooks block in an existing case so the now - // unconditional hook-secret gate keeps accepting its hook events. No-op for fresh - // cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks. + // 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). if ((body.mode ?? 'claude') === 'claude') { - await refreshStaleCodemanHooks(workingDir).catch(() => {}); + await ensureCodemanHooks(workingDir).catch(() => {}); // 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 @@ -2899,11 +2910,19 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); } } else if (!remote && !docker && mode !== 'opencode') { - // COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the - // now-unconditional hook-secret gate keeps accepting its hook events. No-op when - // the hooks aren't ours or already carry the secret. Skipped for remote cases — - // resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem. - await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); + // 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. + if (mode === 'claude') { + await ensureCodemanHooks(resolvedCasePath).catch(() => {}); + } else { + await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); + } } // Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create, @@ -2936,7 +2955,10 @@ export function registerSessionRoutes( if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) { await writeHooksConfig(resolvedCasePath); } else { - await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); + // 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(() => {}); } } catch { /* non-fatal — the session still runs, hooks may be degraded */ diff --git a/src/web/server.ts b/src/web/server.ts index 4231b162..189d4db9 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -76,6 +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 { PushSubscriptionStore } from '../push-store.js'; import webpush from 'web-push'; import { SseStreamManager } from './sse-stream-manager.js'; @@ -2819,6 +2820,13 @@ export class WebServer extends EventEmitter { } } + // Sessions recovered from a previous run predate the create-path hook + // install, and these are long-lived: by the time a server restart comes + // round a session may be days old and has been running hook-blind the + // whole time. Claude Code re-reads settings.local.json, so writing the + // block now arms the RUNNING CLI, no session restart needed. + await this.ensureHooksForRecoveredWorkspaces(); + // Start stats collection for mux sessions this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); } @@ -2845,6 +2853,32 @@ export class WebServer extends EventEmitter { } } + /** + * Install Codeman's hooks into the workspaces of the sessions just recovered. + * + * Deduped by workspace, because sessions in one repo share a single + * `.claude/settings.local.json` and the write is otherwise repeated per tab. + * Claude mode only (nothing else reads `.claude` hooks), never for remote + * sessions (their `workingDir` is a path on ANOTHER host, so writing it here + * would scaffold a stray directory locally), and never for a docker case that + * opted out of hooks. + * + * 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. + */ + private async ensureHooksForRecoveredWorkspaces(): Promise { + const workspaces = new Set(); + for (const session of this.sessions.values()) { + if (session.mode !== 'claude' || session.remote) continue; + if (session.docker && !session.docker.hooksEnabled) continue; + if (session.workingDir) workspaces.add(session.workingDir); + } + for (const workspace of workspaces) { + await ensureCodemanHooks(workspace).catch(() => {}); + } + } + /** * COD-108 — handle a `remoteSessionDropped` emit from the watcher: reattach * the dropped remote session and report the outcome back to the watcher so it diff --git a/test/routes/session-routes-workspace-hooks.test.ts b/test/routes/session-routes-workspace-hooks.test.ts new file mode 100644 index 00000000..c1960cf8 --- /dev/null +++ b/test/routes/session-routes-workspace-hooks.test.ts @@ -0,0 +1,111 @@ +/** + * @fileoverview Hooks are installed into the workspace a claude session starts in. + * + * Regression cover for the 2026-08-15 report: a session in a LINKED case (the user's + * own repo, where most sessions live) ran with no hooks block at all, because + * `writeHooksConfig` only fires when Codeman CREATES a case directory and the old + * self-heal call deliberately never ADDED one. The visible symptom was an + * AskUserQuestion dialog blocking the pane while the tab and the phone overview both + * showed a calm `idle` — no hook event, so no pending-hook state, so no alert. + * + * Asserts bytes on disk (the real `ensureCodemanHooks`), not a spy call. + * Uses app.inject(), so no real HTTP port is needed. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +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'; + +interface HooksFile { + hooks?: Record }>>; + permissions?: unknown; + model?: unknown; +} + +describe('POST /api/sessions workspace hooks', () => { + let app: FastifyInstance; + let workingDir: string; + + const settingsPath = () => join(workingDir, '.claude', 'settings.local.json'); + const readSettings = async (): Promise => JSON.parse(await readFile(settingsPath(), 'utf-8')); + + const createSession = (payload: Record) => + app.inject({ method: 'POST', url: '/api/sessions', payload }); + + beforeEach(async () => { + workingDir = await mkdtemp(join(tmpdir(), 'codeman-workspace-hooks-')); + app = Fastify({ logger: false }); + await app.register(fastifyCookie); + registerSessionRoutes(app, createMockRouteContext()); + installRouteErrorHandler(app); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + await rm(workingDir, { recursive: true, force: true }); + }); + + it('installs hooks in a workspace that has none (the linked-case bug)', async () => { + const res = await createSession({ name: 'hooks-fresh', mode: 'claude', workingDir }); + expect(res.statusCode).toBe(200); + + const settings = await readSettings(); + const matchers = (settings.hooks?.Notification ?? []).map((entry) => entry.matcher); + // permission_prompt is the one an AskUserQuestion dialog raises; the + // elicitation pair is what CLOSES the resulting Approvals Inbox item. + expect(matchers).toEqual( + expect.arrayContaining([ + 'idle_prompt', + 'permission_prompt', + 'elicitation_dialog', + 'elicitation_complete', + 'elicitation_response', + ]) + ); + expect(settings.hooks?.Stop?.length).toBeGreaterThan(0); + + const serialized = JSON.stringify(settings.hooks); + // The two shapes that have historically shipped dead hooks: no secret header + // (401 once the gate went unconditional) and no -k (exit 60 on HTTPS installs). + expect(serialized).toContain('X-Codeman-Hook-Secret'); + expect(serialized).toContain('curl -sk -X POST'); + }); + + it('merges into a user-owned settings file without disturbing it', async () => { + await mkdir(join(workingDir, '.claude'), { recursive: true }); + const userHook = { matcher: 'Write', hooks: [{ type: 'command', command: './my-formatter.sh' }] }; + await writeFile( + settingsPath(), + JSON.stringify({ model: 'opus[1m]', permissions: { allow: ['Read'] }, hooks: { PostToolUse: [userHook] } }) + ); + + expect((await createSession({ name: 'hooks-merge', mode: 'claude', workingDir })).statusCode).toBe(200); + + const settings = await readSettings(); + expect(settings.model).toBe('opus[1m]'); + expect(settings.permissions).toEqual({ allow: ['Read'] }); + expect(JSON.stringify(settings.hooks)).toContain('./my-formatter.sh'); + expect((settings.hooks?.Notification ?? []).length).toBeGreaterThan(0); + }); + + it('leaves a non-claude session alone (only claude reads .claude hooks)', async () => { + expect((await createSession({ name: 'hooks-shell', mode: 'shell', workingDir })).statusCode).toBe(200); + expect(existsSync(settingsPath())).toBe(false); + }); + + it('leaves a malformed settings file untouched rather than replacing it', async () => { + await mkdir(join(workingDir, '.claude'), { recursive: true }); + await writeFile(settingsPath(), '{ not json'); + + expect((await createSession({ name: 'hooks-malformed', mode: 'claude', workingDir })).statusCode).toBe(200); + expect(await readFile(settingsPath(), 'utf-8')).toBe('{ not json'); + }); +}); From f4850851748e40e10619cc50352d0fa816e2fab3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 07:05:10 +0200 Subject: [PATCH 2/5] feat: workspaceHooksEnabled setting as the opt-out for workspace hook installs Installing hooks into any workspace a Claude session runs in is the right default, but it takes a decision away from a user who deliberately removed them: nothing on disk distinguishes "removed on purpose" from "never had any", so they would come back on the next session create. Adds the synced workspaceHooksEnabled setting (App Settings -> Agents & CLIs -> Claude), default ON. OFF restores the older behavior exactly: a Codeman hooks block that is already present is still refreshed when stale (COD-91), but one is never added. Every create path routes through one applyWorkspaceHooks() helper so the gate cannot apply to some paths only, and the boot-time recovery sweep honours it too. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- src/web/ports/config-port.ts | 2 + src/web/public/index.html | 7 ++ src/web/public/settings-ui.js | 4 ++ src/web/routes/session-routes.ts | 63 +++++++++++------- src/web/schemas.ts | 11 ++++ src/web/server.ts | 16 +++++ test/mocks/mock-route-context.ts | 4 ++ .../session-routes-workspace-hooks.test.ts | 66 +++++++++++++++++++ 9 files changed, 149 insertions(+), 26 deletions(-) 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()); + }); }); From 1c949952904e412c00108a3a4366f897120c6ba9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 07:30:10 +0200 Subject: [PATCH 3/5] docs(skill): hooks are a setting now, not who created the directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace-hooks install makes the skill's central hooks rule wrong in the cautious direction. Six places told a worker that a linked case or a raw workingDir has no `stop`/`blocked` and that send-and-wait cannot be trusted there, so an agent would hand-roll output-marker synchronization in exactly the workspaces where `wait:true` now works. Rewritten against the setting rather than directory provenance: - verbs.md §5.1: the where-to-spawn table, the rule paragraph (now naming `workspaceHooksEnabled`, default ON, the add-only merge, and the boot sweep of recovered sessions), and the silent-failure warning. The three cases that stay hook-less regardless are called out: remote SSH sessions, docker cases that opted out, and a workspace Codeman cannot write to. - verbs.md §5.3: the send-and-wait precondition is "the workspace has the hooks block", not "a case Codeman created". - endpoints.md: the Signals-by-mode table is now keyed on the setting, with rows for OFF, for remote/docker-opt-out, and for a session from an older server. The old create-path grep list becomes a "before 1.18.x" note. - SKILL.md §2 + the cost list, recipes.md Flow-1 contrast, messaging.md step 1. "Check, do not assume" is kept and promoted to the load-bearing habit, because the setting is not visible from the call and a session created by an older server that has not restarted still has nothing. The `spawn_worker` hooks grep STAYS: it guards the setting being off, remote sessions, and older servers. Only its diagnostic changes, since "pick an unused name" is no longer the fix. That text lives in both the §0 heredoc and `preamble.sh`, which `test/agent-skill.test.ts` pins byte-identical, so both are patched with the same bytes. Docs only, no behavior change. 23 skill tests green, full test:ci 5109 passed. Co-Authored-By: Claude Opus 5 (1M context) --- skills/codeman/SKILL.md | 19 +++++---- skills/codeman/preamble.sh | 12 +++--- skills/codeman/reference/endpoints.md | 58 ++++++++++++------------- skills/codeman/reference/messaging.md | 14 +++--- skills/codeman/reference/recipes.md | 6 +-- skills/codeman/reference/verbs.md | 61 +++++++++++++++++---------- 6 files changed, 91 insertions(+), 79 deletions(-) diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 30945dda..2afdf5f1 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -138,14 +138,14 @@ spawn_worker() { # NOT retryable in a loop: every quick-start failure code is terminal (§5.1). [ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; } [ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer - # quick-start RESOLVES the name before creating: a linked case or an existing dir - # wins over a fresh scratch case, so "created => hooks" is only true after this one - # local grep (the same marker the server itself checks for). No marker means sendwait - # would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse - # rather than run the job there. + # The server installs hooks into every claude workspace now, so this grep normally + # passes; it stays because the install is gated on a setting the operator can turn + # off, remote sessions never get hooks, and a session created by an older server + # still has none. No marker means sendwait would false-resolve on flapping idle, + # possibly inside the user's REAL repo: refuse rather than run the job there. cp=$(jq -r '.data.casePath // empty' <<<"$q") grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || { - echo "case '$name' resolved to '$cp', which has no Codeman hooks (linked or pre-existing?): pick an unused name, or work §5.1+§5.5 by hand" >&2 + echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2 delete_session "$sid" >/dev/null; return 1; } # Short composer wait FIRST, then the trust-dialog probe: a case still showing the # dialog can never pass the composer wait, so probing early keeps a cold case from @@ -343,7 +343,8 @@ Four things this block leans on, each one link away, no detour needed to run it: `~/codeman-cases/`, not your repo. A name that already means something (a linked case, a pre-existing directory) is refused by `spawn_worker` rather than silently reused. Spawning where the work actually is (a linked case, a git worktree) - is a different call with **no hooks**, and the costliest mistake in this skill: §5.1. + is a different call, and picking the wrong one is the costliest mistake in this + skill: §5.1. Those workspaces do get hooks now, unless the operator disabled it. - `sendwait` supplies the `\r`, picks a fresh `seq`, and self-heals a stranded Enter. A prompt without the `\r` is never submitted (§3), a reused `seq` is silently swallowed as an already-applied duplicate, and an Enter eaten by an Ink repaint @@ -358,9 +359,9 @@ One row per job. Acting on this table alone is correct; the §5 links are the de | I want to | Call | Detail | |-----------|------|--------| -| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/` unless the name is already a case: full signals there. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`, and expect **no hooks**. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) | +| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) | | know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`) | [§5.2](reference/verbs.md#52-readiness) | -| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is only trustworthy in a **case Codeman created** (claude mode + hooks present). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) | +| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy only where the workspace **has hooks** (claude mode; installed by default, but the operator can disable it and remote sessions never get them). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) | | know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) | | read the answer | `GET .../last-response`, **polled** (claude/codex only; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) | | know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) | diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index a3aa4bf9..b69e0e04 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -60,14 +60,14 @@ spawn_worker() { # NOT retryable in a loop: every quick-start failure code is terminal (§5.1). [ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; } [ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer - # quick-start RESOLVES the name before creating: a linked case or an existing dir - # wins over a fresh scratch case, so "created => hooks" is only true after this one - # local grep (the same marker the server itself checks for). No marker means sendwait - # would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse - # rather than run the job there. + # The server installs hooks into every claude workspace now, so this grep normally + # passes; it stays because the install is gated on a setting the operator can turn + # off, remote sessions never get hooks, and a session created by an older server + # still has none. No marker means sendwait would false-resolve on flapping idle, + # possibly inside the user's REAL repo: refuse rather than run the job there. cp=$(jq -r '.data.casePath // empty' <<<"$q") grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || { - echo "case '$name' resolved to '$cp', which has no Codeman hooks (linked or pre-existing?): pick an unused name, or work §5.1+§5.5 by hand" >&2 + echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2 delete_session "$sid" >/dev/null; return 1; } # Short composer wait FIRST, then the trust-dialog probe: a case still showing the # dialog can never pass the composer wait, so probing early keeps a cold case from diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 75b66989..05566671 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -251,10 +251,12 @@ that is expected, not a failure: read `terminal?tail=` and strip ANSI instead. **It means** that session has no Codeman hooks, so `stop` can never fire and the wait silently degraded to `idle`, which flaps mid-turn. Nothing rejected your request: `wait:true` (and even an explicit `until=stop`) is accepted because the 400 is about -session **mode**, and the mode really is `claude`. Hooks are written only when Codeman -**creates** the directory; a linked case or a raw `workingDir` gets none (an existing -case that Codeman created earlier keeps the block it was given), see the table under -[Signals by mode](#signals-by-mode). Measured: on a +session **mode**, and the mode really is `claude`. Hooks are installed into every +claude workspace at session create (synced `workspaceHooksEnabled`, default ON) and +swept across recovered sessions at boot, so a linked case or a raw `workingDir` gets +them too; with the setting off, on a remote session, or on a session from an older +server, they are absent, see the table under +[Signals by mode](#signals-by-mode). Measured before that changed: on a linked case whose `.claude/settings.local.json` carries env/model/permissions/statusLine and no `hooks` block, a `wait?until=stop,exit` parked for twelve consecutive 60 s rounds never resolved although the worker finished its turn. @@ -360,10 +362,10 @@ loop. ⚠️ `caseName` resolves through the linked-cases registry first, so a name that happens to match a case the user linked in lands in that **real repo**, not a fresh scratch directory. Pick distinctive scratch names, and use a linked name deliberately when you -do want a worker in an existing checkout. ⚠️ It also decides whether you get hooks: -Codeman writes them only when it **creates** the directory, so a linked case or a raw -path gives you a worker with no `stop` signal, while a scratch case Codeman created -earlier keeps working signals ([Signals by mode](#signals-by-mode)). +do want a worker in an existing checkout. It no longer decides whether you get hooks: +every claude create path installs them, so a linked case and a raw path both get a +`stop` signal unless the operator turned `workspaceHooksEnabled` off +([Signals by mode](#signals-by-mode)). **The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`, @@ -614,35 +616,27 @@ Three bounded long-polls. Shared semantics: | `exit` | PTY exited or session deleted | every mode | ⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real -precondition is that the session's working directory has a Codeman hooks block**, and -whether it does depends on who created the directory: +precondition is that the session's working directory has a Codeman hooks block**, which +is now installed by default rather than depending on who created the directory: | The worker's directory | Hooks | `stop` / `blocked` | Synchronize with | |------------------------|-------|--------------------|------------------| -| Codeman created it (`quick-start` with a NEW `caseName`, `POST /api/cases`, clone, docker quickcreate) | written at create | fire | send-and-wait on `stop` | -| Codeman never created it (a linked case pointing at your own checkout, a raw `workingDir`) | none written | never fire | `wait-output` markers only | +| any claude workspace, with `workspaceHooksEnabled` ON (the default) | installed at session create, add-only merge | fire | send-and-wait on `stop` | +| the same, with the setting OFF and no block already on disk | none added | never fire | `wait-output` markers only | +| a remote SSH session, a docker case that opted out, a workspace Codeman cannot write | none | never fire | `wait-output` markers only | +| a session created by a pre-1.18.x server and never restarted since | whatever it had | only if present | check, then choose | -⚠️ **Docker cases are the one exception.** For a docker case, quick-start writes hooks -whenever `.claude/settings.local.json` is *missing* (`session-routes.ts:2836-2845`: -absent means write, present means refresh), regardless of who created that host -directory. There the discriminator really is "does the settings file exist". No -downstream advice changes, since docker quickcreate is already on the create side. +The install is an add-only merge, so a user's own hook entries survive and a malformed +settings file is left untouched. Sessions recovered at server boot get the same sweep, +which is what heals sessions created before this behavior existed. When in doubt, test +it rather than reason about it: grep for `/api/hook-event` in +`/.claude/settings.local.json`. -⚠️ For every non-docker case the discriminator is **who created the directory, not -whether it exists now**. A -scratch case Codeman created last week still has its hooks block on disk, so -`quick-start` against that existing name gets working `stop` signals. Only a directory -Codeman never created lacks them. When in doubt, test it rather than reason about it: -grep for `/api/hook-event` in `/.claude/settings.local.json`. - -`writeHooksConfig()` runs only on the create paths (`case-routes.ts:341`, `:520`, -`:869`, `ralph-routes.ts:318`, `session-routes.ts:2799` inside -`if (!existsSync(resolvedCasePath))`, `:2841` for docker). Quick-start against a -directory that already exists takes the else-if branch and calls -`refreshStaleCodemanHooks()`, which returns immediately when there is no -`settings.local.json` and again when the hooks it finds are not ours -(`hooks-config.ts:706-731`); it never *adds* a hooks block. `POST /api/cases/link` is -not on that list at all: it only records a name-to-path entry. See +Before 1.18.x, `writeHooksConfig()` ran only on the create paths and `quick-start` +against an existing directory called `refreshStaleCodemanHooks()`, which never *adds* a +block, so a linked case or a raw `workingDir` had no hooks at all. `POST +/api/cases/link` still only records a name-to-path entry; what changed is that the +session-create path installs hooks regardless of how the directory got there. See [symptom 8](#8-send-and-wait-resolves-instantly-with-signalidle-and-the-answer-is-last-turns). Default `until` set: `stop,idle,exit`. On non-claude modes the server silently drops diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index 3edea7d2..ac7e391c 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -196,12 +196,14 @@ idle: The contract an orchestrator follows for any fleet of two or more messaging workers. Every topology in the next section is this protocol plus a wiring diagram. -1. **Spawn with a name, and with hooks.** Use `quick-start` with `sessionName` (the - `--name` gate above), and let it **CREATE** the case. ⚠️ Linking does NOT install - hooks (`POST /api/cases/link` writes only the name-to-path entry), and neither does a - bare `POST /api/sessions`; a worker in a directory Codeman did not create has no - `stop`/`blocked` signals at all and every synchronization below degrades to output - markers. The discriminator is who created the directory, not whether it exists now. +1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the + `--name` gate above). Session create installs the hooks block into the workspace + whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get + `stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn + `workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from + an older server may have none, and without them every synchronization below degrades + to output markers. Grep `/.claude/settings.local.json` for + `/api/hook-event` at spawn rather than inferring it from how the directory got there. 2. **Readiness before addressing.** Flow 1's ladder per worker, then the availability probe. A worker that fails the probe is an HTTP worker for the rest of the run; that is a routing decision, not an error. diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index 31d61e05..c5c4f985 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -492,9 +492,9 @@ What breaks if you use send-and-wait anyway: `wait:true` is accepted (the 400 is *mode*, not about hooks, and these are claude-mode sessions), so the call falls back to the default set's `idle`, which is a heuristic that flaps mid-turn. You get a "finished" answer for a turn still running, and `last-response` then hands you the *previous* -turn's text. The contrast is the lesson: a worker in a case Codeman created (Flow 1) has -the hooks, so `stop` there is definitive and free. In a worktree you pay one marker per -worker instead. +turn's text. The contrast is the lesson: a worker whose workspace carries the hooks +block (Flow 1, and by default any other workspace too) has a `stop` that is definitive +and free. Where the block is absent you pay one marker per worker instead. ```bash declare -A TOK diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index e44b4897..d00a8114 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -30,28 +30,40 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo: | Where the work is | Call | Hooks, and therefore signals | |-------------------|------|------------------------------| | a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy | -| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **no hooks**, unless that repo already carries a Codeman hooks block from some earlier path. Check before relying on `stop` | -| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | **no hooks**: no `stop`, no `blocked`, synchronize with markers ([§5.5](#55-markers-for-hook-less-workers)) | +| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check | +| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check | Read `.data.casePath` back from the `quick-start` response and check it is where you meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through the linked-cases registry **first**, so a name that collides with something the user linked in lands in that real repo rather than a scratch dir. -**The rule is who created the directory.** Codeman writes hooks only where it created -the workspace itself: `quick-start` on a NEW case name, `POST /api/cases`, the repo -clone, the docker quick-create. Those hooks persist, so a scratch case created last -week still has them today. A directory that already existed when Codeman first pointed -at it never gets them: `POST /api/cases/link` writes only the name-to-path entry in -`linked-cases.json`, and quick-start into an existing path runs -`refreshStaleCodemanHooks()`, which by design returns immediately when there is no -Codeman hooks block to refresh. Source-verified by exhaustive call-site grep, and -measured: a worker in a linked case never resolved a parked `wait?until=stop,exit` -across twelve consecutive 60 s rounds, although it had finished its turn. +**The rule is a setting, not who created the directory.** Every claude create path +(`POST /api/sessions`, `POST /api/quick-start`, and quick-start's docker branch) now +installs the hooks block into the workspace, and the server sweeps the workspaces of +sessions it recovers at boot. So a linked case, a cloned repo and a hand-made git +worktree all get `stop`/`blocked`, not just a scratch case Codeman scaffolded. The +install is an **add-only merge**: a user's own hook entries and every other settings +key survive, and a malformed settings file is left alone. -**Check, do not assume.** Read `/.claude/settings.local.json` with your own -file tools and look for `/api/hook-event`. Present means `stop`/`blocked` will fire; -absent means they never will. +The gate is the synced **`workspaceHooksEnabled`** setting, **default ON** (an absent +key counts as ON). Turned OFF, the old behavior returns exactly: an existing Codeman +block is still refreshed when stale, but one is never added, and the boot sweep is +skipped. Three cases stay hook-less regardless: **remote SSH sessions** (their +`workingDir` is a path on another host), **docker cases that opted out**, and any +workspace Codeman cannot write to. + +Until this landed, hooks existed only where Codeman created the directory, and the +gap was invisible: a worker in a linked case never resolved a parked +`wait?until=stop,exit` across twelve consecutive 60 s rounds, although it had finished +its turn. If you are driving an older server, assume that older rule. + +**Check, do not assume.** This is now the load-bearing habit, because you cannot tell +from the call which way the setting is set, and an old session created before the fix +on a server that has not restarted still has nothing. Read +`/.claude/settings.local.json` with your own file tools and look for +`/api/hook-event`. Present means `stop`/`blocked` will fire; absent means they never +will, whatever kind of workspace it is. ⚠️ **The hook-less failure is silent, and it is the worst one in this skill.** `"wait":true` is still **accepted** on a hook-less claude session: the 400 you may be @@ -59,9 +71,10 @@ expecting is about session *mode*, not about hooks. With no `stop` to resolve on default signal set falls back to the heuristic `idle`, which flaps mid-turn, so send-and-wait returns "finished" while the worker is still working, and the `last-response` you read next hands you the **previous** turn's text. No error is -raised anywhere. In any workspace Codeman did not create, use markers -([§5.5](#55-markers-for-hook-less-workers)) and treat send-and-wait's answer as -unreliable. +raised anywhere. Hooks are installed by default now, so this is rarer than it was, but +the failure is unchanged when it happens: in any workspace whose settings file has no +`/api/hook-event`, use markers ([§5.5](#55-markers-for-hook-less-workers)) and treat +send-and-wait's answer as unreliable. Spawning at a raw path: @@ -238,11 +251,13 @@ fi ### 5.3 Send a task and wait -⚠️ **Precondition: this is the call to prefer only for a claude worker in a workspace -Codeman created**, because it is trustworthy only when the `stop` hook exists. On a -linked case or a raw path it is accepted, resolves on flapping `idle`, and reports a -turn as finished while it is still running, with no error anywhere. Check hooks first -([§5.1](#51-where-to-spawn)); where they are absent, use markers +⚠️ **Precondition: a claude worker whose workspace has the hooks block**, because +this is trustworthy only when the `stop` hook exists. Every claude create path installs +it by default now, so that is the normal case, but where it is absent (the setting off, +a remote session, an older server) the call is still accepted, resolves on flapping +`idle`, and reports a turn as finished while it is still running, with no error +anywhere. Check hooks first ([§5.1](#51-where-to-spawn)); where they are absent, use +markers ([§5.5](#55-markers-for-hook-less-workers)). It registers the waiter *before* typing, From 499d35566b0deee500e1159b7ba6e815c200d5e5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 19:15:08 +0200 Subject: [PATCH 4/5] review fixes: never install workspace hooks for a remote attach or a cwd-fallback create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A claude-mode attachRemoteSession create overwrites workingDir with the user@host:session pseudo-path, which is a RELATIVE path locally — the old refresh-only call no-op'd on it, but ensureCodemanHooks mkdirs, so it created a junk local directory. And with workingDir omitted the cwd fallback reaches the hooks write unvalidated; under installer-created services cwd is $HOME, so hooks materialized in ~/.claude/settings.local.json. Both guarded at the applyWorkspaceHooks call site; regression tests prove the remote attach leaves no junk dir and the no-workingDir create leaves the server cwd untouched. Co-Authored-By: Claude Fable 5 --- src/web/routes/session-routes.ts | 8 +++-- .../session-routes-workspace-hooks.test.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 5174221f..c4a00c57 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -793,8 +793,12 @@ export function registerSessionRoutes( } // Hooks for the workspace this session runs in (install vs refresh-only is the - // `workspaceHooksEnabled` setting; see applyWorkspaceHooks). - if ((body.mode ?? 'claude') === 'claude') { + // `workspaceHooksEnabled` setting; see applyWorkspaceHooks). Never for a remote + // attach (workingDir is a user@host:session pseudo-path — mkdir 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 hooks + // materializing in ~/.claude/settings.local.json was never asked for. + if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') { 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 diff --git a/test/routes/session-routes-workspace-hooks.test.ts b/test/routes/session-routes-workspace-hooks.test.ts index e1eb8130..eac6685e 100644 --- a/test/routes/session-routes-workspace-hooks.test.ts +++ b/test/routes/session-routes-workspace-hooks.test.ts @@ -23,6 +23,7 @@ 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 { getDataDir } from '../../src/config/instance.js'; interface HooksFile { hooks?: Record }>>; @@ -138,6 +139,37 @@ describe('POST /api/sessions workspace hooks', () => { expect(existsSync(settingsPath())).toBe(false); }); + 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. + 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 after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null; + expect(after).toBe(before); + }); + + 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. + await mkdir(getDataDir(), { recursive: true }); + await writeFile( + join(getDataDir(), 'remote-hosts.json'), + JSON.stringify([{ id: 'h1', label: 'box', host: '10.0.0.5', username: 'dev' }]) + ); + + const res = await createSession({ + name: 'hooks-remote', + mode: 'claude', + attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' }, + }); + expect(res.statusCode).toBe(200); + expect(existsSync(join(process.cwd(), 'dev@10.0.0.5:codeman-ssh-abc123'))).toBe(false); + }); + it('leaves a malformed settings file untouched rather than replacing it', async () => { await mkdir(join(workingDir, '.claude'), { recursive: true }); await writeFile(settingsPath(), '{ not json'); From c8f3981b0c3a472e4ae24efee8183ff5b8c941ce Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 16 Aug 2026 19:16:10 +0200 Subject: [PATCH 5/5] review fixes: 1.19.0 is the real version boundary, and the preamble stamp matches its bytes again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit endpoints.md named 1.18.x as the version where workspace hooks became a setting, but 1.18.x servers do NOT have this behavior — an agent driving one would falsely conclude its workspace has hooks. The feature ships in 1.19.0. And preamble.sh changed content this PR without bumping its CODEMAN_PREAMBLE stamp, so a cache stamped 1.18.3 would pass the staleness check while holding old bytes; stamp bumped to 1.19.0 in preamble.sh and the SKILL.md heredoc together (byte-identity pin). Co-Authored-By: Claude Fable 5 --- skills/codeman/SKILL.md | 12 ++++++------ skills/codeman/preamble.sh | 4 ++-- skills/codeman/reference/endpoints.md | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 2afdf5f1..bc371540 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.18.3$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.18.3 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.19.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -233,10 +233,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.18.3 +CODEMAN_PREAMBLE=1.19.0 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -287,7 +287,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first T=('reply with one line: the absolute path of your working directory' 'reply with one line: your model name') # tasks, same order as N diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index b69e0e04..ccb007ea 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.18.3 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}" SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}" # Credentials, cheapest first. Your session has usually INHERITED the server's @@ -155,4 +155,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.18.3 +CODEMAN_PREAMBLE=1.19.0 diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 05566671..12c2fb98 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -624,7 +624,7 @@ is now installed by default rather than depending on who created the directory: | any claude workspace, with `workspaceHooksEnabled` ON (the default) | installed at session create, add-only merge | fire | send-and-wait on `stop` | | the same, with the setting OFF and no block already on disk | none added | never fire | `wait-output` markers only | | a remote SSH session, a docker case that opted out, a workspace Codeman cannot write | none | never fire | `wait-output` markers only | -| a session created by a pre-1.18.x server and never restarted since | whatever it had | only if present | check, then choose | +| a session created by a pre-1.19.0 server and never restarted since | whatever it had | only if present | check, then choose | The install is an add-only merge, so a user's own hook entries survive and a malformed settings file is left untouched. Sessions recovered at server boot get the same sweep, @@ -632,7 +632,7 @@ which is what heals sessions created before this behavior existed. When in doubt it rather than reason about it: grep for `/api/hook-event` in `/.claude/settings.local.json`. -Before 1.18.x, `writeHooksConfig()` ran only on the create paths and `quick-start` +Before 1.19.0, `writeHooksConfig()` ran only on the create paths and `quick-start` against an existing directory called `refreshStaleCodemanHooks()`, which never *adds* a block, so a linked case or a raw `workingDir` had no hooks at all. `POST /api/cases/link` still only records a name-to-path entry; what changed is that the