README.zh-CN.md taught a recipe that cannot work: its programmatic-input example had no trailing `\r`, so Enter was never sent and the prompt sat unsubmitted forever, and its read step used `/output`, whose `textOutput` is always empty for interactive tmux-backed sessions. A reader following the Chinese README walked into both of the silent failures the English one warns about. Its agent/automation section is now brought in line with README.md: the `\r` rule and every example that needs it, and the correct read path. CLAUDE.md's "Single-line prompts only" gotcha described the newline restriction but never mentioned that input must end with `\r` or Enter is never sent, which is the most common silent failure when driving the API. docs/agent-control-plan.md asserted as still-open several things that shipped in 1.14.1 and 1.14.2 (the wait endpoints, the packaged skill, the install CLI, agentSkillEnabled). The status header and the stale bullets now match reality; the historical design content is untouched, since the document is a record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
54 KiB
Agent Control Plan: skill packaging + wait primitives
Status: steps 1 to 8 DONE and RELEASED. The wait primitives and the skill itself
(steps 1 to 5) shipped in 1.13.0; the codeman skill install CLI, per-case injection
and agentSkillEnabled (step 6) shipped in 1.14.1 and were republished with fixes in
1.14.2. Steps 1 to 5 were multi-round verified on 2026-08-08, step 6 on 2026-08-09;
see §7 Build log for what shipped, what each
verification round found, and the two items that genuinely remain open (§2.4's footgun
guard and the Part 3 deferrals).
Date: 2026-08-08 Scope: Part 1 (agent skill) and Part 2 (wait primitives) were specified and built. Parts 3 to 5 are captured so they are not lost, but remain deliberately deferred.
0. Where this came from: what herdr does
herdr (Rust, Apache-2.0, ~25.8k stars) is a terminal multiplexer built around AI coding agents. Relevant findings from the research pass:
| Capability | How herdr does it |
|---|---|
| Agent state | Four states (idle, working, blocked, done) that roll up pane to tab to workspace in a sidebar |
| Detection | Lifecycle hooks where the agent supports them (it names Pi and MastraCode), otherwise TOML manifests matched against a live bottom-buffer snapshot. Bundled manifests plus remote updates from herdr.dev, local overrides win |
| Control API | Newline-delimited JSON over a Unix socket (~/.config/herdr/sessions/<name>/herdr.sock), {"id":"req_1","method":"pane.split","params":{}}, dot-notation methods, plus long-lived event subscriptions |
| Discoverability | herdr api schema prints a machine-readable schema |
| Agent skill | npx skills add herdrdev/herdr --skill herdr -g, a SKILL.md wrapping the CLI, guarded by test "${HERDR_ENV:-}" = 1 so an agent outside a herdr pane refuses to act |
| Persistence | Background server, detach with ctrl+b q, snapshot restore of workspaces/tabs/panes/cwd/layout, experimental screen-history replay, agent resume via native session ids, live PTY handoff across server replacement |
| Plugins | herdr-plugin.toml manifest, actions, event hooks, plugin panes, link handlers, GitHub-topic marketplace index |
The commands the skill teaches the agent:
| Group | Commands |
|---|---|
| workspace | workspace list, workspace create |
| tab | tab list --workspace <id>, tab create |
| pane | pane current, pane list, pane layout, pane split --current --direction right --cwd <path> --no-focus, pane run <id> "<cmd>", pane wait-output <id> --match/--regex <p> --timeout <ms>, pane read <id> --source visible|recent|detection |
| agent | agent list, agent start <name> --kind <type> --pane <id>, agent prompt <name> "<text>" --wait --timeout <ms>, agent wait <name> --until <state> --timeout <ms>, agent send-keys, agent get, agent read |
The honest comparison
herdr and Codeman are not the same product. herdr is a local, keyboard-first multiplexer with no server, no web UI, and no autonomy layer. Codeman is a server with a browser and mobile UI, remote and Docker cases, respawn, Ralph, cron, and the orchestrator, none of which herdr has.
What herdr genuinely does better is being callable by the agent running inside it. For Codeman that is a packaging problem plus one missing primitive, not an architecture problem.
1. Gap analysis
| herdr capability | Codeman equivalent today | Gap |
|---|---|---|
pane split + agent start |
POST /api/quick-start, POST /api/sessions |
none, already there |
agent prompt |
POST /api/sessions/:id/input with clientId+seq exactly-once |
no --wait |
pane read |
GET /api/sessions/:id/output, GET /api/sessions/:id/terminal?full=1 |
none |
agent list / agent get |
GET /api/sessions, GET /api/sessions/unified, GET /api/status |
none |
agent wait --until <state> |
SSE only (/api/events) |
missing, and SSE is impractical from a shell tool |
pane wait-output --match |
nothing | missing |
| Skill file | README section "Driving Codeman from an Agent" | not packaged, an agent will never find it |
Env guard HERDR_ENV=1 |
CODEMAN_MUX=1, CODEMAN_API_URL, CODEMAN_SESSION_ID already exported at spawn |
none, the guard variables exist |
blocked state |
hook events (permission_prompt, elicitation_dialog) plus CSS classes plus the phone overview NEEDS YOU section |
not in the wire contract (SessionStatus = 'idle' | 'busy' | 'stopped' | 'error') |
api schema |
hand-written docs/api-reference.md |
no machine-readable schema |
| Detection manifests | hardcoded in usage-limit-patterns.ts, respawn-*-patterns, regex-patterns.ts |
patterns are code, not data |
| Plugin runtime | deliberately refused, see docs/extending-codeman.md |
not a gap, a decision |
| Session handoff on restart | tmux owns the PTYs, so they already survive a Codeman restart | not a gap, solved by architecture |
Conclusion: roughly 90% of the capability surface already exists. Parts 1 and 2 below close the two real gaps.
The table is the 2026-08-08 snapshot that motivated the work, kept as written. The three rows
marked missing are closed since: GET .../wait and GET .../wait-output shipped in 1.13.0, and
the skill is packaged at skills/codeman (npm tarball included). blocked as a wire-contract
state, and the machine-readable schema, are still open (Parts 3 and 4).
2. Part 1: the Codeman agent skill
2.1 Goal
An agent running inside a Codeman session can discover and correctly drive Codeman without the user pasting API docs into the prompt, and without inventing dangerous calls.
2.2 Layout and distribution
The npx skills CLI (vercel-labs/skills) clones a GitHub repo and looks for
skills/<name>/SKILL.md. Claude Code natively discovers .claude/skills/<name>/SKILL.md in a
project and ~/.claude/skills/ globally. Both are satisfied with one source of truth plus a
symlink, which is the pattern this repo already uses for remotion-best-practices.
skills/
codeman/
SKILL.md <- single source of truth
reference/
endpoints.md <- full endpoint tables, loaded on demand
recipes.md <- worked multi-session orchestration examples
.claude/skills/codeman -> ../../skills/codeman (symlink, dogfooding in this repo)
Adding a skills/ directory to the repo root costs one entry in the GitHub listing. CLAUDE.md
keeps the root short on purpose, so this needs a conscious sign-off; the alternative is
docs/skills/codeman/ with a --skill path argument, which breaks the one-liner install.
Recommendation: accept skills/ at the root, because the install one-liner is the whole
point of shipping a skill.
Install paths, in order of how a user gets it:
npx skills add Ark0N/Codeman --skill codeman -g(global, any agent, matches the herdr flow).codeman skill install [--global | --case <name>], a new CLI subcommand writing the same file. This is the path for users who installed via npm and never cloned the repo.- Automatic per-case injection, modeled exactly on
applyStatusLineConfig(casePath, enabled)inhooks-config.ts: write<case>/.claude/skills/codeman/SKILL.mdat case creation, gated on a new setting. Codeman already writes<case>/.claude/settings.local.jsonhooks throughwriteHooksConfig(), so this is the same mechanism with the same lifecycle.
Setting name: agentSkillEnabled. Synced (not per-device), since it changes on-disk case
content rather than display. Default: ON after the dogfooding phase, OFF in the first
release. Rationale for starting OFF: Claude Code loads every skill's name and description
into context on every turn, so an always-on skill has a small permanent token cost, and we
should measure that we are buying something with it first.
2.3 SKILL.md content
Frontmatter, per the skills convention (name + description required):
---
name: codeman
description: >-
Control Codeman, the session manager this agent is running inside: list sessions,
start worker sessions, send prompts, read terminal output, and wait for other agents
to finish. Only usable when CODEMAN_MUX=1.
---
Body sections, in order:
1. Guard (first thing, non-negotiable).
test "${CODEMAN_MUX:-}" = 1 || { echo "not inside a Codeman session"; exit 1; }
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set, refusing to guess}"
SELF="${CODEMAN_SESSION_ID:-}"
If CODEMAN_MUX is not 1, the agent must stop and say it is not running inside a
Codeman-managed session. Same shape as herdr's HERDR_ENV guard, and the variables are
already exported by tmux-manager.buildEnvExports(). No fallback URL when
CODEMAN_API_URL is unset: any guess is the wrong scheme on an HTTPS install (prod is
HTTPS with a self-signed cert, hence curl -sk throughout), and a server the agent
cannot identify is not one it should be driving.
2. Rules of the road. Lifted and tightened from README lines 666 to 745:
- Single-line input only. Multi-line breaks the agent TUI (Ink).
- Always send
clientId+ a monotonicseqonPOST .../inputso a retry cannot double-deliver. - Envelope is
{success, data}; a few legacy GETs are bare, so readbody.data ?? body. - Add
-u admin:"$CODEMAN_PASSWORD"when a password is set. Prod is HTTPS, socurl -sk. - Prefer
/api/v1/*, the stable alias.
3. Safety rules (the section that does not exist anywhere today).
- Never act on
$CODEMAN_SESSION_ID. That is you. - Only
DELETEsessions you created in this conversation, by exact id. Keep the list. - Never bulk-delete, never loop a
DELETEover/api/sessions. There is no undo. - Never
tmux kill-session,pkill tmux,pkill claude. Use the API. - Creating a session consumes a slot against the 50-session cap. Clean up what you start.
4. Recipes, each one a single copy-pasteable curl:
| Task | Call |
|---|---|
| list sessions | GET /api/v1/sessions |
| find yourself | match ids by PREFIX of $CODEMAN_SESSION_ID (Docker cases truncate it to 8 chars, so an equality check never fires there) |
| start a worker | POST /api/v1/quick-start {caseName, mode, effort} |
| send a prompt | POST /api/v1/sessions/:id/input {input:"…\r", useMux:true, clientId, seq} (the trailing \r is what sends Enter; without it the text sits on the prompt unsubmitted) |
| send prompt and wait | POST /api/v1/sessions/:id/input {input:"…\r", wait:"stop", waitTimeout:600000} (Part 2) |
| wait for a worker | GET /api/v1/sessions/:id/wait?until=stop,blocked&timeout=300000 (Part 2) |
| wait for a marker | GET /api/v1/sessions/:id/wait-output?match=DONE_<random>&timeout=120000 (Part 2; unique per call, per §3.3's repaint rule) |
| read output | GET /api/v1/sessions/:id/output |
| read full scrollback | GET /api/v1/sessions/:id/terminal?full=1 |
| watch sub-agents | GET /api/v1/subagents |
| schedule work | POST /api/v1/cron/jobs |
| clean up | DELETE /api/v1/sessions/:id |
5. Pointer to reference/endpoints.md for anything not in the table, so the always-loaded
part of the skill stays small.
2.4 An ergonomics guard worth adding server-side
The skill will tell the agent not to act on itself, but a confused agent can still try. Propose:
the skill sends X-Codeman-Caller-Session: $CODEMAN_SESSION_ID on every request, and the server
refuses destructive operations (DELETE /api/sessions/:id, kill, respawn stop) when that header
equals the target id, with a clear error.
This is a footgun guard, not a security control: any caller can omit the header. Document it
as such so nobody mistakes it for a boundary. It costs about 10 lines in route-helpers.ts.
2.5 Verification
Per the always-end-to-end-test rule, "the skill exists" is not done. Done is:
- Symlink it into
.claude/skills/, start a real throwaway Codeman session, and ask that agent to "start a worker session that runs the test suite and tell me when it finishes". - Confirm from the outside that exactly one new session appeared, got the prompt, and that the lead agent waited rather than polling in a busy loop.
- Confirm the guard: run the same prompt in a shell with
CODEMAN_MUXunset and confirm refusal. - Confirm cleanup: the worker session is deleted by exact id and no other session was touched.
Never run this against w1/w2/w3.
2.6 Files touched
skills/codeman/SKILL.md(new),skills/codeman/reference/*.md(new).claude/skills/codemansymlink (new)src/cli.ts(newskill installsubcommand)src/hooks-config.ts(newapplyAgentSkill(casePath, enabled), mirroringapplyStatusLineConfig)src/web/schemas.ts(agentSkillEnabledinSettingsUpdateSchema, which is.strict())src/web/routes/system-routes.ts(settings PUT must resolve the flag frommerged, never from the raw body, per the partial-PUT invariant)src/web/public/settings-ui.js+index.html(checkbox)package.jsonfilesarray, soskills/ships to npm- README pointer,
docs/extending-codeman.mdseam 3 pointer
3. Part 2: wait primitives
3.1 Goal
Make Codeman orchestratable from a shell tool. Today the only "tell me when" channel is SSE, which a curl-driven agent cannot practically consume: it would have to hold a streaming connection and parse events inline. herdr solves this with blocking CLI calls. Codeman should solve it with bounded long-poll endpoints.
All three additions are additive, so the versioning policy stays intact (new endpoints and new optional fields are non-breaking).
3.2 The signal model
A waiter resolves on the first of a set of signals. Sources that already exist:
| Signal | Source today |
|---|---|
idle |
Session emits idle (session.ts ~1775 for Claude, ~2101 for shell), wired at session-listener-wiring.ts:402 |
working |
Session emits working (session.ts ~1788), wired at session-listener-wiring.ts:401 |
stop |
POST /api/hook-event with event: 'stop', the definitive "Claude finished responding" signal already used by controller.signalStopHook() |
blocked |
POST /api/hook-event with permission_prompt or elicitation_dialog |
exit |
Session emits exit |
stop is the highest-quality signal for "the turn is over" and should be the documented default
for orchestration. idle is heuristic: output stabilization plus prompt detection, and it can
flap mid-turn when a spinner pauses. External CLI modes (isExternalCliMode()) have no stop
hook at all, so for opencode/codex/gemini/antigravity only idle, working and exit are
available. The skill and the docs must say which signals exist per mode, otherwise an agent
waits forever on stop in a codex session.
3.3 Endpoint specs
A. GET /api/sessions/:id/wait
| Param | Type | Default | Notes |
|---|---|---|---|
until |
comma list of idle,working,stop,blocked,exit |
stop,idle,exit |
resolves on first match |
timeout |
ms | 60000 | clamped to MAX_WAIT_MS (600000) |
fresh |
0/1 |
0 |
1 requires a transition, ignoring the state at call time |
Response (always 200 unless the session is missing or a cap is hit):
{
"success": true,
"data": {
"signal": "stop",
"timedOut": false,
"immediate": false,
"ended": false,
"waitedMs": 8421,
"status": "idle",
"sessionId": "...",
"until": ["stop", "idle", "exit"],
"limitPaused": false
}
}
until is echoed back because the server may narrow it: stop/blocked are dropped
from the DEFAULT set for external CLI modes (asking for them EXPLICITLY is a 400
instead, since omitting until must never 400). limitPaused tells a caller that a
timeout was expected rather than a stall worth retrying hard.
A timeout is not an error. {"timedOut": true, "signal": null} with HTTP 200, so a caller
can loop without treating every poll boundary as a failure. Errors are reserved for
NOT_FOUND (unknown or not-owned session) and SESSION_BUSY (waiter cap exceeded).
immediate: true means the session was already in the requested state and fresh was not set.
B. GET /api/sessions/:id/wait-output
| Param | Type | Default | Notes |
|---|---|---|---|
match |
literal string, 1 to 200 chars | required | substring match against ANSI-stripped output |
nocase |
0/1 |
0 |
case-insensitive compare |
from |
now | buffer |
now |
buffer scans the existing text buffer first, then waits |
timeout |
ms | 60000 | clamped to MAX_WAIT_MS |
Response: { matched: true, timedOut: false, snippet: "...", waitedMs }.
No regex in v1, deliberately. search-service.ts already avoids regex specifically so there
is no ReDoS surface, and this endpoint would be even more exposed since the pattern is attacker
supplied and the input is a live stream. herdr can offer --regex because Rust's regex crate is
linear-time with no backtracking; JS RegExp is not. If regex is wanted later, the honest
options are a length-capped subset compiled once with a match budget, or re2. Note it and move on.
Implementation detail that will bite if missed: a match can straddle two PTY chunks. Keep a
carry buffer of match.length - 1 bytes from the previous chunk and test carry + chunk.
⚠️ from=now does not mean "printed after you asked". tmux repaints the visible
screen on attach, resize, or any TUI redraw, and a repaint arrives as ordinary terminal
data. Observed live: a marker echoed a minute earlier matched instantly on a fresh
from=now wait. This is inherent to a terminal multiplexer, not fixable in the registry,
so the contract is: use a marker unique per call (echo DONE_$RANDOM), never a
generic one like BUILD OK. The skill's recipes must show that.
The returned snippet is whitespace-collapsed (blank runs to a single newline) for
readability only; matching runs on the raw stripped text. Without it, a real pane's
\r\n padding between the prompt and the match fills the whole context window with
nothing, which was the first thing the live test showed.
C. wait on the existing input endpoint
POST /api/sessions/:id/input gains two optional fields:
{ "input": "run the tests\r", "useMux": true, "clientId": "agent-1", "seq": 7, "wait": "stop", "waitTimeout": 600000 }
(The trailing \r is required on every input body: sendInput sends Enter only
when the input contains a carriage return.)
Response gains "wait": { "signal": "stop", "timedOut": false, "waitedMs": 41230 }.
This is the important one, because it closes a race the standalone GET .../wait cannot: between
"input delivered" and "session flips to working" there is a window where a naive
send-then-wait sees the pre-existing idle state and returns instantly. The combined endpoint
registers the waiter before writing, so that window does not exist. This is exactly why herdr
ships agent prompt --wait as its own thing.
wait accepts true (the default signal set) or the same comma grammar as until.
Both new fields are .nullish(), not .optional(): a third-party caller building the
body with JSON.stringify keeps an explicit null on the wire, and .optional()
rejects that with INVALID_INPUT. That gotcha has shipped as a real bug twice.
Two behaviors to preserve carefully:
useMuxis fire-and-forget today. The handler responds without awaitingwriteViaMux, on purpose (a tmux child process must not block the HTTP response). Withwaitpresent the handler already has to stay open, so it can await delivery, and awriteViaMuxfailure becomes observable for the first time. The non-wait path must keep its current fire-and-forget shape byte for byte.- Duplicate suppression. A tagged redelivery (
clientId+seqalready applied) returns 200 without writing. Withwaitset it still waits, since the caller's intent is "tell me when this settles". But it waits withrequireTransition: false, unlike a fresh delivery: the original turn may be long over, and requiring a new transition would block a redelivery until timeout for no reason. Fresh delivery requires a transition, a duplicate answers from the current state. - Capacity rollback.
shouldApplyInput()MUTATES (it records the seq), and it runs before the waiter is registered. If registration then fails on a full pool, the handler must callforgetInputSeqbefore returningSESSION_BUSY, or the caller's retry is rejected as a duplicate and the input is lost by the very mechanism reliable delivery exists for.
3.4 Module design
New file src/web/session-wait-registry.ts, with the IO-free core unit-testable in isolation
(same split as self-update.ts):
type WaitSignal = 'idle' | 'working' | 'stop' | 'blocked' | 'exit';
waitForSignal(sessionId, { until: Set<WaitSignal>, timeoutMs, requireTransition }): Promise<WaitResult>
notifySignal(sessionId, signal: WaitSignal): void
waitForOutput(sessionId, { match, nocase, timeoutMs }): Promise<OutputWaitResult>
notifyOutput(sessionId, chunk: string): void
cancelAll(sessionId, reason): void
Wiring points, all existing:
src/web/session-listener-wiring.tsaround lines 190 and 200 already handlesworkingandidleand broadcasts them. Add anotifySignal()call next to each broadcast, plusexit.src/web/routes/hook-event-routes.tsalready switches oneventfor the respawn controller. AddnotifySignal(sessionId, 'stop' | 'blocked')in the same switch.- Output:
notifyOutput()rides the ALREADY-attachedterminallistener in session-listener-wiring.ts. An earlier draft had the registry hand out attach/detach callbacks so a listener could be added lazily; that was deleted once it was clear no second listener is needed at all. The cost is one Map lookup per PTY chunk, which is why the no-waiter check comes before the ANSI strip. - Session deletion calls
notifySignal('exit')thencancelAll(), so no promise is left hanging. Both are required:_doCleanupSessiondetaches the session's listeners BEFOREsession.stop(), so on a delete the PTY exit event never reaches the registry, and anuntil=exitcaller would otherwise get a bareendedinstead of its signal. Found by live-testing the delete path, not by the unit tests.
Memory-leak discipline, per the 24-hour-session rules: every waiter owns a timer that is cleared
on resolve, the per-session waiter set is deleted when it empties, and the output listener is
removed with it. test/memory-leak-prevention.test.ts should grow a case for this.
Caps in a new src/config/agent-wait.ts (limits live in src/config/, env-overridable):
| Constant | Default | Why |
|---|---|---|
MAX_WAIT_MS |
600000 | an unbounded long-poll is a socket leak |
DEFAULT_WAIT_MS |
60000 | short enough to survive most proxies |
MAX_WAITERS_PER_SESSION |
16 | |
MAX_WAITERS_TOTAL |
128 | same reasoning as MAX_SSE_CLIENTS |
Exceeding a cap returns SESSION_BUSY, not a silent queue.
3.5 Transport concerns
Fastify is constructed with defaults in server.ts:329-331. requestTimeout defaults to 0
(disabled) and keepAliveTimeout (72s) applies between requests, not to an in-flight one, so a
10-minute in-process hold is fine. Verify this on the real instance before relying on it.
Intermediaries are the actual risk. Prod is reached through tailscale serve, and users also run
cloudflared tunnels; both can cut an idle connection. That is why DEFAULT_WAIT_MS is 60s and
why the documented pattern is a client-side loop over short waits rather than one 10-minute call.
The skill's recipes must show the loop.
3.6 Edge cases to get right
| Case | Behavior |
|---|---|
Session already idle, fresh=0 |
return immediately, immediate: true |
Session already idle, fresh=1 |
wait for the next transition into a requested state |
| Session dies mid-wait | resolve with signal: "exit" if exit was requested, otherwise resolve timedOut:false, signal:null, ended:true. Never hang |
| Session deleted mid-wait | same, resolve, do not throw. Verified live: until=exit gets signal:"exit", a concurrent until=blocked gets ended:true, both in ~0ms |
| Shutdown with a wait pending | cancelEverything() in stop(). Verified live: SIGTERM with a 300s wait in flight exits in 1s |
| External CLI mode | stop and blocked never fire. Reject until=stop for those modes with a clear INVALID_INPUT rather than hanging until timeout |
| Multi-user | goes through findSessionOrFail(ctx, id, req), which already enforces ownership |
| Remote / Docker cases | signals originate from the same Session object, so no special casing. Docker hooks need CODEMAN_DOCKER_BRIDGE_HOOKS=1 for stop/blocked to arrive at all; without it, only idle works. Document it |
Respawn /clear mid-wait |
a respawn cycle emits idle. Callers waiting on stop are unaffected; callers on idle may resolve early. Documented, not fixed |
| Limit pause | if the session is paused on a usage limit, nothing will fire until the reset. The wait times out honestly. Consider surfacing limitPaused: true in the response so the caller can back off |
3.7 Tests
test/session-wait-registry.test.ts(pure): immediate resolve, transition-required, multi-signal first-wins, timeout, cap exceeded, cancel on session end, no listener leak after resolve, chunk-straddling output match, case-insensitive match.test/routes/session-wait-routes.test.ts(app.inject(), no port): all three endpoints against aMockSession, including the 200-with-timedOutcontract and the ownership 404.test/routes/session-input-wait.test.ts: the send-and-wait race, plus proof that the non-wait path is unchanged (still returns beforewriteViaMuxsettles).- Live verification on a throwaway session before COM, per the always-end-to-end-test rule.
3.8 Files touched
src/config/agent-wait.ts(new)src/web/session-wait-registry.ts(new)src/web/session-listener-wiring.ts(notify on idle/working/exit)src/web/routes/hook-event-routes.ts(notify on stop/blocked)src/web/routes/session-routes.ts(two new routes,waitfields on input)src/web/schemas.ts(SessionWaitQuerySchema,SessionWaitOutputQuerySchema, extendSessionInputWithLimitSchema. Note:.optional()rejectsnull, so the frontend and any generated client must sendundefined, nevernull)docs/api-reference.md,docs/extending-codeman.md, README API tableskills/codeman/SKILL.mdrecipes (Part 1 depends on this)
4. Deferred: parts 3 to 5
Not in scope now, kept here so they are not lost.
Part 3: promote blocked to a first-class state
SessionStatus is 'idle' | 'busy' | 'stopped' | 'error'. "Needs you" exists three times over:
hook events, the tab-alert-action CSS class, and the phone overview NEEDS YOU section, each
re-deriving it. herdr makes blocked a real state that rolls up.
Add blocked (and possibly done) to SessionStatus, set it from the same hook events that
Part 2 uses as wait signals, and clear it on the next working/stop. Then the tab strip, the
mobile overview, the wait endpoints, and any external agent read one field.
Cost: SessionStatus is a widely-consumed union, so every exhaustive switch (the codebase has
assertNever and noFallthroughCasesInSwitch) will need a branch. That is a feature, it makes
the compiler find every site. This is a minor bump, not a patch: it widens a public type in
the HTTP contract.
Part 4: GET /api/schema
herdr ships herdr api schema. Every Codeman route is already Zod-validated, so
zod-to-json-schema over schemas.ts gives a self-describing API almost free. Value: third-party
tools and the skill stop drifting from hand-written docs. Open question: whether to emit full
OpenAPI (@fastify/swagger would need per-route schema registration, which is a much larger
change) or just dump the Zod schemas keyed by name (cheap, 80% of the value).
Part 5: detection manifests instead of hardcoded patterns
CLI-specific readiness, blocked and usage-limit patterns live in code across
usage-limit-patterns.ts, the respawn pattern helpers and regex-patterns.ts. Externalizing the
per-CLI ones into data files would make adding a sixth CLI a data change instead of a code change.
Do not copy the remote-update part. herdr auto-fetches manifest updates from herdr.dev. Codeman auto-pulling behavioral rules from a vendor server contradicts its security posture. Bundled manifests plus local override only, no network.
Explicit non-goals
- Plugin runtime and marketplace.
docs/extending-codeman.mdalready argues this: a plugin runtime means third-party code inside a process that spawns agents with your credentials, on a server people expose over a tunnel. The reasoning still holds. If the marketplace pattern is wanted, apply it to data (web tabs, case templates, cron recipes), never to executable code. - Live PTY handoff on restart. herdr needs it because it owns the terminals. Codeman delegates to tmux, so PTYs already survive a self-update restart.
- Socket API. HTTP plus SSE is the existing, documented, stable contract. A second transport would double the surface for no capability gain.
5. Sequencing
| Step | Work | Gate |
|---|---|---|
| 1 ✅ | src/config/agent-wait.ts + session-wait-registry.ts + unit tests |
48 tests green |
| 2 ✅ | GET .../wait + wiring in listener-wiring, hook-event-routes, server teardown |
15 route tests green; live-verified on an isolated CODEMAN_INSTANCE=waittest instance (immediate resolve, 400 on a bad signal, 200+timedOut on timeout, hook stop and permission_prompt→blocked waking an in-flight wait, delete delivering exit, SIGTERM not blocked); full test:ci sweep green |
| 3 ✅ | GET .../wait-output |
16 route tests green; live-verified on real PTY bytes (echo MARKER waking a blocked request in ~1s, from=buffer immediate hit, never-seen marker timing out at exactly 2001ms, nocase, regex refused with a 400); full test:ci sweep green |
| 4 ✅ | wait field on POST .../input, non-wait path proven unchanged |
16 route tests green; live-verified (no-wait returns in 26ms with the historical bare body; an idle session did NOT satisfy a wait request, blocking the full 2001ms, which is the race the endpoint exists to close; the stop hook resolved a send-and-wait at 1510ms and the input was confirmed in the tmux pane; wait:null accepted) |
| 5 ✅ | skills/codeman/SKILL.md + reference files + .claude/skills symlink |
live dogfood: a real session orchestrates a worker end to end |
| 6 ✅ | codeman skill install CLI + applyAgentSkill() + agentSkillEnabled setting |
10 unit tests (test/agent-skill.test.ts) + real-server case-creation tests (test/quick-start.test.ts, incl. the settings PUT accepting the key) green; CLI verified live (install/uninstall, global + --case, foreign/symlink refusals) |
| 7 ✅ | Docs: api-reference, extending-codeman, README | plus architecture-invariants.md (§agent-wait-primitives), CLAUDE.md and the API reference's per-mode signal table |
| 8 ✅ | COM (minor bump: new endpoints, new setting, new optional fields) | released as 1.13.0 (wait primitives + skill); step 6 followed in 1.14.1 and was republished as 1.14.2 after live-testing the packaged skill |
Parts 1 and 2 are independent enough to land separately, but the skill is much less useful without the wait endpoints, so the wait work goes first.
6. Open questions for the owner
- ✅
skills/at the repo root: accepted (built that way; the install one-liner depends on it). - ✅
agentSkillEnableddefault: OFF for the first release, per §2.2's rationale (skills cost context on every turn; measure before defaulting on). Flip later if dogfooding earns it. - ✅ Both: global install via
npx skills add/codeman skill install, AND per-case auto-injection behind the (default-off) setting. Injection is add-only at session create and marker-guarded, so a user-authored copy is never touched. - Is
X-Codeman-Caller-Sessionself-protection worth the 10 lines, given it is a footgun guard and not a security boundary? (Still open, not built with step 6.) - ✅ Regex support in
wait-output: literal-only shipped, and aregexquery param is rejected with a 400 rather than ignored, so an agent that assumed otherwise cannot silently wait on the wrong thing.
7. Build log: what actually happened
Written at the end of the build so the next person inherits the reasoning, not just the
diff. Process artifacts (per-agent briefs, findings, reports) live in the gitignored
tmp/agent-wait-review/; this section is the part worth keeping.
What shipped
| Piece | Files |
|---|---|
| Bounds + clamping | src/config/agent-wait.ts (new) |
| Blocking-wait registry | src/web/session-wait-registry.ts (new, IO-free, unit-tested) |
GET .../wait, GET .../wait-output, wait/waitTimeout on POST .../input |
src/web/routes/session-routes.ts |
| Signal wiring | session-listener-wiring.ts (idle/working/exit + output), hook-event-routes.ts (stop/blocked), server.ts (teardown, shutdown) |
| Agent skill | skills/codeman/SKILL.md + reference/, .claude/skills/codeman symlink, package.json files |
| Docs | api-reference.md, extending-codeman.md, architecture-invariants.md, README.md, CLAUDE.md |
| Tests | test/session-wait-registry.test.ts, three test/routes/session-*wait*.test.ts, http-contract.test.ts, mock-session.ts |
Bugs found in ADJACENT code, not in the new feature
These are the highest-value output of the exercise and none were on the plan:
- Every Codeman hook was dead on HTTPS installs.
hooks-config.tsbuilt the hook curl ascurl -swith no-kwhile the statusline exporter 300 lines below usedcurl -skand documented why. Proven with the real hook command:curl exit=60without the flag, success with it, and the failure swallowed by the hook's own2>/dev/null || true. This silently killedstop,permission_prompt,elicitation_dialog,idle_prompt,teammate_idleandtask_completed, taking respawn's definitive idle signals with them. Fixed, plus a staleness detector inrefreshStaleCodemanHooksthat regenerates the on-disk config of already-created cases (23 of 26 local cases carried the broken form; fixing the generator alone would have left every one of them broken). buildEnvExports()exported a wrong-schemeCODEMAN_API_URL(http://fallback on an HTTPS install). Now omitted rather than guessed, so in-session guards fail closed.- Programmatic input is only submitted when it contains
\r.sendInputsends Enter only if the payload has a carriage return; without it the text sits in the composer forever. Bit this build repeatedly before it was diagnosed, and had leaked into the docs' own examples.
Design decisions worth not re-litigating
- A timeout is HTTP 200 with
wait.timedOut, never a 4xx: callers loop over short waits because tunnels cut idle connections, and every poll boundary would otherwise be indistinguishable from failure. - Send-and-wait must be one endpoint. A separate POST-then-wait races: between the
write and the flip to
working, a wait sees the staleidleand reports the PREVIOUS turn as this one. The waiter is registered before the write. stop/blockedexist forclaudemode only. They come from Claude Code hooks;shellinstalls none either, so keying offisExternalCliMode()was wrong.- Literal matching only, never regex. JS
RegExpbacktracks; herdr can offer--regexbecause Rust's regex crate is linear-time. - Client-hangup abort listens on
reply.rawguarded bywritableFinished. Onreq.raw,closefires when the request BODY ends, which on a POST killed every send-and-wait instantly, and noapp.inject()test can see it (inject never emitsclose). - Liveness cannot come from
session.pid. For a tmux session that is the localtmux attachclient, not the worker: a worker exiting inside its pane leavespane_dead=1with the client alive, sopidnever goes null. Liveness is probed at the mux layer, cached (~750 ms) and only on blocking waits, never on the input hot path.
Verification rounds
Six agents across three rounds, each verifying the previous round's work rather than its
own. Findings that mattered, in order of severity, were: the dead-pane liveness gap; the
reply.raw abort regression; abandoned long-polls leaking waiter slots; a crashed session
reporting idle; shell accepting until=stop; and a documented recipe that reported
success without running its task. Two traps recurred often enough to name:
- Vacuous passes.
app.inject()never emitsclose; a latchedcancelEverything()inafterEachsilently killed the registry for every later test in a file; three test files sharing one session id against the process-wide registry let one file's leftover waiter fail another's assertion. Any new wait test needs care on all three. - HTTP-only test instances. Every isolated instance used during the build was plain HTTP, which is exactly why the HTTPS hook bug survived so long. Test the transport the user actually runs.
Resolved at wrap-up (2026-08-08, conclusion pass)
- R2-A: the fire-and-forget-then-gather-sequentially pattern was removed from
the skill rather than patched. Signals are edge-triggered with no history, so a
stopthat fires before its waiter registers is unobservable afterwards; afresh=0gather was rejected because the onlyuntilset that current state can satisfy answersidlefor a prompt that never submitted, resurrecting the exact false-success failure R2-B had just closed. Flow 3b's pattern B now gathers on latchedwait-outputmarkers (from=buffer), the same mechanism that makes the shell flows reliable; the limitation is recorded inarchitecture-invariants#agent-wait-primitivesandendpoints.md. The durable fix, a latched last-signal-per-turn on the server, stays with deferred Part 3. - Docs F7/F8, F4 and the false-
idleattribution:api-reference.md,extending-codeman.mdandarchitecture-invariants.mdrewritten to the post-fix matcher (one normalized stream, chunk-straddling found, snippet as a rendering of the matched window), the real no-PTY answer (ended:true,aborted:false,delivered:false), and the startup-idle mechanism (a session parked on the trust dialog emits no furtheridle; the false success is the startup transition). - Orchestrate #12, #5/R2-B, #6, and R2-C..R2-E: fire-and-forget's empty
datadocumented; every send-and-wait retry loop now treatsduplicate:true+immediate:trueas "no new turn ran" and reads the terminal before believing it; claude fan-out is pattern A (backgrounded send-and-waits) or the marker gather; readiness budgets rebalanced (5 s stage 1, 45 s stage 3) with the virgin-case floor named; the auth fallback now also reads the supervisor definition (codeman-web.service/ launchd plist) and acceptsexport-prefixed.envlines;pid != nullis documented as startup-only, never liveness. - Both public readiness recipes (extending-codeman.md, README) are bypass-first with
the trust probe as the bounded fallback; the worked recipe carries
-kand fails loudly on an empty SID; the hook-k/self-heal fix appears in every "hooks go missing" list; the multi-word-TUI claim is "unreliable", not "never".
Still open
Both release-checklist items that used to sit here are done: skills/ is tracked and
ships through package.json files (published with 1.13.0, republished with 1.14.2),
and the changeset was consumed, committed and deployed. What is left:
- Deferred with Part 3: the latched last-signal-per-turn. Nice-to-haves from the
reviews: N2 (create the death-watcher inside its
try, still built one line above it inGET .../wait) and converting timeout-shaped test detections into fast assertions. - §2.4's
X-Codeman-Caller-Sessionfootgun guard: still not built (open question 4).
Step 6 (2026-08-09): install command, per-case injection, the setting
Built to the §2.6 file list, mirroring the statusLine mechanism throughout:
| Piece | Where |
|---|---|
applyAgentSkill(casePath, enabled) + installAgentSkillInto / removeAgentSkillFrom |
src/hooks-config.ts |
codeman skill install / skill uninstall (--global default, --case <name>) |
src/cli.ts |
agentSkillEnabled (SYNCED, default OFF) |
schemas.ts (SettingsUpdateSchema), getAgentSkillEnabled() on ConfigPort/server.ts, checkbox in index.html + settings-ui.js |
| Injection call sites (Claude mode only) | POST /api/sessions next to refreshStaleCodemanHooks; POST /api/quick-start after the case-create/self-heal blocks (local + docker cases; remote skipped, its path lives on another host) |
| Tests | test/agent-skill.test.ts (10 unit), test/quick-start.test.ts (real server: default-off, PUT accepts key, injection on create, shell-mode skipped) |
Decisions worth keeping:
- Ownership marker, prefix-matched. The injected SKILL.md ends with
<!-- codeman-managed-agent-skill: … -->; install/refresh/remove all refuse a copy without the marker (a user's own skill) and match on the PREFIX so a wording change cannot disown older injected copies (theBACKGROUND_WAKE_MARKER_PREFIXpattern). - Symlink refusal. This repo's own dogfooding layout
(
.claude/skills/codeman -> ../../skills/codeman) means the injector mustlstatthe skill dir AND itsskills/parent and bail on a symlink, or enabling the setting in the Codeman repo itself would overwrite the skill source through the link. - ADD-ONLY at session create, same shared-
.clauderationale as the statusLine: a create while the setting is off must not yank the skill out from under other live sessions in the repo. The remove path exists (CLIskill uninstall, tests); no automatic sweep removes on toggle-off. - Removal is manifest-based, never
rm -rf: only files the packaged source would have written are deleted, directories are pruned bottom-up only if they emptied, so a user's extra notes inreference/survive an uninstall. - Source resolution:
join(moduleDir, '..', 'skills', 'codeman')works fromsrc/(tsx),dist/(tsc build), and the npm tarball alike, because all three sit one level below the package root andfilesshipsskills/. - Nothing acts on the setting at PUT time: injection reads the merged persisted
settings at session create (
readSettings, ~2s cache), so the partial-PUT invariant (toggleServicereadingmerged) is untouched by construction.