fix(skill): stale user-level skill copy shadowed injections; seed the preamble

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-<id>.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 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-14 14:46:23 +02:00
parent 0a89505358
commit 74662dd788
7 changed files with 357 additions and 19 deletions
+1 -1
View File
@@ -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) **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 <name>]` / `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 <name>]` / `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-<id>.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`. **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
+38 -14
View File
@@ -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 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). 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 ```bash
test "${CODEMAN_MUX:-}" = 1 || { echo "Not inside a Codeman-managed session; refusing to act."; exit 1; } 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")" 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.2$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' grep -qs '^CODEMAN_PREAMBLE=1.18.3$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.18.2 (written by the SKILL.md §0 bootstrap) ---- # ---- 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}" 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
@@ -105,8 +126,10 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
# composer draws, and pid!=null proved startup, never readiness. # composer draws, and pid!=null proved startup, never readiness.
spawn_worker() { spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r 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' \ 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") 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). # 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; }
@@ -206,18 +229,14 @@ 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.2 CODEMAN_PREAMBLE=1.18.3
PREAMBLE 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: Every later Bash call that touches the API starts with the same two loader lines from
the top of this section.
```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; }
```
Why it is built this way, all of it load-bearing: 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. to assemble and no per-call body to hand-build.
```bash ```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 N=(alpha beta) # one FRESH case name per worker
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
@@ -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 - **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. `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 - **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 - **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 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 name that resolved to a hook-less directory with one local grep, so a worker it hands
+158
View File
@@ -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() { # <sid> <timeoutMs> -> "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 <caseName> [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 <caseName>... -> one "<caseName> <sessionId>" 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 <sid> <prompt> [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 <sid> [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
+1 -1
View File
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```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.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 Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
+44
View File
@@ -31,6 +31,7 @@
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir } from 'node:fs/promises'; 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 { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -948,6 +949,49 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
}); });
} }
/**
* Seed a claude session's agent preamble file (`$XDG_CACHE_HOME/codeman-agent-<id>.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<void> {
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<AgentSkillApplyResult | 'absent'> {
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 * 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 * refusals as the install path. Deletes only files the packaged source would have
+23
View File
@@ -82,6 +82,8 @@ import {
stripCaseEnvKeys, stripCaseEnvKeys,
applyStatusLineConfig, applyStatusLineConfig,
applyAgentSkill, applyAgentSkill,
refreshUserAgentSkill,
seedAgentSessionPreamble,
refreshStaleCodemanHooks, refreshStaleCodemanHooks,
} from '../../hooks-config.js'; } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js'; import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -601,6 +603,13 @@ function abortOnClientHangUp(reply: FastifyReply): AbortController {
async function injectAgentSkill(casePath: string): Promise<void> { async function injectAgentSkill(casePath: string): Promise<void> {
const skillDir = join(casePath, '.claude', 'skills', 'codeman'); const skillDir = join(casePath, '.claude', 'skills', 'codeman');
try { 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); const result = await applyAgentSkill(casePath, true);
if (result === 'foreign') { if (result === 'foreign') {
console.warn( console.warn(
@@ -913,6 +922,13 @@ export function registerSessionRoutes(
ctx.store.incrementSessionsCreated(); ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session); ctx.persistSessionState(session);
await ctx.setupSessionListeners(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 }); getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal. // Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
@@ -3016,6 +3032,13 @@ export function registerSessionRoutes(
ctx.store.incrementSessionsCreated(); ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session); ctx.persistSessionState(session);
await ctx.setupSessionListeners(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({ getLifecycleLog().log({
event: 'created', event: 'created',
sessionId: session.id, sessionId: session.id,
+92 -3
View File
@@ -11,11 +11,17 @@
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 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 { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir, homedir } from 'node:os';
import { applyAgentSkill, installAgentSkillInto, removeAgentSkillFrom } from '../src/hooks-config.js'; import {
applyAgentSkill,
installAgentSkillInto,
removeAgentSkillFrom,
refreshUserAgentSkill,
seedAgentSessionPreamble,
} from '../src/hooks-config.js';
const MARKER_PREFIX = '<!-- codeman-managed-agent-skill'; const MARKER_PREFIX = '<!-- codeman-managed-agent-skill';
@@ -120,3 +126,86 @@ describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n'); expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
}); });
}); });
describe('preamble single-source (seed + §0 heredoc parity)', () => {
const packagedDir = join(process.cwd(), 'skills', 'codeman');
it("SKILL.md's §0 heredoc is byte-identical to the packaged preamble.sh", async () => {
const skillMd = await readFile(join(packagedDir, 'SKILL.md'), 'utf-8');
const openTag = "<<'PREAMBLE'\n";
const open = skillMd.indexOf(openTag);
expect(open).toBeGreaterThan(-1);
const start = open + openTag.length;
const end = skillMd.indexOf('\nPREAMBLE\n', start);
expect(end).toBeGreaterThan(start);
// slice(.., end + 1) keeps the final line's own newline.
const heredoc = skillMd.slice(start, end + 1);
// The server seeds preamble.sh while agents that paste §0 write the heredoc; any
// byte of drift between the two would make the §0 grep rewrite a seeded file (or
// worse, ship different behavior depending on which path wrote it).
const preamble = await readFile(join(packagedDir, 'preamble.sh'), 'utf-8');
expect(preamble).toBe(heredoc);
});
it('seedAgentSessionPreamble writes the stamped preamble to the XDG cache path, 0600', async () => {
const prevXdg = process.env.XDG_CACHE_HOME;
const cacheDir = join(casePath, 'xdg-cache');
process.env.XDG_CACHE_HOME = cacheDir;
try {
await seedAgentSessionPreamble('seed-test-session');
const target = join(cacheDir, 'codeman-agent-seed-test-session.sh');
const content = await readFile(target, 'utf-8');
expect(content.startsWith('# ---- Codeman agent preamble')).toBe(true);
expect(content).toMatch(/\nCODEMAN_PREAMBLE=\d+\.\d+\.\d+\n$/);
expect((await stat(target)).mode & 0o777).toBe(0o600);
} finally {
if (prevXdg === undefined) delete process.env.XDG_CACHE_HOME;
else process.env.XDG_CACHE_HOME = prevXdg;
}
});
it('seedAgentSessionPreamble falls back to ~/.cache when XDG_CACHE_HOME is unset', async () => {
const prevXdg = process.env.XDG_CACHE_HOME;
delete process.env.XDG_CACHE_HOME;
try {
await seedAgentSessionPreamble('seed-home-session');
// setup.ts points HOME at a per-file fixture, so this never touches the real ~.
const target = join(homedir(), '.cache', 'codeman-agent-seed-home-session.sh');
expect(existsSync(target)).toBe(true);
} finally {
if (prevXdg !== undefined) process.env.XDG_CACHE_HOME = prevXdg;
}
});
});
describe('refreshUserAgentSkill (the user-level copy must not rot)', () => {
const userSkillDir = () => join(homedir(), '.claude', 'skills', 'codeman');
it('reports absent and installs nothing when there is no user-level copy', async () => {
expect(await refreshUserAgentSkill()).toBe('absent');
expect(existsSync(userSkillDir())).toBe(false);
});
it('refreshes a stale Codeman-managed user copy back to the packaged content', async () => {
await mkdir(userSkillDir(), { recursive: true });
// An old injected version: different content, marker intact. This is the exact
// shape that shadowed every fresh per-case injection on 2026-08-14.
await writeFile(join(userSkillDir(), 'SKILL.md'), `old skill body\n\n${MARKER_PREFIX}: installed by Codeman -->\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');
});
});