diff --git a/CLAUDE.md b/CLAUDE.md index d2a37e9f..f4b52fef 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** (`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/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 30945dda..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 @@ -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 @@ -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 @@ -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..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 @@ -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 @@ -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 75b66989..12c2fb98 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.19.0 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.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 +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, 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/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 7858efcb..13fc8f02 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2061,6 +2061,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 03ffa99a..aa7aab33 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -411,6 +411,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; @@ -2020,6 +2023,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 cb5ccb2e..c4a00c57 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'; @@ -625,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 @@ -765,11 +792,14 @@ 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. - if ((body.mode ?? 'claude') === 'claude') { - await refreshStaleCodemanHooks(workingDir).catch(() => {}); + // Hooks for the workspace this session runs in (install vs refresh-only is the + // `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 // skill from under other live sessions in the repo. Marker-guarded, so a @@ -2899,11 +2929,17 @@ 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): 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 applyWorkspaceHooks(ctx, resolvedCasePath); + } else { + await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); + } } // Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create, @@ -2936,7 +2972,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`, 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 933cf724..55947f06 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 4231b162..62f1acec 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'; @@ -636,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), @@ -1710,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 @@ -2819,6 +2831,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 +2864,37 @@ 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. + * + * 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; + 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/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 new file mode 100644 index 00000000..eac6685e --- /dev/null +++ b/test/routes/session-routes-workspace-hooks.test.ts @@ -0,0 +1,209 @@ +/** + * @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'; +import { generateHooksConfig } from '../../src/hooks-config.js'; +import { getDataDir } from '../../src/config/instance.js'; + +interface HooksFile { + hooks?: Record }>>; + permissions?: unknown; + 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; + + 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 }); + + /** 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 }); + 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 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'); + + 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()); + }); +});