From 74662dd78886347ef6035a669594e10ce004b3cc Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 14 Aug 2026 14:46:23 +0200 Subject: [PATCH] fix(skill): stale user-level skill copy shadowed injections; seed the preamble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live failures from one root cause: Claude Code loads a same-named user-level skill (~/.claude/skills/codeman, written once by `codeman skill install`) over the fresh per-case copy, and nothing ever refreshed it. A stale Aug-9 copy (pre fast-path, pre lineage header) made every agent-driven spawn run the old recipes: workers spawned serially with pid polls and without X-Codeman-Parent-Session, so the web UI drew no lineage arcs. - refreshUserAgentSkill(): session create now refreshes a marker-owned user-level copy (refresh-only: absent copies are not installed, foreign/symlink copies stay untouched). - seedAgentSessionPreamble(): local claude session create pre-seeds the skill's preamble into ${XDG_CACHE_HOME:-~/.cache}/codeman-agent-.sh, single-sourced from the new skills/codeman/preamble.sh, so the skill's §0 bootstrap collapses to a two-line loader instead of a ~150-line paste the model has to type out (measured ~47s of generation per run). - SKILL.md: §0 now leads with the loader and keeps the full block as the stale/missing fallback; explicit verbatim-paste warning (a hand-assembled preamble is how the header and the fast-path functions got lost); spawn_worker also sends parentSessionId in the body as defense in depth; preamble stamp bumped to 1.18.3 so pre-fix cached preambles self-heal. - test/agent-skill.test.ts pins preamble.sh byte-identical to the SKILL.md heredoc and covers seeding (XDG + HOME fallback, 0600) and the user-level refresh (absent/stale/foreign). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- skills/codeman/SKILL.md | 52 ++++++--- skills/codeman/preamble.sh | 158 ++++++++++++++++++++++++++++ skills/codeman/reference/recipes.md | 2 +- src/hooks-config.ts | 44 ++++++++ src/web/routes/session-routes.ts | 23 ++++ test/agent-skill.test.ts | 95 ++++++++++++++++- 7 files changed, 357 insertions(+), 19 deletions(-) create mode 100644 skills/codeman/preamble.sh diff --git a/CLAUDE.md b/CLAUDE.md index f535ef96..64151bf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,7 +182,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) -**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` come from Claude Code hooks and therefore fire for **`claude` mode ONLY** (`shell` installs none either); asking for one explicitly on another mode is a 400, the default set silently drops them. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case ]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` +**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` come from Claude Code hooks and therefore fire for **`claude` mode ONLY** (`shell` installs none either); asking for one explicitly on another mode is a 400, the default set silently drops them. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case ]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 35cedd5f..7e14417b 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -41,7 +41,28 @@ the preamble to a file once and source it afterwards, rather than re-pasting a hundred-odd lines at the top of every call (a half-re-pasted preamble used to be the single most likely way to break a run). -Run this block once per Codeman session: +**Codeman seeds the preamble file for you** when it spawns a claude session (server +1.18.3+), so the bootstrap is usually just loading it — the same two lines every later +call starts with: + +```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; } +``` + +If that passed, §0 is done: go straight to your job (§1's block opens with this same +loader, so when §1 is the job you can simply start there). Only when it reports +missing or stale, run the full block below once — and run it **verbatim**: paste it +as-is, never re-type it, trim it, or "extract the parts you need". A hand-assembled +preamble is the documented failure mode of this skill: one live run rebuilt it +"minimally" and lost the `X-Codeman-Parent-Session` header (every worker spawned with +no lineage arc in the web UI) and the fast-path functions (the spawn fell back to a +serial quick-start loop plus pid polls), turning a ten-second job into a fifty-second +one. If your harness directs temporary files into a scratchpad directory, that +directive covers task scratch, not this file: it is a per-session cache that every +later call re-sources by this exact path, so keep the path below. If you must relocate +it anyway, copy the block's content byte-for-byte unchanged and source your path in +every later call instead. ```bash test "${CODEMAN_MUX:-}" = 1 || { echo "Not inside a Codeman-managed session; refusing to act."; exit 1; } @@ -50,8 +71,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.2$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.18.2 (written by the SKILL.md §0 bootstrap) ---- +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) ---- 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 @@ -105,8 +126,10 @@ _composer_up() { # -> "true"/"false". `shift+tab` is the one # composer draws, and pid!=null proved startup, never readiness. spawn_worker() { local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r + # parentSessionId doubles the CURL header, so a spawn_worker copied off the shared + # curl (or a body someone rebuilt from this recipe) still carries its lineage. q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \ - -d "$(jq -nc --arg n "$name" --arg m "$mode" '{caseName:$n,mode:$m}')") + -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" '{caseName:$n,mode:$m,parentSessionId:$p}')") sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q") # NOT retryable in a loop: every quick-start failure code is terminal (§5.1). [ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; } @@ -206,18 +229,14 @@ 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.2 +CODEMAN_PREAMBLE=1.18.3 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.18.2 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { 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 these two lines instead: - -```bash -. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.18.2 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } -``` +Every later Bash call that touches the API starts with the same two loader lines from +the top of this section. Why it is built this way, all of it load-bearing: @@ -259,7 +278,8 @@ Fill in the case names and the prompts. Everything below is `spawn_workers` / to assemble and no per-call body to hand-build. ```bash -. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" # §0 +. "${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; } N=(alpha beta) # one FRESH case name per worker T=('reply with one line: the absolute path of your working directory' 'reply with one line: your model name') # tasks, same order as N @@ -292,7 +312,11 @@ the time went into deliberation, not the API. The three things that actually cos - **Spawning serially.** One worker per Bash call is one model turn per worker. `&` plus `wait`, as above, makes N workers cost about what one costs. - **Re-deriving the happy path** from §5.1 + §5.2 + §5.3 + §5.10. That is what the - preamble functions exist to end. Compose them; do not rebuild them. + preamble functions exist to end. Compose them; do not rebuild them. The tells that + you are rebuilding anyway: a `for` loop around `quick-start`, a poll on `.data.pid`, + a bespoke `ready()` or `spawn()` of your own. Each is a worse copy of a function + already sitting in your preamble; the live run that wrote them spawned serially, + polled pid for nothing, and shipped its workers without lineage. - **Verifying what is already checked for you.** Two verifications specifically are not worth a call here, because `spawn_worker` carries them: the hooks check (it refuses a name that resolved to a hook-less directory with one local grep, so a worker it hands diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh new file mode 100644 index 00000000..a3aa4bf9 --- /dev/null +++ b/skills/codeman/preamble.sh @@ -0,0 +1,158 @@ +# ---- Codeman agent preamble 1.18.3 (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 +# CODEMAN_PASSWORD already (§6 explains why, and what to do when it has not); +# the data dir's .env is the documented fallback, the same one `codeman attach` +# reads. The data dir is wherever the hook-secret file lives. Values may be +# quoted or `export`-prefixed. +ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}" +envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; } +if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then + CODEMAN_USERNAME=$(envval CODEMAN_USERNAME) + CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD) +fi +AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD") +# -k: harmless on http, required on https (self-signed cert). +# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can +# draw the lineage. Set once here and every present and future create call carries it; +# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never +# fail a spawn, so there is no case where you would want to leave it off. +CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF") +CID=codeman-agent-1 # FIXED literal, never "agent-$$": see below + +# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older +# `is_self "$SID" || curl -X DELETE ...` shape failed OPEN, because an undefined +# is_self exits 127 and the `||` branch then ran the delete completely unguarded. +# Undefined delete_session is "command not found", which deletes nothing. +delete_session() { + local id="${1:-}" + [ -n "$id" ] || { echo "refusing: empty session id"; return 1; } + [ "${#SELF}" -ge 8 ] || { echo "refusing: \$SELF unset or too short to prove this is not me"; return 1; } + # ids appear in full AND 8-char form (Docker exports a truncated $SELF; mux names and + # UI surfaces carry 8-char ids), so compare by prefix in BOTH directions. Equality or + # a one-directional check each miss a real combination, and the miss deletes you. + case "$id" in "$SELF"*) echo "refusing: $id is me"; return 1 ;; esac + case "$SELF" in "$id"*) echo "refusing: $id is me"; return 1 ;; esac + "${CURL[@]}" -X DELETE "$API/api/v1/sessions/$id" +} + +# ---- fast path: the four verbs, already written. §1 composes them. ---- +_composer_up() { # -> "true"/"false". `shift+tab` is the one token + "${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \ + --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \ + --data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false' +} +# spawn_worker [mode] -> session id on stdout, diagnostics on stderr. +# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means +# a READY claude worker in a hook-carrying case. Anything less is rc 1 with EMPTY +# stdout, and the half-spawned session is deleted here rather than handed back, because +# a worker that never drew its composer would eat the task prompt with its trust +# dialog. There is deliberately no pid poll: wait-output already blocks until the +# composer draws, and pid!=null proved startup, never readiness. +spawn_worker() { + local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r + # parentSessionId doubles the CURL header, so a spawn_worker copied off the shared + # curl (or a body someone rebuilt from this recipe) still carries its lineage. + q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" '{caseName:$n,mode:$m,parentSessionId:$p}')") + sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q") + # 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. + 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 + 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 + # paying the whole long wait before the fallback even runs (§5.2). A warm case + # matches in under a second and never reaches the probe. + r=$(_composer_up "$sid" 5000) + if [ "$r" != true ]; then + if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \ + --data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \ + | jq -e '.data.wait.matched' >/dev/null; then + # Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback. + "${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null + fi + r=$(_composer_up "$sid" 45000) + fi + [ "$r" = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >&2 + delete_session "$sid" >/dev/null; return 1; } + printf '%s\n' "$sid" +} +# spawn_workers ... -> one " " line per worker, in order; +# the sessionId column is EMPTY for a spawn that failed (stderr has why). CONCURRENT: +# N workers cost about what one costs. Spawning them one Bash call at a time is the +# single biggest avoidable delay in this skill. Names must be UNIQUE: two workers in +# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race. +spawn_workers() { + local d n i=0 + [ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; } + [ -z "$(printf '%s\n' "$@" | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; } + d=$(mktemp -d "${TMPDIR:-/tmp}/codeman-spawn.XXXXXX") || return 1 + for n in "$@"; do ( spawn_worker "$n" > "$d/$i" ) & i=$((i+1)); done + wait + i=0; for n in "$@"; do printf '%s %s\n' "$n" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done + rm -rf "$d" +} +# sendwait [seq] -> blocks until that worker's turn ENDS (~10 min ceiling +# across its two waits). One billed turn. The \r and the per-worker clientId are applied +# here, which is why you never hand-build this body. seq defaults to the CURRENT EPOCH +# SECOND so that every new prompt is a new frame: the server drops any (clientId,seq) +# pair it has already applied, so a fixed default would make every later prompt to that +# worker a silent no-op that still "succeeds" and reports the previous turn's state. +# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a +# deliberate duplicate, at the SAME number (§5.3). +# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the +# typed prompt stranded on the composer while a long wait runs its whole timeout +# (observed live). So the first wait is short; on its timeout a bare \r goes out (the +# missing Enter when the prompt is stranded, a no-op when the turn is genuinely +# running), then the ORIGINAL frame is resent unchanged, which the server takes as a +# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy only for a claude +# worker spawn_worker handed back (hooks vetted); hook-less workspaces and other modes +# resolve on flapping idle: markers instead (§5.5). +sendwait() { + local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r + body=$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$seq" \ + '{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:20000}') + r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \ + -H 'Content-Type: application/json' --data-binary "$body") + if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then + "${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \ + '{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null + r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \ + -H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")") + fi + printf '%s\n' "$r" +} +# last_text [prev] -> that worker's last assistant message. Polled, because the +# transcript write LAGS the stop signal, and "some text exists" is not "THIS turn's +# text exists": right after a SECOND turn on the same worker the endpoint still serves +# the previous answer for a beat (observed live). When reading consecutive turns, pass +# the previous answer as [prev]: the poll then holds out for text that differs from it, +# falling back to whatever it last saw if the budget runs dry, so an honestly repeated +# answer still comes back. Non-zero exit means the worker really never wrote one. +last_text() { + local t="" prev="${2:-}" + for _ in $(seq 1 15); do + t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty') + [ -n "$t" ] && [ "$t" != "$prev" ] && { printf '%s\n' "$t"; return 0; } + sleep 1 + done + [ -n "$t" ] && { printf '%s\n' "$t"; return 0; } + return 1 +} + +# 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 diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index 4f3795b6..31d61e05 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.18.2 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the diff --git a/src/hooks-config.ts b/src/hooks-config.ts index bfe7dc94..c9365de6 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -31,6 +31,7 @@ import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -948,6 +949,49 @@ export async function installAgentSkillInto(skillDir: string): Promise.sh`, + * default `~/.cache/`) from the packaged `skills/codeman/preamble.sh`, so the agent + * skill's §0 bootstrap collapses to a two-line loader instead of a ~150-line block the + * model has to type out (measured live: that paste alone cost a spawn run ~47 s of + * generation time). The path formula must match the skill's + * `${XDG_CACHE_HOME:-$HOME/.cache}` exactly; sessions inherit the server's env, so + * reading the server's own XDG_CACHE_HOME keeps the two in agreement (`||` mirrors the + * shell's `:-`, treating empty as unset). Callers gate to LOCAL claude sessions (a + * remote or in-container HOME is not this filesystem) and treat it as best-effort: the + * skill's §0 fallback block self-heals a missing or stale file. + */ +export async function seedAgentSessionPreamble(sessionId: string): Promise { + const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8'); + const cacheDir = process.env.XDG_CACHE_HOME || join(homedir(), '.cache'); + await mkdir(cacheDir, { recursive: true }); + await writeFile(join(cacheDir, `codeman-agent-${sessionId}.sh`), content, { mode: 0o600 }); +} + +/** + * Refresh the USER-LEVEL skill copy (`~/.claude/skills/codeman`) IF one exists and is + * Codeman-managed. `codeman skill install` (no `--case`) writes that copy once, and + * unlike per-case copies (re-installed on every session create) nothing ever refreshed + * it, so it stayed at whatever version installed it. That matters because Claude Code + * loads the USER-LEVEL copy over a case's fresh one when both carry the name `codeman`: + * observed live 2026-08-14, an Aug 9 user copy (pre fast-path, pre lineage header) + * shadowed the current per-case injections, so every agent-driven spawn ran the old + * recipes, spawned workers serially, and lost their lineage arcs. + * + * Refresh-ONLY: an absent copy is not installed (the user never asked for a global + * copy), and foreign/symlink copies are refused by installAgentSkillInto itself. + */ +export async function refreshUserAgentSkill(): Promise { + const skillDir = join(homedir(), '.claude', 'skills', 'codeman'); + try { + const existing = await readFile(join(skillDir, 'SKILL.md'), 'utf-8'); + if (!existing.includes(AGENT_SKILL_MARKER_PREFIX)) return 'foreign'; + } catch { + return 'absent'; + } + return installAgentSkillInto(skillDir); +} + /** * Remove a Codeman-managed skill copy from `skillDir`. Same ownership and symlink * refusals as the install path. Deletes only files the packaged source would have diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 8f309aec..cb5ccb2e 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -82,6 +82,8 @@ import { stripCaseEnvKeys, applyStatusLineConfig, applyAgentSkill, + refreshUserAgentSkill, + seedAgentSessionPreamble, refreshStaleCodemanHooks, } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; @@ -601,6 +603,13 @@ function abortOnClientHangUp(reply: FastifyReply): AbortController { async function injectAgentSkill(casePath: string): Promise { const skillDir = join(casePath, '.claude', 'skills', 'codeman'); try { + // Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, + // written once by `codeman skill install`) over the case copy injected below, so a + // stale user copy silently replaces every fresh injection (observed 2026-08-14: an + // old copy cost every spawned worker its lineage arc and the fast path). Keep it + // current on the same trigger. Refresh-only + marker-guarded; quiet on refusal, + // since a foreign user copy is the user's own authored skill, not a config error. + await refreshUserAgentSkill(); const result = await applyAgentSkill(casePath, true); if (result === 'foreign') { console.warn( @@ -913,6 +922,13 @@ export function registerSessionRoutes( ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); + // Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line + // loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort. + if (mode === 'claude' && !remote && (await ctx.getAgentSkillEnabled())) { + await seedAgentSessionPreamble(session.id).catch((err: unknown) => + console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`) + ); + } getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name }); // Use light state for broadcast + response — buffers are fetched on-demand via /terminal. @@ -3016,6 +3032,13 @@ export function registerSessionRoutes( ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); + // Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line + // loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort. + if (mode === 'claude' && !remote && !docker && (await ctx.getAgentSkillEnabled())) { + await seedAgentSessionPreamble(session.id).catch((err: unknown) => + console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`) + ); + } getLifecycleLog().log({ event: 'created', sessionId: session.id, diff --git a/test/agent-skill.test.ts b/test/agent-skill.test.ts index afc3aa2c..924c0268 100644 --- a/test/agent-skill.test.ts +++ b/test/agent-skill.test.ts @@ -11,11 +11,17 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir } from 'node:fs/promises'; +import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { applyAgentSkill, installAgentSkillInto, removeAgentSkillFrom } from '../src/hooks-config.js'; +import { tmpdir, homedir } from 'node:os'; +import { + applyAgentSkill, + installAgentSkillInto, + removeAgentSkillFrom, + refreshUserAgentSkill, + seedAgentSessionPreamble, +} from '../src/hooks-config.js'; const MARKER_PREFIX = '\n`); + + expect(await refreshUserAgentSkill()).toBe('refreshed'); + const refreshed = await readFile(join(userSkillDir(), 'SKILL.md'), 'utf-8'); + expect(refreshed.startsWith('---\nname: codeman')).toBe(true); + expect(existsSync(join(userSkillDir(), 'reference', 'endpoints.md'))).toBe(true); + + // And a second run settles to unchanged. + expect(await refreshUserAgentSkill()).toBe('unchanged'); + }); + + it("leaves a user's own (unmarked) skill alone", async () => { + await mkdir(userSkillDir(), { recursive: true }); + await writeFile(join(userSkillDir(), 'SKILL.md'), 'my own codeman skill\n'); + expect(await refreshUserAgentSkill()).toBe('foreign'); + expect(await readFile(join(userSkillDir(), 'SKILL.md'), 'utf-8')).toBe('my own codeman skill\n'); + }); +});