mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8f3981b0c | ||
|
|
1c94995290 | ||
|
|
f485085174 | ||
|
|
98fa8c00d1 |
@@ -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)
|
**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`.
|
**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`.
|
||||||
|
|
||||||
|
|||||||
+16
-15
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${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
|
⚠️ **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")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# 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.
|
# 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'
|
grep -qs '^CODEMAN_PREAMBLE=1.19.0$' "$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) ----
|
# ---- 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}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# 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).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
|
[ "$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
|
# The server installs hooks into every claude workspace now, so this grep normally
|
||||||
# wins over a fresh scratch case, so "created => hooks" is only true after this one
|
# passes; it stays because the install is gated on a setting the operator can turn
|
||||||
# local grep (the same marker the server itself checks for). No marker means sendwait
|
# off, remote sessions never get hooks, and a session created by an older server
|
||||||
# would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse
|
# still has none. No marker means sendwait would false-resolve on flapping idle,
|
||||||
# rather than run the job there.
|
# possibly inside the user's REAL repo: refuse rather than run the job there.
|
||||||
cp=$(jq -r '.data.casePath // empty' <<<"$q")
|
cp=$(jq -r '.data.casePath // empty' <<<"$q")
|
||||||
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
|
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; }
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
|
# 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
|
# 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
|
# 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
|
# 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.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.18.3
|
CODEMAN_PREAMBLE=1.19.0
|
||||||
PREAMBLE
|
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
|
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
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${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
|
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'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
'reply with one line: your model name') # tasks, same order as N
|
'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/<name>`, not your repo. A name that already means something (a
|
`~/codeman-cases/<name>`, not your repo. A name that already means something (a
|
||||||
linked case, a pre-existing directory) is refused by `spawn_worker` rather than
|
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)
|
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.
|
- `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
|
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
|
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 |
|
| I want to | Call | Detail |
|
||||||
|-----------|------|--------|
|
|-----------|------|--------|
|
||||||
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` 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/<name>` 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
|
|||||||
@@ -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}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# 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).
|
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
|
||||||
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
|
||||||
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
|
[ "$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
|
# The server installs hooks into every claude workspace now, so this grep normally
|
||||||
# wins over a fresh scratch case, so "created => hooks" is only true after this one
|
# passes; it stays because the install is gated on a setting the operator can turn
|
||||||
# local grep (the same marker the server itself checks for). No marker means sendwait
|
# off, remote sessions never get hooks, and a session created by an older server
|
||||||
# would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse
|
# still has none. No marker means sendwait would false-resolve on flapping idle,
|
||||||
# rather than run the job there.
|
# possibly inside the user's REAL repo: refuse rather than run the job there.
|
||||||
cp=$(jq -r '.data.casePath // empty' <<<"$q")
|
cp=$(jq -r '.data.casePath // empty' <<<"$q")
|
||||||
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
|
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; }
|
delete_session "$sid" >/dev/null; return 1; }
|
||||||
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
|
# 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
|
# 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
|
# 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
|
# 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.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.18.3
|
CODEMAN_PREAMBLE=1.19.0
|
||||||
|
|||||||
@@ -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
|
**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:
|
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
|
`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
|
session **mode**, and the mode really is `claude`. Hooks are installed into every
|
||||||
**creates** the directory; a linked case or a raw `workingDir` gets none (an existing
|
claude workspace at session create (synced `workspaceHooksEnabled`, default ON) and
|
||||||
case that Codeman created earlier keeps the block it was given), see the table under
|
swept across recovered sessions at boot, so a linked case or a raw `workingDir` gets
|
||||||
[Signals by mode](#signals-by-mode). Measured: on a
|
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
|
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
|
and no `hooks` block, a `wait?until=stop,exit` parked for twelve consecutive 60 s rounds
|
||||||
never resolved although the worker finished its turn.
|
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
|
⚠️ `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
|
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
|
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:
|
do want a worker in an existing checkout. It no longer decides whether you get hooks:
|
||||||
Codeman writes them only when it **creates** the directory, so a linked case or a raw
|
every claude create path installs them, so a linked case and a raw path both get a
|
||||||
path gives you a worker with no `stop` signal, while a scratch case Codeman created
|
`stop` signal unless the operator turned `workspaceHooksEnabled` off
|
||||||
earlier keeps working signals ([Signals by mode](#signals-by-mode)).
|
([Signals by mode](#signals-by-mode)).
|
||||||
|
|
||||||
**The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in
|
**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`,
|
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 |
|
| `exit` | PTY exited or session deleted | every mode |
|
||||||
|
|
||||||
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
|
⚠️ **`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
|
precondition is that the session's working directory has a Codeman hooks block**, which
|
||||||
whether it does depends on who created the directory:
|
is now installed by default rather than depending on who created the directory:
|
||||||
|
|
||||||
| The worker's directory | Hooks | `stop` / `blocked` | Synchronize with |
|
| 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` |
|
| any claude workspace, with `workspaceHooksEnabled` ON (the default) | installed at session create, add-only merge | 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 |
|
| 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
|
The install is an add-only merge, so a user's own hook entries survive and a malformed
|
||||||
whenever `.claude/settings.local.json` is *missing* (`session-routes.ts:2836-2845`:
|
settings file is left untouched. Sessions recovered at server boot get the same sweep,
|
||||||
absent means write, present means refresh), regardless of who created that host
|
which is what heals sessions created before this behavior existed. When in doubt, test
|
||||||
directory. There the discriminator really is "does the settings file exist". No
|
it rather than reason about it: grep for `/api/hook-event` in
|
||||||
downstream advice changes, since docker quickcreate is already on the create side.
|
`<casePath>/.claude/settings.local.json`.
|
||||||
|
|
||||||
⚠️ For every non-docker case the discriminator is **who created the directory, not
|
Before 1.19.0, `writeHooksConfig()` ran only on the create paths and `quick-start`
|
||||||
whether it exists now**. A
|
against an existing directory called `refreshStaleCodemanHooks()`, which never *adds* a
|
||||||
scratch case Codeman created last week still has its hooks block on disk, so
|
block, so a linked case or a raw `workingDir` had no hooks at all. `POST
|
||||||
`quick-start` against that existing name gets working `stop` signals. Only a directory
|
/api/cases/link` still only records a name-to-path entry; what changed is that the
|
||||||
Codeman never created lacks them. When in doubt, test it rather than reason about it:
|
session-create path installs hooks regardless of how the directory got there. See
|
||||||
grep for `/api/hook-event` in `<casePath>/.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
|
|
||||||
[symptom 8](#8-send-and-wait-resolves-instantly-with-signalidle-and-the-answer-is-last-turns).
|
[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
|
Default `until` set: `stop,idle,exit`. On non-claude modes the server silently drops
|
||||||
|
|||||||
@@ -196,12 +196,14 @@ idle:
|
|||||||
The contract an orchestrator follows for any fleet of two or more messaging workers.
|
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.
|
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
|
1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the
|
||||||
`--name` gate above), and let it **CREATE** the case. ⚠️ Linking does NOT install
|
`--name` gate above). Session create installs the hooks block into the workspace
|
||||||
hooks (`POST /api/cases/link` writes only the name-to-path entry), and neither does a
|
whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get
|
||||||
bare `POST /api/sessions`; a worker in a directory Codeman did not create has no
|
`stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn
|
||||||
`stop`/`blocked` signals at all and every synchronization below degrades to output
|
`workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from
|
||||||
markers. The discriminator is who created the directory, not whether it exists now.
|
an older server may have none, and without them every synchronization below degrades
|
||||||
|
to output markers. Grep `<casePath>/.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
|
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
|
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.
|
is a routing decision, not an error.
|
||||||
|
|||||||
@@ -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
|
*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"
|
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*
|
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
|
turn's text. The contrast is the lesson: a worker whose workspace carries the hooks
|
||||||
the hooks, so `stop` there is definitive and free. In a worktree you pay one marker per
|
block (Flow 1, and by default any other workspace too) has a `stop` that is definitive
|
||||||
worker instead.
|
and free. Where the block is absent you pay one marker per worker instead.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
declare -A TOK
|
declare -A TOK
|
||||||
|
|||||||
@@ -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 |
|
| 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 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` |
|
| 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` | **no hooks**: no `stop`, no `blocked`, synchronize with markers ([§5.5](#55-markers-for-hook-less-workers)) |
|
| 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
|
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
|
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
|
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.
|
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 rule is a setting, not who created the directory.** Every claude create path
|
||||||
the workspace itself: `quick-start` on a NEW case name, `POST /api/cases`, the repo
|
(`POST /api/sessions`, `POST /api/quick-start`, and quick-start's docker branch) now
|
||||||
clone, the docker quick-create. Those hooks persist, so a scratch case created last
|
installs the hooks block into the workspace, and the server sweeps the workspaces of
|
||||||
week still has them today. A directory that already existed when Codeman first pointed
|
sessions it recovers at boot. So a linked case, a cloned repo and a hand-made git
|
||||||
at it never gets them: `POST /api/cases/link` writes only the name-to-path entry in
|
worktree all get `stop`/`blocked`, not just a scratch case Codeman scaffolded. The
|
||||||
`linked-cases.json`, and quick-start into an existing path runs
|
install is an **add-only merge**: a user's own hook entries and every other settings
|
||||||
`refreshStaleCodemanHooks()`, which by design returns immediately when there is no
|
key survive, and a malformed settings file is left alone.
|
||||||
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.
|
|
||||||
|
|
||||||
**Check, do not assume.** Read `<casePath>/.claude/settings.local.json` with your own
|
The gate is the synced **`workspaceHooksEnabled`** setting, **default ON** (an absent
|
||||||
file tools and look for `/api/hook-event`. Present means `stop`/`blocked` will fire;
|
key counts as ON). Turned OFF, the old behavior returns exactly: an existing Codeman
|
||||||
absent means they never will.
|
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
|
||||||
|
`<casePath>/.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.**
|
⚠️ **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
|
`"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
|
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
|
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
|
`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
|
raised anywhere. Hooks are installed by default now, so this is rarer than it was, but
|
||||||
([§5.5](#55-markers-for-hook-less-workers)) and treat send-and-wait's answer as
|
the failure is unchanged when it happens: in any workspace whose settings file has no
|
||||||
unreliable.
|
`/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:
|
Spawning at a raw path:
|
||||||
|
|
||||||
@@ -238,11 +251,13 @@ fi
|
|||||||
|
|
||||||
### 5.3 Send a task and wait
|
### 5.3 Send a task and wait
|
||||||
|
|
||||||
⚠️ **Precondition: this is the call to prefer only for a claude worker in a workspace
|
⚠️ **Precondition: a claude worker whose workspace has the hooks block**, because
|
||||||
Codeman created**, because it is trustworthy only when the `stop` hook exists. On a
|
this is trustworthy only when the `stop` hook exists. Every claude create path installs
|
||||||
linked case or a raw path it is accepted, resolves on flapping `idle`, and reports a
|
it by default now, so that is the normal case, but where it is absent (the setting off,
|
||||||
turn as finished while it is still running, with no error anywhere. Check hooks first
|
a remote session, an older server) the call is still accepted, resolves on flapping
|
||||||
([§5.1](#51-where-to-spawn)); where they are absent, use markers
|
`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)).
|
([§5.5](#55-markers-for-hook-less-workers)).
|
||||||
|
|
||||||
It registers the waiter *before* typing,
|
It registers the waiter *before* typing,
|
||||||
|
|||||||
+20
-14
@@ -646,22 +646,28 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* Unlike `refreshStaleCodemanHooks`, this may ADD Codeman handlers to a settings
|
||||||
* user-owned settings file. It is therefore reserved for case quick-starts,
|
* file that has none (a linked case, a cloned repo, any directory Codeman did not
|
||||||
* where the user has explicitly asked Codeman to manage that workspace. A
|
* scaffold). It merges rather than replaces, so a user's own hook entries survive,
|
||||||
* malformed existing file is left untouched rather than replaced.
|
* 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
|
* ⚠️ That "may add" is a deliberate POLICY, adopted 2026-08-15 after the symptom it
|
||||||
* wired it up, and knip can't flag it (`test/**` are entry points, so its tests count as
|
* causes was reported: hooks were only ever written when Codeman CREATED a case
|
||||||
* a use). Kept anyway, because it is redundant with neither sibling: `writeHooksConfig`
|
* directory, so every session in a linked case ran with no hooks at all and each
|
||||||
* REPLACES a malformed settings file and rewrites unconditionally, and
|
* hook-driven surface was silently dead there — an AskUserQuestion dialog blocking
|
||||||
* `refreshStaleCodemanHooks` deliberately never adds hooks to a case that has none. The
|
* the pane while the tab and the phone overview both read a calm `idle`, no
|
||||||
* one place it fits is quick-start's existing-case branch in session-routes.ts, and
|
* Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn, and
|
||||||
* moving that branch onto this function is a POLICY change (hooks would come back for a
|
* no `stop`/`blocked` for the agent wait endpoints. The cost of the policy is the
|
||||||
* user who deleted them from their case, and linked cases would start getting a hooks
|
* other direction: a user who DELETES Codeman's hooks from a workspace gets them
|
||||||
* block they have never had), so that call is left to the owner rather than made here.
|
* 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<void> {
|
export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
||||||
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
|
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface ConfigPort {
|
|||||||
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
|
||||||
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
|
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
|
||||||
getAgentSkillEnabled(): Promise<boolean>;
|
getAgentSkillEnabled(): Promise<boolean>;
|
||||||
|
/** Synced `workspaceHooksEnabled` app setting (default ON); gates INSTALLING hooks into a session's workspace. */
|
||||||
|
getWorkspaceHooksEnabled(): Promise<boolean>;
|
||||||
/** Synced `claudeVoiceEnabled` app setting (default OFF); gates the Claude voice dictation relay. */
|
/** Synced `claudeVoiceEnabled` app setting (default OFF); gates the Claude voice dictation relay. */
|
||||||
getClaudeVoiceEnabled(): Promise<boolean>;
|
getClaudeVoiceEnabled(): Promise<boolean>;
|
||||||
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
getDefaultClaudeMdPath(): Promise<string | undefined>;
|
||||||
|
|||||||
@@ -1984,6 +1984,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Workspace Hooks</span>
|
||||||
|
<span class="set-row-desc">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.</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsWorkspaceHooks"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
<div class="set-row" data-search="remote auto reconnect ssh">
|
<div class="set-row" data-search="remote auto reconnect ssh">
|
||||||
<div class="set-row-text">
|
<div class="set-row-text">
|
||||||
<span class="set-row-label">Remote auto-reconnect</span>
|
<span class="set-row-label">Remote auto-reconnect</span>
|
||||||
|
|||||||
@@ -409,6 +409,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Claude Permissions settings
|
// Claude Permissions settings
|
||||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||||
document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? 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('appSettingsClaudeModel').value = settings.claudeModel ?? '';
|
||||||
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
|
||||||
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
|
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
|
||||||
@@ -2017,6 +2020,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Claude Permissions settings
|
// Claude Permissions settings
|
||||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||||
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
|
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
|
||||||
|
workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked,
|
||||||
claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked,
|
claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked,
|
||||||
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
claudeModel: document.getElementById('appSettingsClaudeModel').value,
|
||||||
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import {
|
|||||||
applyAgentSkill,
|
applyAgentSkill,
|
||||||
refreshUserAgentSkill,
|
refreshUserAgentSkill,
|
||||||
seedAgentSessionPreamble,
|
seedAgentSessionPreamble,
|
||||||
|
ensureCodemanHooks,
|
||||||
refreshStaleCodemanHooks,
|
refreshStaleCodemanHooks,
|
||||||
} from '../../hooks-config.js';
|
} from '../../hooks-config.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
@@ -625,6 +626,32 @@ async function injectAgentSkill(casePath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
const install = await ctx.getWorkspaceHooksEnabled();
|
||||||
|
await (install ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
export function registerSessionRoutes(
|
export function registerSessionRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||||||
@@ -765,11 +792,10 @@ export function registerSessionRoutes(
|
|||||||
await applyStatusLineConfig(workingDir, true);
|
await applyStatusLineConfig(workingDir, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// COD-91 self-heal: refresh a pre-secret hooks block in an existing case so the now
|
// Hooks for the workspace this session runs in (install vs refresh-only is the
|
||||||
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
|
// `workspaceHooksEnabled` setting; see applyWorkspaceHooks).
|
||||||
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
|
|
||||||
if ((body.mode ?? 'claude') === 'claude') {
|
if ((body.mode ?? 'claude') === 'claude') {
|
||||||
await refreshStaleCodemanHooks(workingDir).catch(() => {});
|
await applyWorkspaceHooks(ctx, workingDir);
|
||||||
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
// 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
|
// .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
|
// skill from under other live sessions in the repo. Marker-guarded, so a
|
||||||
@@ -2899,11 +2925,17 @@ export function registerSessionRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||||
}
|
}
|
||||||
} else if (!remote && !docker && mode !== 'opencode') {
|
} else if (!remote && !docker && mode !== 'opencode') {
|
||||||
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
|
// EXISTING case directory (a linked case, a cloned repo, anything Codeman did
|
||||||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
// not scaffold): install-or-refresh per the setting (see applyWorkspaceHooks).
|
||||||
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
|
// Other modes keep the narrower COD-91 self-heal unconditionally: only claude
|
||||||
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
|
// reads `.claude` hooks, so a shell/codex quick-start should not author a block
|
||||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
// 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,
|
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
||||||
@@ -2936,7 +2968,10 @@ export function registerSessionRoutes(
|
|||||||
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
|
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
|
||||||
await writeHooksConfig(resolvedCasePath);
|
await writeHooksConfig(resolvedCasePath);
|
||||||
} else {
|
} 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 {
|
} catch {
|
||||||
/* non-fatal — the session still runs, hooks may be degraded */
|
/* non-fatal — the session still runs, hooks may be degraded */
|
||||||
|
|||||||
@@ -906,6 +906,17 @@ export const SettingsUpdateSchema = z
|
|||||||
* add-only at create; a marker keeps user-authored copies untouched.
|
* add-only at create; a marker keeps user-authored copies untouched.
|
||||||
*/
|
*/
|
||||||
agentSkillEnabled: z.boolean().optional(),
|
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,
|
* Let browser dictation transcribe through this machine's Claude Code login,
|
||||||
* the same speech-to-text service the CLI's own `/voice` mode uses
|
* the same speech-to-text service the CLI's own `/voice` mode uses
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import { RunSummaryTracker } from '../run-summary.js';
|
|||||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||||
|
import { ensureCodemanHooks } from '../hooks-config.js';
|
||||||
import { PushSubscriptionStore } from '../push-store.js';
|
import { PushSubscriptionStore } from '../push-store.js';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { SseStreamManager } from './sse-stream-manager.js';
|
import { SseStreamManager } from './sse-stream-manager.js';
|
||||||
@@ -636,6 +637,7 @@ export class WebServer extends EventEmitter {
|
|||||||
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
|
||||||
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
|
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
|
||||||
getAgentSkillEnabled: this.getAgentSkillEnabled.bind(this),
|
getAgentSkillEnabled: this.getAgentSkillEnabled.bind(this),
|
||||||
|
getWorkspaceHooksEnabled: this.getWorkspaceHooksEnabled.bind(this),
|
||||||
getClaudeVoiceEnabled: this.getClaudeVoiceEnabled.bind(this),
|
getClaudeVoiceEnabled: this.getClaudeVoiceEnabled.bind(this),
|
||||||
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
|
||||||
getLightState: this.getLightState.bind(this),
|
getLightState: this.getLightState.bind(this),
|
||||||
@@ -1710,6 +1712,16 @@ export class WebServer extends EventEmitter {
|
|||||||
return settings.agentSkillEnabled === true;
|
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<boolean> {
|
||||||
|
const settings = await this.readSettings();
|
||||||
|
return settings.workspaceHooksEnabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
// Whether browser dictation may use this machine's Claude Code credentials
|
// Whether browser dictation may use this machine's Claude Code credentials
|
||||||
// (synced `claudeVoiceEnabled` setting, default OFF; docs/claude-voice-plan.md).
|
// (synced `claudeVoiceEnabled` setting, default OFF; docs/claude-voice-plan.md).
|
||||||
// OFF by default because turning it on spends the operator's Claude subscription
|
// 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
|
// Start stats collection for mux sessions
|
||||||
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
|
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<void> {
|
||||||
|
if (!(await this.getWorkspaceHooksEnabled())) return;
|
||||||
|
const workspaces = new Set<string>();
|
||||||
|
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
|
* COD-108 — handle a `remoteSessionDropped` emit from the watcher: reattach
|
||||||
* the dropped remote session and report the outcome back to the watcher so it
|
* the dropped remote session and report the outcome back to the watcher so it
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function createMockRouteContext(options?: {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
agentSkillEnabled?: boolean;
|
agentSkillEnabled?: boolean;
|
||||||
claudeVoiceEnabled?: boolean;
|
claudeVoiceEnabled?: boolean;
|
||||||
|
workspaceHooksEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const sessionId = options?.sessionId ?? 'test-session-1';
|
const sessionId = options?.sessionId ?? 'test-session-1';
|
||||||
const session = createMockSession(sessionId);
|
const session = createMockSession(sessionId);
|
||||||
@@ -96,6 +97,9 @@ export function createMockRouteContext(options?: {
|
|||||||
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
|
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
|
||||||
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
|
// Default OFF mirrors the shipped setting: no test opens a voice relay by accident.
|
||||||
getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false),
|
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),
|
getDefaultClaudeMdPath: vi.fn(async () => undefined),
|
||||||
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
|
||||||
getLightSessionsState: vi.fn(() => {
|
getLightSessionsState: vi.fn(() => {
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* @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';
|
||||||
|
|
||||||
|
interface HooksFile {
|
||||||
|
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
|
||||||
|
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<HooksFile> => JSON.parse(await readFile(settingsPath(), 'utf-8'));
|
||||||
|
|
||||||
|
const createSession = (payload: Record<string, unknown>) =>
|
||||||
|
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 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user