Merge #784: the codeman skill drives the local model (cloud plus local)

This commit is contained in:
Codeman maintainer committed 2026-11-08 10:41:02 +01:00
commit 9c9245028b
12 files changed
+2000 -87

No files matched your search

+3 -1
View File
@@ -492,6 +492,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Open a tmux session you started by hand**: a tmux session started outside Codeman shows up on the home screen, on this machine automatically and in your Docker cases' containers or on your saved SSH hosts when you scan for them. One click opens it as a tab. Closing the tab only detaches, and Codeman will not respawn or relaunch an agent it did not start. See [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman).
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **GitHub Copilot CLI** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*` vs `COPILOT_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) [`docs/omp-integration.md`](docs/omp-integration.md) and [`docs/copilot-integration.md`](docs/copilot-integration.md)
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **A model on this Mac** _(experimental, new in 1.50.0)_: on a Mac with Apple silicon, **Run a local model** on the welcome screen shows which coding model fits, installs it with its runtime and a coding agent, and starts a session on it: no account, no API key, and the prompts and files of that session go to a model on this Mac, not to a cloud provider. Then mix cloud and local: through the [codeman skill](#the-agent-skill-start-here) a Claude Code session hands work to that model (`spawn_worker <name> local`, `sendmark`) and reads the answer back, so Claude plans and reviews in the cloud while the Mac does the bulk: summaries, first drafts, files that should stay here. See [`docs/local-models.md`](docs/local-models.md)
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host; file previews and downloads come over the same ssh connection. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
@@ -795,7 +796,7 @@ Everything in this section also ships as an **agent skill**, [`skills/codeman`](
| How | Command | Scope |
| -------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Skills CLI | `npx skills add Ark0N/Codeman --skill codeman -g` | Global, for any skills-aware agent ([skills.sh](https://skills.sh)); the way to give it to pi |
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`) |
| Claude Code plugin | `/plugin marketplace add Ark0N/Codeman` then `/plugin install codeman@codeman` | Global, through Claude Code's plugin manager; `/plugin update codeman` follows releases. Pick this OR a `codeman skill install`, not both: a Claude Code with both lists the skill twice (`codeman` and `codeman:codeman`). |
| Bundled CLI | `codeman skill install` | Global (`~/.claude/skills/codeman`), for npm installs that never cloned the repo |
| Bundled CLI | `codeman skill install --case <name> [--mode <mode>]` | One case only; `--mode codex` (or any other run mode) writes it where that CLI looks for skills |
| Web UI | App Settings → Agents & CLIs → All agent CLIs → **Codeman Agent Skill** | Auto-injects into each case on session create, where that session's CLI looks: `.claude/skills/` for Claude Code, `.agents/skills/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; not pi, whose project skills trigger a trust prompt (`agentSkillEnabled`, SYNCED, default off) |
@@ -812,6 +813,7 @@ That is the entire interface. No curl, no endpoint names, no session ids. These
| _"Start a shell worker on the `myapp` case, run the test suite, tell me if it passes."_ | Spawns, waits on a split completion marker, reads back the exit code, cleans up. |
| _"Spin up 3 workers for lint, typecheck and tests. Run them in parallel, report failures."_ | The fan-out flow: one session per task, all started first, then gathered as each finishes. |
| _"Have a claude worker on `refactor-auth` summarize `src/session.ts`, then close it."_ | Spawns, runs the readiness ladder (first-run trust dialog included), send-and-wait, reads the clean transcript answer, deletes. |
| _"Use the local model to summarize every file in `docs/`, then check its summaries."_ | Starts a worker on the model Codeman installed on this Mac, gives it the files, reads its answer back and reviews it in the cloud. Says why instead when no local model is installed. |
| _"Watch session w4 and tell me if it gets stuck on a permission prompt."_ | Blocks on the `blocked` signal and surfaces the question to **you**. It never answers another session's prompt itself. |
One short line is enough. Here a Claude Code lead gets `use codeman to start 5 claude workers, one per TODO item`, and five real Claude Code workers power on, shown side by side in the tile grid:
+16 -2
View File
@@ -1,7 +1,8 @@
# A model that runs on this Mac
Codeman can set up a coding model that runs on the Mac the server runs on: no account, no
API key, nothing leaves the machine. One button on the welcome screen opens a window that
Codeman can set up a coding model that runs on the Mac the server runs on: no account and
no API key, and the session's prompts and files go to a model on this Mac rather than a
cloud provider (the coding agent itself may still check for updates). One button on the welcome screen opens a window that
shows what fits this Mac, installs the choice and ends on a coding agent session that
runs on it.
@@ -169,6 +170,19 @@ A different digest refuses the launch: "The model on this Mac changed since it w
installed. Remove it and install it again." The digest is also read again at boot and when
the runtime is started.
### From a Claude Code session: cloud plus local
The codeman skill ([`skills/codeman`](../skills/codeman/SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac))
lets an agent in a Codeman session hand work to this model, so Claude in the cloud plans and
reviews while the model on this Mac does the bulk: summaries, classification, first drafts, and
files that should not leave the machine. `local_model` reads `GET /api/local-model` and says what
is installed or why nothing can run; `spawn_worker <name> local` starts the runtime when it was
stopped (as Start does), then starts the installed coding agent on `local-ollama` through the same
quick-start launch and the same checks as above; `sendmark` gives the worker a task and reads its
answer back from a file it writes in its own folder. The skill never installs a model and never
answers a confirmation, the runtime still answers one request at a time, and while a worker it
opened is live, Stop and Remove are refused until it is closed.
## Stop, Start, Remove
- **Stop** stops the runtime and keeps it from starting at the next boot. **Start** undoes
+7
View File
@@ -83,6 +83,7 @@ These prompts work as written:
| "Spin up 3 workers for lint, typecheck and tests, run them in parallel, report failures." | One session per task, all started first, then gathered as each finishes. |
| "Have a claude worker summarize `src/session.ts`, then close it." | Spawns, waits until the worker is ready, sends and waits, reads the answer, deletes the session. |
| "Have a DeepSeek Harness worker build search and a claude worker build checkout." | A mixed fleet: one worker per CLI, started in one go. |
| "Use the local model to summarize every file in `docs/`, then check its summaries." | A worker on the model Codeman installed on this Mac does the bulk; the lead reads its answer back and reviews it. Without a local model, it says why. |
| "Watch session w4 and tell me if it gets stuck on a permission prompt." | Blocks on the `blocked` signal and brings the question to **you**. |
Sessions the agent creates are deleted when it is done, so you can watch the tabs appear and
@@ -118,6 +119,12 @@ so the skill can wait for "turn finished" and read the answer as clean text. Eve
and plain shell sessions are synchronized on an output marker instead, which works the same
from your side. See [Agent CLIs](Agent-CLIs) for the run modes.
**The model on this Mac is a worker too.** When Codeman has installed a local model
([A model that runs on this Mac](Custom-Model-Endpoints#a-model-that-runs-on-this-mac)), a
Claude Code lead can hand work to it: Claude in the cloud plans and reviews, the local model
takes summaries, first drafts and files that should stay on the machine. It answers one
request at a time and is slower than a cloud model, and the skill never installs one for you.
With Claude Code workers, where the installed Claude Code supports it, the lead can also
message a worker directly instead of typing into its terminal.
+318 -24
View File
@@ -7,7 +7,8 @@ description: >-
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
work across Codeman sessions, get a second opinion from another CLI or model, hand
work to the local model Codeman installed on this Mac ("use the local model"), watch
another session, start and manage workers, or retrieve saved local Codex history
by file/native ID or root and descendants with topic/date filters. Live API
operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
@@ -106,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -134,8 +135,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.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.50.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
@@ -186,11 +187,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -250,21 +255,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
done
return 1
}
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
# a person's decision, made in Codeman's "Run a local model" window.
local_model() {
local r code body out
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
code=${r##*$'\n'}; body=${r%$'\n'*}
case "$code" in
200) out=$(jq -c '
def say: (.text // "") as $t
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
message:(if $i.error then ($i.error.message | say)
else "The model is installed, but the server offers no session on it right now." end)} + $base
end
elif $d.recommendation.reason == "not-macos" then
{spawnable:false, reason:"not-mac",
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
elif $i.state == "idle" then
{spawnable:false, reason:"not-installed",
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
else
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
+ " Only the Run a local model window continues it.")} + $base
end
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
esac
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
printf '%s\n' "$out"
[ "$(jq -r .spawnable <<<"$out")" = true ]
}
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
# with exactly what the local model window's Start button sends, and read again until it
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
# runs one runtime verb at a time and a second start keeps the runtime that runs.
_local_ready_model() {
local lm r i=0
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
return 1; }
while :; do
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
sleep 2
done
}
# 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 worker whose end-of-turn signal can be trusted -- a claude worker in a
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
# signal unless its harness is claude: drive that one with sendmark.
# 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.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
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 lm cm='' mark=''
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
return 1; }
fi
# 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.
# deepseek: ask for the same permission posture the Run button sends, because the
@@ -272,21 +382,36 @@ spawn_worker() {
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
return 1
fi
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; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -332,10 +457,10 @@ spawn_worker() {
# 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. A bare name is a claude worker;
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
# call. Case 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 (the mode never disambiguates two
# workers, since they would still share the directory).
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
# local model, and a mixed fleet is one call. Case 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
# (the mode never disambiguates two workers, since they would still share the directory).
spawn_workers() {
local d spec n m i=0
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
@@ -369,7 +494,7 @@ spawn_workers() {
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
@@ -434,6 +559,108 @@ sendwait() {
fi
printf '%s\n' "$r"
}
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
# rc 0: the answer file was read, after the marker or once the file held still (below).
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
# (`detail` says why).
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
# full-screen agent can print the marker without it ever matching: opencode redraws its
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
# read and `matched` says false. It is read from this machine's disk, only when it is a
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
# (§5.14).
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
# worker on another host): rc 0 then means only "the marker printed", and the answer is
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
sendmark() {
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
case "$p" in *$'\n'*|*$'\r'*)
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
file="$wd/codeman-answer-$tok.md"
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
else
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
fi
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
return "$rc"
fi
start=$(date +%s); end=$((start + t))
while :; do # bounded waits of at most 10 s each, until the timeout
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
[ "$rem" -le 10 ] || rem=10
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
return 1; }
# No marker this round. The answer file is the other way out (see above): it counts once
# it is a non-empty regular file whose checksum did not change since the last round.
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
else
prev=''
fi
done
if [ "$matched" = true ] && [ -n "$file" ]; then
while :; do
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
[ -f "$file" ] && { got=1; break; }
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
# once then. A wait that did not answer (server gone) still costs its two seconds.
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
i=$((i + 2))
done
fi
if [ "$got" = 1 ]; then
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
return 0
fi
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
if [ "$matched" = true ]; then
[ -z "$file" ] && return 0
[ "$ended" = 1 ] && return 3
return 4
fi
[ "$ended" = 1 ] && return 3
return 2
}
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
@@ -456,10 +683,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.44.0
CODEMAN_PREAMBLE=1.50.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -510,7 +737,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -557,8 +784,8 @@ the time went into deliberation, not the API. The four things that actually cost
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
back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
name that resolved to a hook-less directory with one local grep, so a claude worker it
hands back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
which is dead weight because `wait-output` already blocks on the composer.
Four things this block leans on, each one link away, no detour needed to run it:
@@ -618,6 +845,71 @@ Three things to know before you spawn one:
worker whose pane clearly finished: that profile is one of them, so drive it with
markers instead.
### Claude in the cloud, a local model on this Mac
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
on its welcome screen), you can hand work to that model from here. You stay the planner and
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
other mode: `spawn_workers plan review:deepseek summ:local`.
Reach for it when:
- **The work is bulk, not judgment**: summarizing a folder, classifying, first drafts of
tests, boilerplate. A local turn has no per-token bill.
- **The files should stay on this machine**: the worker reads them here, and only what you
read back from it enters your own context.
- **You want a second opinion from a different model**, with different blind spots.
- **The worker should not depend on the network**: the model's runtime listens on this Mac
only, started with its cloud features off. You, the lead, still need a connection.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
jq -r '.answer // "(no answer)"' <<<"$R"
if [ "$RC" = 0 ]; then delete_session "$SID" >/dev/null
else echo "kept $SID (sendmark rc $RC: 2 no answer yet, 3 ended, 4 no answer file): $R"; fi
```
`sendmark` is the local worker's `sendwait`: it adds one sentence to your prompt asking for
the answer in `codeman-answer-<token>.md` in the worker's folder and then a split marker,
waits for the marker with `wait-output`, and reads the file back. OpenCode can draw the
marker in pieces that never match, so a file that holds still between two checks ends the
wait too ([§5.16](reference/verbs.md#516-a-worker-on-the-local-model)). Read its `answer`,
not the pane: a full-screen agent's pane is a poor transcript. ⚠️ A marker proves that the model
printed a token, not that the work is done or right. Review what comes back the way you
would review any first draft.
The limits, before you promise the user anything:
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
window. This skill never installs one: that is a download of many gigabytes the person
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
instead of guessing.
- **One model, one request at a time.** The runtime loads one model and answers one request
at a time, so two local workers take turns rather than work side by side. Fan out to the
cloud; give the Mac a queue.
- **Slower than a cloud model.** The one measured pair, OpenCode with Qwen3.6 35B-A3B on an
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
loads the model first. A local worker run by claude spends minutes on its first answer
(its fixed prompt is about 36,000 tokens).
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
has hooks and `sendwait` works as for any claude worker.
- **Spawning can change what the person set.** A stopped runtime is started, which also
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
open, the window refuses Stop and Remove and names your workers, so delete them as soon
as they are done.
- **Admin only in multi-user mode, and experimental.** The local model routes answer 403
for anyone else (`admin-only`), and they may change in any release.
## 2. What do you want to do?
One row per job. Acting on this table alone is correct; the §5 links are the detail.
@@ -627,7 +919,8 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`); a `deepseek` worker draws `❯` instead, and its boot `stop` fires ~300 ms BEFORE that, so never read the signal as readiness | [§5.2](reference/verbs.md#52-readiness) |
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy where the signal is real: claude mode with hooks (installed by default, but the operator can disable it and remote sessions never get them) and `deepseek` mode through its status bridge. Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead. For an agent worker, `sendmark <sid> "<prompt>"` does all of it and reads the answer back | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
| hand work to the local model on this Mac (cheap bulk work, files that stay here, a second opinion) | `local_model` says what is installed; `spawn_worker <name> local` (or `name:local` in `spawn_workers`) starts a worker on it; `sendmark` tasks it and returns the answer | [Local model](#claude-in-the-cloud-a-local-model-on-this-mac), [§5.16](reference/verbs.md#516-a-worker-on-the-local-model) |
| read the answer | `GET .../last-response`, **polled** (claude, codex and deepseek write a transcript; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
@@ -743,6 +1036,7 @@ these**; open the one row you actually hit.
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
| [5.16 A worker on the local model](reference/verbs.md#516-a-worker-on-the-local-model) | what `local_model` answers, what `spawn_worker <name> local` does step by step, and every `sendmark` outcome |
## 6. Setup and auth
+242 -16
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
@@ -49,11 +49,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -113,21 +117,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
done
return 1
}
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
# a person's decision, made in Codeman's "Run a local model" window.
local_model() {
local r code body out
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
code=${r##*$'\n'}; body=${r%$'\n'*}
case "$code" in
200) out=$(jq -c '
def say: (.text // "") as $t
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
message:(if $i.error then ($i.error.message | say)
else "The model is installed, but the server offers no session on it right now." end)} + $base
end
elif $d.recommendation.reason == "not-macos" then
{spawnable:false, reason:"not-mac",
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
elif $i.state == "idle" then
{spawnable:false, reason:"not-installed",
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
else
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
+ " Only the Run a local model window continues it.")} + $base
end
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
esac
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
printf '%s\n' "$out"
[ "$(jq -r .spawnable <<<"$out")" = true ]
}
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
# with exactly what the local model window's Start button sends, and read again until it
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
# runs one runtime verb at a time and a second start keeps the runtime that runs.
_local_ready_model() {
local lm r i=0
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
return 1; }
while :; do
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
sleep 2
done
}
# 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 worker whose end-of-turn signal can be trusted -- a claude worker in a
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
# signal unless its harness is claude: drive that one with sendmark.
# 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.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
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 lm cm='' mark=''
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
return 1; }
fi
# 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.
# deepseek: ask for the same permission posture the Run button sends, because the
@@ -135,21 +244,36 @@ spawn_worker() {
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
return 1
fi
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; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -195,10 +319,10 @@ spawn_worker() {
# 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. A bare name is a claude worker;
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
# call. Case 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 (the mode never disambiguates two
# workers, since they would still share the directory).
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
# local model, and a mixed fleet is one call. Case 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
# (the mode never disambiguates two workers, since they would still share the directory).
spawn_workers() {
local d spec n m i=0
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
@@ -232,7 +356,7 @@ spawn_workers() {
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
@@ -297,6 +421,108 @@ sendwait() {
fi
printf '%s\n' "$r"
}
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
# rc 0: the answer file was read, after the marker or once the file held still (below).
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
# (`detail` says why).
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
# full-screen agent can print the marker without it ever matching: opencode redraws its
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
# read and `matched` says false. It is read from this machine's disk, only when it is a
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
# (§5.14).
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
# worker on another host): rc 0 then means only "the marker printed", and the answer is
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
sendmark() {
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
case "$p" in *$'\n'*|*$'\r'*)
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
file="$wd/codeman-answer-$tok.md"
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
else
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
fi
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
return "$rc"
fi
start=$(date +%s); end=$((start + t))
while :; do # bounded waits of at most 10 s each, until the timeout
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
[ "$rem" -le 10 ] || rem=10
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
return 1; }
# No marker this round. The answer file is the other way out (see above): it counts once
# it is a non-empty regular file whose checksum did not change since the last round.
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
else
prev=''
fi
done
if [ "$matched" = true ] && [ -n "$file" ]; then
while :; do
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
[ -f "$file" ] && { got=1; break; }
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
# once then. A wait that did not answer (server gone) still costs its two seconds.
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
i=$((i + 2))
done
fi
if [ "$got" = 1 ]; then
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
return 0
fi
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
if [ "$matched" = true ]; then
[ -z "$file" ] && return 0
[ "$ended" = 1 ] && return 3
return 4
fi
[ "$ended" = 1 ] && return 3
return 2
}
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
@@ -319,4 +545,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.44.0
CODEMAN_PREAMBLE=1.50.0
@@ -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.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { 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
@@ -484,7 +484,10 @@ snippet carries the exit code back to you.
For a **claude** worker with no hooks, ask for the marker in halves in the prompt
itself ("print the word WORKDONE immediately followed by `_<token>`") for the same
reason, and match the joined token. ⚠️ Against a TUI, match a single space-free token:
reason, and match the joined token. For any **agent** worker without `stop` (a hook-less
claude, a worker on the local model, every external agent mode but deepseek), the
preamble's `sendmark <sid> "<prompt>"` is exactly that in one call, and also asks for the
answer in a file it reads back: [§5.16](#516-a-worker-on-the-local-model). ⚠️ Against a TUI, match a single space-free token:
a full-screen TUI positions text with cursor movements rather than literal spaces, so
the stripped stream can read `Yes,Itrustthisfolder`, and whether a phrase keeps its
spaces depends on how the TUI happened to draw it (observed live: some match, some
@@ -814,6 +817,103 @@ Rules that matter:
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
### 5.16 A worker on the local model
The detail behind [Claude in the cloud, a local model on this
Mac](../SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac). Three preamble functions,
all read from source and pinned by `test/agent-skill-local-model.test.ts` against a fake
server; the local model routes themselves are experimental.
**`local_model`** is one `GET /api/v1/local-model` (the `/api/v1` alias reaches the
experimental route like any other) and prints one JSON line. Exit 0 means a `local` worker
can be spawned now (`running: true`) or once `spawn_worker` has started the runtime
(`running: false`):
```json
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
```
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
| `reason` | What it means | What to tell the user |
|----------|---------------|-----------------------|
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
| `unsupported` | the server has no local model routes (404) | it predates the feature |
| `unauthorized`, `unreachable`, `error` | the request itself failed | §6 for a 401 |
It never downloads, installs or starts anything. Installing is many gigabytes the person
confirms in the window, and no route this skill calls can do it.
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), in order:
1. `local_model`; anything but exit 0 fails the spawn with that reason.
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
window's Start button sends, then `local_model` again until it reports `running`
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
session", with the installed agent as the mode. No advisor, which is a cloud model.
The server checks again that its own runtime runs and that the model on disk is the one
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
person (a context below the agent's safe floor, a model swap that would evict another
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
and no `confirmed*` flag is ever sent.
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its
composer draws, or the spawn is refused before a session exists.
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
- It joins the prompt onto one line and adds one sentence: write the complete answer to
`codeman-answer-<token>.md` in the working directory, and only then end the reply with
the word DONE immediately followed by `_<token>`. The typed text never holds the joined
marker, so its echo cannot match.
- It sends once with the exactly-once body (`$CID-<sid>`, an epoch-second `seq`, one `\r`)
and a one-second `exit` wait, so `delivered` is the pane's answer, not a guess.
- It waits on `wait-output?match=DONE_<token>&from=buffer` in rounds of at most 10 s until
the timeout (default 600 s), then reads the answer file from this machine's disk: only a
regular file (a symlink is refused), at most 256 KB.
- ⚠️ A full-screen agent can print the marker without it ever matching. OpenCode redraws
its spinner between the pieces a model streams, so with a reply streamed a character or
two at a time the stripped stream read `D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…` while the screen showed the whole
token (measured on OpenCode 1.3.0 on Linux). So between rounds it also looks at the answer file:
once it is non-empty and its checksum held still from one round to the next, it is read
and the wait ends with `matched: false`.
| exit | JSON | Meaning |
|------|------|---------|
| 0 | `answer` holds the file | the file was read: after the marker (`matched: true`), or once it held still (`matched: false`) |
| 2 | `matched: false` | neither the marker nor a settled file before the timeout. The worker may still be working: re-check the pane, never resend blindly |
| 3 | `ended: true` or `delivered: false` | the worker's pane ended. A pane that ended after it wrote the file but before the marker matched leaves `codeman-answer-<token>.md` in its folder: look there yourself |
| 4 | `matched: true, answer: null` | the marker printed, but no file within `SENDMARK_FILE_WAIT` s (default 120): the model ignored the instruction, or a reasoning model restated the marker while planning and is still at work |
| 1 | `detail` | the send or a wait failed |
What a marker proves, and what it does not: the model printed the token. Not that the
work is done, not that it is right, and with a reasoning model not even that the turn is
over, which is why the file is the second gate. What a small local model writes is a
first draft; review it before you build on it. The answer files stay in the worker's
folder, a scratch case that [§5.14](#514-clean-up) lists for cleanup.
`SENDMARK_ANSWER_FILE=0` asks for the marker alone, for a worker that cannot write where
it runs (codex in a folder it runs read-only, an agent that asks before it writes a file,
a worker on another host): exit 0 then means only that the marker printed, and with no
file to fall back on, a full-screen agent can answer exit 2 for a reply that finished. A
worker that stops on such a question shows up here as exit 2, not as an error.
Close every local worker you opened: while one is open the window refuses Stop and
Remove, naming it.
## Experimental bounded inspection and guarded submission
These three commands and their HTTP routes are labeled experimental, so they may
+318 -24
View File
@@ -7,7 +7,8 @@ description: >-
CLI, send them prompts, block until they finish (wait / wait-output / send-and-wait),
read their output, and clean up; where available, message claude workers directly
(Claude Code cross-session messaging). Use when asked to orchestrate or parallelize
work across Codeman sessions, get a second opinion from another CLI or model, watch
work across Codeman sessions, get a second opinion from another CLI or model, hand
work to the local model Codeman installed on this Mac ("use the local model"), watch
another session, start and manage workers, or retrieve saved local Codex history
by file/native ID or root and descendants with topic/date filters. Live API
operations require a Codeman-managed session (CODEMAN_MUX=1). Offline history
@@ -106,7 +107,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -134,8 +135,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.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.50.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
@@ -186,11 +187,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -250,21 +255,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
done
return 1
}
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
# a person's decision, made in Codeman's "Run a local model" window.
local_model() {
local r code body out
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
code=${r##*$'\n'}; body=${r%$'\n'*}
case "$code" in
200) out=$(jq -c '
def say: (.text // "") as $t
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
message:(if $i.error then ($i.error.message | say)
else "The model is installed, but the server offers no session on it right now." end)} + $base
end
elif $d.recommendation.reason == "not-macos" then
{spawnable:false, reason:"not-mac",
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
elif $i.state == "idle" then
{spawnable:false, reason:"not-installed",
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
else
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
+ " Only the Run a local model window continues it.")} + $base
end
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
esac
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
printf '%s\n' "$out"
[ "$(jq -r .spawnable <<<"$out")" = true ]
}
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
# with exactly what the local model window's Start button sends, and read again until it
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
# runs one runtime verb at a time and a second start keeps the runtime that runs.
_local_ready_model() {
local lm r i=0
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
return 1; }
while :; do
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
sleep 2
done
}
# 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 worker whose end-of-turn signal can be trusted -- a claude worker in a
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
# signal unless its harness is claude: drive that one with sendmark.
# 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.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
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 lm cm='' mark=''
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
return 1; }
fi
# 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.
# deepseek: ask for the same permission posture the Run button sends, because the
@@ -272,21 +382,36 @@ spawn_worker() {
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
return 1
fi
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; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -332,10 +457,10 @@ spawn_worker() {
# 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. A bare name is a claude worker;
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
# call. Case 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 (the mode never disambiguates two
# workers, since they would still share the directory).
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
# local model, and a mixed fleet is one call. Case 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
# (the mode never disambiguates two workers, since they would still share the directory).
spawn_workers() {
local d spec n m i=0
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
@@ -369,7 +494,7 @@ spawn_workers() {
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
@@ -434,6 +559,108 @@ sendwait() {
fi
printf '%s\n' "$r"
}
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
# rc 0: the answer file was read, after the marker or once the file held still (below).
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
# (`detail` says why).
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
# full-screen agent can print the marker without it ever matching: opencode redraws its
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
# read and `matched` says false. It is read from this machine's disk, only when it is a
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
# (§5.14).
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
# worker on another host): rc 0 then means only "the marker printed", and the answer is
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
sendmark() {
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
case "$p" in *$'\n'*|*$'\r'*)
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
file="$wd/codeman-answer-$tok.md"
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
else
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
fi
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
return "$rc"
fi
start=$(date +%s); end=$((start + t))
while :; do # bounded waits of at most 10 s each, until the timeout
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
[ "$rem" -le 10 ] || rem=10
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
return 1; }
# No marker this round. The answer file is the other way out (see above): it counts once
# it is a non-empty regular file whose checksum did not change since the last round.
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
else
prev=''
fi
done
if [ "$matched" = true ] && [ -n "$file" ]; then
while :; do
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
[ -f "$file" ] && { got=1; break; }
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
# once then. A wait that did not answer (server gone) still costs its two seconds.
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
i=$((i + 2))
done
fi
if [ "$got" = 1 ]; then
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
return 0
fi
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
if [ "$matched" = true ]; then
[ -z "$file" ] && return 0
[ "$ended" = 1 ] && return 3
return 4
fi
[ "$ended" = 1 ] && return 3
return 2
}
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
@@ -456,10 +683,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.44.0
CODEMAN_PREAMBLE=1.50.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -510,7 +737,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -557,8 +784,8 @@ the time went into deliberation, not the API. The four things that actually cost
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
back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
name that resolved to a hook-less directory with one local grep, so a claude worker it
hands back always has a working `stop` and `sendwait` is trustworthy), and the pid poll,
which is dead weight because `wait-output` already blocks on the composer.
Four things this block leans on, each one link away, no detour needed to run it:
@@ -618,6 +845,71 @@ Three things to know before you spawn one:
worker whose pane clearly finished: that profile is one of them, so drive it with
markers instead.
### Claude in the cloud, a local model on this Mac
When Codeman has installed a model on the Mac it runs on (the **Run a local model** button
on its welcome screen), you can hand work to that model from here. You stay the planner and
the reviewer in the cloud; the Mac does the bulk. `spawn_worker <name> local` starts a worker
on the installed model, run by the coding agent it was installed with (OpenCode on the Mac
it was tested on), and from then on it is the same verbs. It mixes into one fleet like any
other mode: `spawn_workers plan review:deepseek summ:local`.
Reach for it when:
- **The work is bulk, not judgment**: summarizing a folder, classifying, first drafts of
tests, boilerplate. A local turn has no per-token bill.
- **The files should stay on this machine**: the worker reads them here, and only what you
read back from it enters your own context.
- **You want a second opinion from a different model**, with different blind spots.
- **The worker should not depend on the network**: the model's runtime listens on this Mac
only, started with its cloud features off. You, the lead, still need a connection.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agent, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # starts a stopped runtime first; empty = failed
WD=$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq -r .data.workingDir)
mkdir -p "$WD/input" && cp ./docs/*.md "$WD/input/" # inside its own folder (§5.9), apart from its CLAUDE.md
R=$(sendmark "$SID" 'Summarize each .md file in the folder input, two lines each' 900); RC=$?
jq -r '.answer // "(no answer)"' <<<"$R"
if [ "$RC" = 0 ]; then delete_session "$SID" >/dev/null
else echo "kept $SID (sendmark rc $RC: 2 no answer yet, 3 ended, 4 no answer file): $R"; fi
```
`sendmark` is the local worker's `sendwait`: it adds one sentence to your prompt asking for
the answer in `codeman-answer-<token>.md` in the worker's folder and then a split marker,
waits for the marker with `wait-output`, and reads the file back. OpenCode can draw the
marker in pieces that never match, so a file that holds still between two checks ends the
wait too ([§5.16](reference/verbs.md#516-a-worker-on-the-local-model)). Read its `answer`,
not the pane: a full-screen agent's pane is a poor transcript. ⚠️ A marker proves that the model
printed a token, not that the work is done or right. Review what comes back the way you
would review any first draft.
The limits, before you promise the user anything:
- **A Mac with Apple silicon, and a model a person installed** in Codeman's local model
window. This skill never installs one: that is a download of many gigabytes the person
confirms there. `local_model` says what is missing (`not-mac`, `not-installed`,
`install-unfinished`, `blocked`, `admin-only`, `unsupported`), so tell the user that
instead of guessing.
- **One model, one request at a time.** The runtime loads one model and answers one request
at a time, so two local workers take turns rather than work side by side. Fan out to the
cloud; give the Mac a queue.
- **Slower than a cloud model.** The one measured pair, OpenCode with Qwen3.6 35B-A3B on an
M5 Pro with 48 GB, ran at about 68 tokens a second, and the first request after a start
loads the model first. A local worker run by claude spends minutes on its first answer
(its fixed prompt is about 36,000 tokens).
- **Markers, not hooks.** OpenCode reports no end of turn, so `sendwait` answers 400 for
that worker; `sendmark` is the way through. If the installed agent is claude, the worker
has hooks and `sendwait` works as for any claude worker.
- **Spawning can change what the person set.** A stopped runtime is started, which also
undoes the person's Stop: it then starts with Codeman again. And while a local worker is
open, the window refuses Stop and Remove and names your workers, so delete them as soon
as they are done.
- **Admin only in multi-user mode, and experimental.** The local model routes answer 403
for anyone else (`admin-only`), and they may change in any release.
## 2. What do you want to do?
One row per job. Acting on this table alone is correct; the §5 links are the detail.
@@ -627,7 +919,8 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`); a `deepseek` worker draws `❯` instead, and its boot `stop` fires ~300 ms BEFORE that, so never read the signal as readiness | [§5.2](reference/verbs.md#52-readiness) |
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy where the signal is real: claude mode with hooks (installed by default, but the operator can disable it and remote sessions never get them) and `deepseek` mode through its status bridge. Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead. For an agent worker, `sendmark <sid> "<prompt>"` does all of it and reads the answer back | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
| hand work to the local model on this Mac (cheap bulk work, files that stay here, a second opinion) | `local_model` says what is installed; `spawn_worker <name> local` (or `name:local` in `spawn_workers`) starts a worker on it; `sendmark` tasks it and returns the answer | [Local model](#claude-in-the-cloud-a-local-model-on-this-mac), [§5.16](reference/verbs.md#516-a-worker-on-the-local-model) |
| read the answer | `GET .../last-response`, **polled** (claude, codex and deepseek write a transcript; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
@@ -743,6 +1036,7 @@ these**; open the one row you actually hit.
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
| [5.15 Agent inbox](reference/verbs.md#515-agent-inbox) | messages that wait for the receiver instead of being typed: post, read until ack, who counts as the receiver |
| [5.16 A worker on the local model](reference/verbs.md#516-a-worker-on-the-local-model) | what `local_model` answers, what `spawn_worker <name> local` does step by step, and every `sendmark` outcome |
## 6. Setup and auth
+242 -16
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code
# replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted
# from that body (test/agent-skill.test.ts pins it).
@@ -49,11 +49,15 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
}
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${2}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${3}" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=${2}" | jq -r '.data.wait.matched // false'
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
@@ -113,21 +117,126 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
done
return 1
}
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
# a person's decision, made in Codeman's "Run a local model" window.
local_model() {
local r code body out
r=$("${CURL[@]}" -w '\n%{http_code}' "$API/api/v1/local-model")
code=${r##*$'\n'}; body=${r%$'\n'*}
case "$code" in
200) out=$(jq -c '
def say: (.text // "") as $t
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
message:(if $i.error then ($i.error.message | say)
else "The model is installed, but the server offers no session on it right now." end)} + $base
end
elif $d.recommendation.reason == "not-macos" then
{spawnable:false, reason:"not-mac",
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
elif $i.state == "idle" then
{spawnable:false, reason:"not-installed",
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
else
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
+ " Only the Run a local model window continues it.")} + $base
end
| with_entries(select(.value != null))' <<<"$body" 2>/dev/null) ;;
000) out=$(jq -nc --arg a "$API" '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}') ;;
401) out='{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
403) jq -e '.errorCode == "FORBIDDEN"' <<<"$body" >/dev/null 2>&1 &&
out='{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
404) out='{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
esac
[ -n "${out:-}" ] || out=$(jq -nc --arg c "$code" --arg t "$body" \
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}')
printf '%s\n' "$out"
[ "$(jq -r .spawnable <<<"$out")" = true ]
}
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
# with exactly what the local model window's Start button sends, and read again until it
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
# runs one runtime verb at a time and a second start keeps the runtime that runs.
_local_ready_model() {
local lm r i=0
lm=$(local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
r=$("${CURL[@]}" -X POST "$API/api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}')
jq -e '.success == true' <<<"$r" >/dev/null 2>&1 || {
echo "could not start the local model runtime: $(jq -r '.error // empty' <<<"$r" 2>/dev/null || printf '%s' "$r" | head -c 300)" >&2
return 1; }
while :; do
lm=$(local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<<"$lm" >&2; return 1; }
[ "$(jq -r .running <<<"$lm")" = true ] && { printf '%s\n' "$lm"; return 0; }
i=$((i+1)); [ "$i" -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >&2; return 1; }
sleep 2
done
}
# 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 worker whose end-of-turn signal can be trusted -- a claude worker in a
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
# signal unless its harness is claude: drive that one with sendmark.
# 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.
# mode `local` is the model Codeman installed on this Mac (local_model), run by the coding
# agent it was installed with. The worker's mode becomes that agent, and the endpoint and
# model ride the quick-start as `customModel`: the launch the window's "Start a session"
# makes. A stopped runtime is started first. Any answer that wants a person to confirm
# something (a context below the agent's safe floor, a model swap) fails the spawn: this
# never confirms on anyone's behalf.
# CODEMAN_WORKER_ADVISOR=opus (or fable / sonnet) gives every CLAUDE worker spawned while
# it is set Claude Code's advisor tool: a stronger model the worker consults before
# committing to an approach, on a recurring error and before declaring the task done.
# Other modes ignore it. A value the server refuses fails the spawn (INVALID_INPUT); an
# advisor that ranks below the worker's model is accepted but never attached by claude.
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 lm cm='' mark=''
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
cm=$(jq -c '{endpointId, modelId}' <<<"$lm")
# Readiness is a token the agent's composer draws. The registry's composer data never
# reaches the API, so it is named per agent here. opencode: `┃`, its `workDetect.promptGlyph`.
# That field is no readiness mark in general (the registry keeps `composerReadyMark` for
# that, and declares none for opencode), but at boot only the composer draws it: measured
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
# and a prompt pasted with its Enter at that moment was submitted. claude takes the claude
# path below. Any other agent needs LOCAL_READY_MARK rather than a worker handed back on a guess.
[ "$mode" = opencode ] && mark='┃'
mark=${LOCAL_READY_MARK:-$mark}
[ "$mode" = claude ] || [ -n "$mark" ] || {
echo "the local model runs through $mode, and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >&2
return 1; }
fi
# 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.
# deepseek: ask for the same permission posture the Run button sends, because the
@@ -135,21 +244,36 @@ spawn_worker() {
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
# Spawn by hand (§5.1) when you want a worker that asks. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
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" --arg a "${CODEMAN_WORKER_ADVISOR:-}" \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" --arg a "${CODEMAN_WORKER_ADVISOR:-}" --arg cm "$cm" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
+ (if $m == "claude" and $a != "" then {advisorModel:$a} else {} end)')")
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)')")
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<<"$q" >/dev/null 2>&1; then
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<<"$q" >&2
return 1
fi
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; }
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
if [ "$mode" = claude ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
if [ "$mode" = claude ] && [ -z "$cm" ] && [ -n "${CODEMAN_WORKER_ADVISOR:-}" ] &&
[ "$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.advisorModel // empty')" != "$CODEMAN_WORKER_ADVISOR" ]; then
echo "worker $sid: this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >&2
fi
if [ -n "$cm" ] && [ "$mode" != claude ]; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
r=$(_mark_up "$sid" "$mark" 45000)
[ "$r" = true ] || { echo "local worker $sid ($mode) never drew '$mark' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >&2
delete_session "$sid" >/dev/null; return 1; }
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
@@ -195,10 +319,10 @@ spawn_worker() {
# 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. A bare name is a claude worker;
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
# call. Case 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 (the mode never disambiguates two
# workers, since they would still share the directory).
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
# local model, and a mixed fleet is one call. Case 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
# (the mode never disambiguates two workers, since they would still share the directory).
spawn_workers() {
local d spec n m i=0
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
@@ -232,7 +356,7 @@ spawn_workers() {
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
@@ -297,6 +421,108 @@ sendwait() {
fi
printf '%s\n' "$r"
}
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
# rc 0: the answer file was read, after the marker or once the file held still (below).
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
# (`detail` says why).
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
# full-screen agent can print the marker without it ever matching: opencode redraws its
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
# read and `matched` says false. It is read from this machine's disk, only when it is a
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
# (§5.14).
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
# worker on another host): rc 0 then means only "the marker printed", and the answer is
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
sendmark() {
local sid="${1:?}" p="${2:?}" t="${3:-600}" tok mark file='' wd r w='' rem end start got=0 ended=0 matched=false i=0 rc=2 fp prev=''
case "$p" in *$'\n'*|*$'\r'*)
p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n "$p" ] || { echo "sendmark: empty prompt" >&2; return 1; }
tok=$(printf 'm%x%04x' "$(date +%s)" "$RANDOM"); mark="DONE_$tok"
if [ "${SENDMARK_ANSWER_FILE:-1}" != 0 ]; then
wd=$("${CURL[@]}" "$API/api/v1/sessions/$sid" | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n "$wd" ] || { echo "sendmark: could not read the working directory of worker $sid" >&2; return 1; }
file="$wd/codeman-answer-$tok.md"
p="$p (When you are done: write your complete answer to the file codeman-answer-$tok.md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
else
p="$p (When you are done, end your reply with the word DONE immediately followed by _$tok with no space in between.)"
fi
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
--data-binary "$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}')")
if ! jq -e '.success and .data.delivered' <<<"$r" >/dev/null 2>&1; then
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<<"$r" >/dev/null 2>&1 && rc=3 || rc=1
jq -nc --arg t "$tok" --arg r "$r" '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
return "$rc"
fi
start=$(date +%s); end=$((start + t))
while :; do # bounded waits of at most 10 s each, until the timeout
rem=$((end - $(date +%s))); [ "$rem" -ge 1 ] || break
[ "$rem" -le 10 ] || rem=10
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" --data-urlencode "match=$mark" \
--data-urlencode 'from=buffer' --data-urlencode "timeout=$((rem * 1000))")
jq -e '.data.wait.matched' <<<"$w" >/dev/null 2>&1 && { matched=true; break; }
jq -e '.data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || {
jq -nc --arg t "$tok" --arg w "$w" '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
return 1; }
# No marker this round. The answer file is the other way out (see above): it counts once
# it is a non-empty regular file whose checksum did not change since the last round.
if [ -n "$file" ] && [ ! -L "$file" ] && [ -f "$file" ] && [ -s "$file" ]; then
fp=$(cksum < "$file"); [ "$fp" = "$prev" ] && { got=1; break; }; prev=$fp
else
prev=''
fi
done
if [ "$matched" = true ] && [ -n "$file" ]; then
while :; do
[ -L "$file" ] && { echo "sendmark: $file is a symlink, not read" >&2; break; }
[ -f "$file" ] && { got=1; break; }
[ "$i" -lt "${SENDMARK_FILE_WAIT:-120}" ] || break
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
# once then. A wait that did not answer (server gone) still costs its two seconds.
w=$("${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000')
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<<"$w" >/dev/null 2>&1 && { ended=1; break; }
jq -e '.success' <<<"$w" >/dev/null 2>&1 || sleep 2
i=$((i + 2))
done
fi
if [ "$got" = 1 ]; then
head -c 262144 "$file" | jq -Rsc --arg t "$tok" --arg f "$file" --argjson m "$matched" \
--argjson s "$(($(date +%s) - start))" --argjson c "$(($(wc -c < "$file")))" \
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
return 0
fi
jq -nc --arg t "$tok" --arg f "$file" --argjson m "$matched" --argjson e "$ended" --argjson s "$(($(date +%s) - start))" \
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
if [ "$matched" = true ]; then
[ -z "$file" ] && return 0
[ "$ended" = 1 ] && return 3
return 4
fi
[ "$ended" = 1 ] && return 3
return 2
}
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
@@ -319,4 +545,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.44.0
CODEMAN_PREAMBLE=1.50.0
+1 -1
View File
@@ -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.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { 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
+101 -1
View File
@@ -484,7 +484,10 @@ snippet carries the exit code back to you.
For a **claude** worker with no hooks, ask for the marker in halves in the prompt
itself ("print the word WORKDONE immediately followed by `_<token>`") for the same
reason, and match the joined token. ⚠️ Against a TUI, match a single space-free token:
reason, and match the joined token. For any **agent** worker without `stop` (a hook-less
claude, a worker on the local model, every external agent mode but deepseek), the
preamble's `sendmark <sid> "<prompt>"` is exactly that in one call, and also asks for the
answer in a file it reads back: [§5.16](#516-a-worker-on-the-local-model). ⚠️ Against a TUI, match a single space-free token:
a full-screen TUI positions text with cursor movements rather than literal spaces, so
the stripped stream can read `Yes,Itrustthisfolder`, and whether a phrase keeps its
spaces depends on how the TUI happened to draw it (observed live: some match, some
@@ -814,6 +817,103 @@ Rules that matter:
- **Lifetime.** An inbox goes with its session on delete. A server restart empties all
inboxes unless the user turned on *Keep agent inbox across restarts* in Settings.
### 5.16 A worker on the local model
The detail behind [Claude in the cloud, a local model on this
Mac](../SKILL.md#claude-in-the-cloud-a-local-model-on-this-mac). Three preamble functions,
all read from source and pinned by `test/agent-skill-local-model.test.ts` against a fake
server; the local model routes themselves are experimental.
**`local_model`** is one `GET /api/v1/local-model` (the `/api/v1` alias reaches the
experimental route like any other) and prints one JSON line. Exit 0 means a `local` worker
can be spawned now (`running: true`) or once `spawn_worker` has started the runtime
(`running: false`):
```json
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
```
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
| `reason` | What it means | What to tell the user |
|----------|---------------|-----------------------|
| `not-mac` | the server is not on macOS | local models need a Mac with Apple silicon |
| `not-installed` | nothing installed; on a Mac where no model fits, `message` says why | install one with **Run a local model** on the welcome screen |
| `install-unfinished` | a job is installing, paused or failed (`state`, `step`, `errorCode`) | finish it in that window; nothing here continues it |
| `blocked` | installed, but the server refuses a session now (`errorCode`: `model-changed`, `port-in-use`, `runtime-start-failed`, `runtime-stopped`) | the window shows the one button that helps |
| `admin-only` | multi-user mode, and you are not an admin (403) | ask an admin |
| `unsupported` | the server has no local model routes (404) | it predates the feature |
| `unauthorized`, `unreachable`, `error` | the request itself failed | §6 for a 401 |
It never downloads, installs or starts anything. Installing is many gigabytes the person
confirms in the window, and no route this skill calls can do it.
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), in order:
1. `local_model`; anything but exit 0 fails the spawn with that reason.
2. Runtime stopped: `POST /api/v1/local-model/runtime {"action":"start"}`, what the
window's Start button sends, then `local_model` again until it reports `running`
(about 30 s at most). This undoes a Stop the person made. Concurrent spawns may both
send it; the server runs one runtime verb at a time and keeps a runtime that runs.
3. `POST /api/v1/quick-start {"caseName":…,"mode":"<cli>","parentSessionId":…,
"customModel":{"endpointId":…,"modelId":…}}`: the launch of the window's "Start a
session", with the installed agent as the mode. No advisor, which is a cloud model.
The server checks again that its own runtime runs and that the model on disk is the one
it installed, and refuses otherwise (`OPERATION_FAILED`, with the reason).
4. An answer of `requiresContextWarning` or `requiresConfirmation` is a question for a
person (a context below the agent's safe floor, a model swap that would evict another
session's model). It fails the spawn with the answer on stderr and nothing on stdout,
and no `confirmed*` flag is ever sent.
5. Readiness: OpenCode's composer bar `┃` (its registry `promptGlyph`) through
`wait-output`, 45 s, and the session deleted on a miss. Measured on OpenCode 1.3.0 on
Linux (not yet on a Mac) with a fresh home and a custom provider: nothing draws `┃` before the composer, there was no
first-run screen, and a prompt pasted with its Enter at the first `┃` was submitted. A
claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). Any other agent needs `LOCAL_READY_MARK` set to a space-free token its
composer draws, or the spawn is refused before a session exists.
**`sendmark <sid> <prompt> [timeoutSec]`** drives it, and any other agent worker without
an end-of-turn signal ([§5.5](#55-markers-for-hook-less-workers)):
- It joins the prompt onto one line and adds one sentence: write the complete answer to
`codeman-answer-<token>.md` in the working directory, and only then end the reply with
the word DONE immediately followed by `_<token>`. The typed text never holds the joined
marker, so its echo cannot match.
- It sends once with the exactly-once body (`$CID-<sid>`, an epoch-second `seq`, one `\r`)
and a one-second `exit` wait, so `delivered` is the pane's answer, not a guess.
- It waits on `wait-output?match=DONE_<token>&from=buffer` in rounds of at most 10 s until
the timeout (default 600 s), then reads the answer file from this machine's disk: only a
regular file (a symlink is refused), at most 256 KB.
- ⚠️ A full-screen agent can print the marker without it ever matching. OpenCode redraws
its spinner between the pieces a model streams, so with a reply streamed a character or
two at a time the stripped stream read `D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…` while the screen showed the whole
token (measured on OpenCode 1.3.0 on Linux). So between rounds it also looks at the answer file:
once it is non-empty and its checksum held still from one round to the next, it is read
and the wait ends with `matched: false`.
| exit | JSON | Meaning |
|------|------|---------|
| 0 | `answer` holds the file | the file was read: after the marker (`matched: true`), or once it held still (`matched: false`) |
| 2 | `matched: false` | neither the marker nor a settled file before the timeout. The worker may still be working: re-check the pane, never resend blindly |
| 3 | `ended: true` or `delivered: false` | the worker's pane ended. A pane that ended after it wrote the file but before the marker matched leaves `codeman-answer-<token>.md` in its folder: look there yourself |
| 4 | `matched: true, answer: null` | the marker printed, but no file within `SENDMARK_FILE_WAIT` s (default 120): the model ignored the instruction, or a reasoning model restated the marker while planning and is still at work |
| 1 | `detail` | the send or a wait failed |
What a marker proves, and what it does not: the model printed the token. Not that the
work is done, not that it is right, and with a reasoning model not even that the turn is
over, which is why the file is the second gate. What a small local model writes is a
first draft; review it before you build on it. The answer files stay in the worker's
folder, a scratch case that [§5.14](#514-clean-up) lists for cleanup.
`SENDMARK_ANSWER_FILE=0` asks for the marker alone, for a worker that cannot write where
it runs (codex in a folder it runs read-only, an agent that asks before it writes a file,
a worker on another host): exit 0 then means only that the marker printed, and with no
file to fall back on, a full-screen agent can answer exit 2 for a reply that finished. A
worker that stops on such a question shows up here as exit 2, not as an error.
Close every local worker you opened: while one is open the window refuses Stop and
Remove, naming it.
## Experimental bounded inspection and guarded submission
These three commands and their HTTP routes are labeled experimental, so they may
+650
View File
@@ -0,0 +1,650 @@
/**
* @fileoverview The codeman skill's local model helpers: `local_model`, the `local` worker kind
* of `spawn_worker`, and `sendmark`.
*
* Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub that plays a
* fake Codeman: it records every request (method, route, query, body) in order and answers from
* files the test wrote, one per route and optionally one per call of that route. So these check
* what the functions put on the wire and what they make of each answer, never a copy of their
* logic, and nothing here talks to a real server.
*
* Port: N/A (no server; the stub answers every request).
*/
import { afterEach, describe, expect, it } from 'vitest';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh');
const API = 'http://127.0.0.1:9';
const SELF = 'caller-session-0000';
const SID = 'worker-local-0001';
// The preamble builds every request body with jq.
let hasJq = true;
try {
execFileSync('jq', ['--version'], { stdio: 'ignore' });
} catch {
hasJq = false;
}
/**
* The fake Codeman. Each call is appended to $CALLS as one JSON line, then answered from
* $ANSWERS/<route>.<n> (the n-th call of that route) or $ANSWERS/<route>. `<file>.code` is the
* HTTP status `-w` reports (default 200) and `<file>.delay` seconds to sleep first. A wait-output
* for `DONE_<token>` writes $WD/codeman-answer-<token>.md first when $ANSWER_TEXT is set, or a
* symlink to $ANSWER_SYMLINK, the way a worker would before printing its marker; with
* $ANSWER_APPEND it appends that line on every such call, a worker still writing its answer.
*
* Written without a single dollar-brace, because this is a JS template literal.
*/
const STUB = String.raw`
stub() {
local m=GET u='' d='' q='' w='' route key n f tok
while [ "$#" -gt 0 ]; do
case "$1" in
-X) m=$2; shift ;;
-d|--data-binary) d=$2; shift ;;
--data-urlencode) q="$q&$2"; shift ;;
-w) w=$2; shift ;;
-H|-u|-o) shift ;;
http*) u=$1 ;;
esac
shift
done
route=$(printf '%s' "$u" | sed "s|^$CODEMAN_API_URL/api/v1/||")
case "$route" in
local-model) key=lm-get ;;
local-model/runtime) key=lm-runtime ;;
quick-start) key=quick-start ;;
sessions/*/*) key=$(basename "$route") ;;
sessions/*) if [ "$m" = DELETE ]; then key=delete; else key=session; fi ;;
*) key=other ;;
esac
jq -nc --arg m "$m" --arg k "$key" --arg q "$q" --arg d "$d" '{m:$m,key:$k,q:$q,d:$d}' >> "$CALLS"
n=$(grep -cF "\"key\":\"$key\"" "$CALLS")
f="$ANSWERS/$key.$n"; [ -f "$f" ] || f="$ANSWERS/$key"
if [ "$key" = wait-output ]; then
case "$q" in *match=DONE_*)
tok=$(printf '%s' "$q" | sed -e 's/.*match=DONE_//' -e 's/&.*//')
if [ -n "$ANSWER_TEXT" ]; then printf '%s' "$ANSWER_TEXT" > "$WD/codeman-answer-$tok.md"; fi
if [ -n "$ANSWER_APPEND" ]; then printf '%s\n' "$ANSWER_APPEND" >> "$WD/codeman-answer-$tok.md"; fi
if [ -n "$ANSWER_SYMLINK" ]; then ln -s "$ANSWER_SYMLINK" "$WD/codeman-answer-$tok.md"; fi ;;
esac
fi
[ -f "$f.delay" ] && sleep "$(cat "$f.delay")"
if [ -f "$f" ]; then cat "$f"; else printf '%s' '{"success":false,"error":"no stub answer","errorCode":"NOT_FOUND"}'; fi
if [ -n "$w" ]; then printf '\n%s' "$(cat "$f.code" 2>/dev/null || echo 200)"; fi
}
CURL=(stub)
`;
interface Call {
m: string;
key: string;
q: string;
d: string;
}
interface Run {
rc: number | null;
stdout: string;
stderr: string;
calls: Call[];
wd: string;
}
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
/** Sources the preamble, installs the stub, runs `body`, and returns what happened. */
function runSkill(body: string, answers: Record<string, string> = {}, env: Record<string, string> = {}): Run {
const dir = mkdtempSync(join(tmpdir(), 'codeman-local-skill-'));
dirs.push(dir);
const answerDir = join(dir, 'answers');
const wd = join(dir, 'wd');
const calls = join(dir, 'calls');
mkdirSync(answerDir);
mkdirSync(wd);
writeFileSync(calls, '');
for (const [name, content] of Object.entries(answers)) writeFileSync(join(answerDir, name), content);
const res = spawnSync('bash', ['-c', `. "$PREAMBLE" >/dev/null\n${STUB}\n${body}`], {
encoding: 'utf-8',
timeout: 20_000,
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
PREAMBLE,
CALLS: calls,
ANSWERS: answerDir,
WD: wd,
CODEMAN_API_URL: API,
CODEMAN_SESSION_ID: SELF,
...env,
},
});
const callList = readFileSync(calls, 'utf-8')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as Call);
return { rc: res.status, stdout: res.stdout, stderr: res.stderr, calls: callList, wd };
}
/** The `data` of GET /api/local-model, as the server builds it. */
function status(install: Record<string, unknown>, recommendation: Record<string, unknown> = {}): string {
return JSON.stringify({
success: true,
data: {
hardware: { platform: 'darwin' },
recommendation: { verdict: 'ok', headline: { text: 'A model fits.' }, ...recommendation },
catalogUpdated: '2026-11-06',
install: { running: false, actions: [], ...install },
},
});
}
const INSTALLED = {
state: 'installed',
cli: 'opencode',
cliLabel: 'OpenCode',
endpointId: 'local-ollama',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
};
const RUNNING = status({ ...INSTALLED, running: true, actions: ['start-session', 'stop', 'remove'] });
const STOPPED = status({ ...INSTALLED, running: false, actions: ['start', 'remove'] });
const QUICK_START = JSON.stringify({ success: true, data: { sessionId: SID, casePath: '/tmp/summ' } });
const MATCHED = JSON.stringify({ success: true, data: { wait: { matched: true, timedOut: false, ended: false } } });
const NOT_MATCHED = JSON.stringify({ success: true, data: { wait: { matched: false, timedOut: true, ended: false } } });
function lastJson(stdout: string): Record<string, unknown> {
const lines = stdout.trim().split('\n');
return JSON.parse(lines[lines.length - 1]) as Record<string, unknown>;
}
describe.skipIf(!hasJq)('preamble local_model', () => {
const cases: Array<{ name: string; body: string; code?: string; rc: number; expect: Record<string, unknown> }> = [
{
name: 'a running model: spawnable now, with the harness, endpoint and model',
body: RUNNING,
rc: 0,
expect: {
spawnable: true,
running: true,
cli: 'opencode',
cliLabel: 'OpenCode',
endpointId: 'local-ollama',
modelId: 'qwen3.6:35b-a3b',
modelLabel: 'Qwen3.6 35B-A3B',
},
},
{
name: 'an installed model whose runtime is stopped: spawnable once started',
body: STOPPED,
rc: 0,
expect: { spawnable: true, running: false, cli: 'opencode' },
},
{
name: 'start offered beside an error (another program holds the port): blocked, with the sentence',
body: status({
...INSTALLED,
actions: ['start', 'remove'],
error: {
code: 'port-in-use',
next: 'other-port',
message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } },
},
}),
rc: 1,
expect: {
spawnable: false,
reason: 'blocked',
errorCode: 'port-in-use',
message: 'Port 11435 is in use by another program.',
},
},
{
name: 'not a Mac',
body: JSON.stringify({
success: true,
data: {
hardware: { platform: 'linux' },
recommendation: { verdict: 'none', reason: 'not-macos', headline: { text: 'x' } },
install: { state: 'idle', running: false, actions: ['install'] },
},
}),
rc: 1,
expect: { spawnable: false, reason: 'not-mac' },
},
{
name: 'nothing installed',
body: status({ state: 'idle', actions: ['install'] }),
rc: 1,
expect: { spawnable: false, reason: 'not-installed' },
},
{
name: 'nothing installed on a Mac that fits none: the reason is the server sentence',
body: status(
{ state: 'idle', actions: ['install'] },
{
verdict: 'none',
reason: 'memory',
headline: { text: 'Nothing fits in {budget} GB.', vars: { budget: '12' } },
}
),
rc: 1,
expect: {
spawnable: false,
reason: 'not-installed',
message: 'No local model is installed. Nothing fits in 12 GB.',
},
},
{
name: 'an install that has not finished',
body: status({ state: 'paused', step: 'model', actions: ['resume', 'install', 'remove'] }),
rc: 1,
expect: { spawnable: false, reason: 'install-unfinished', state: 'paused', step: 'model' },
},
{
name: 'admin only in multi-user mode (403)',
body: JSON.stringify({ success: false, error: 'Admin only in multi-user mode', errorCode: 'FORBIDDEN' }),
code: '403',
rc: 1,
expect: { spawnable: false, reason: 'admin-only' },
},
{
name: 'a Codeman without local model support (404)',
body: JSON.stringify({ message: 'Route GET:/api/local-model not found', error: 'Not Found', statusCode: 404 }),
code: '404',
rc: 1,
expect: { spawnable: false, reason: 'unsupported' },
},
{
name: 'refused credentials (a plain-text 401)',
body: 'Unauthorized',
code: '401',
rc: 1,
expect: { spawnable: false, reason: 'unauthorized' },
},
{
name: 'no answer at all',
body: '',
code: '000',
rc: 1,
expect: { spawnable: false, reason: 'unreachable' },
},
{
name: 'any other failure carries its status and error',
body: JSON.stringify({ success: false, error: 'boom', errorCode: 'INTERNAL_ERROR' }),
code: '500',
rc: 1,
expect: { spawnable: false, reason: 'error', status: '500', message: 'boom' },
},
];
for (const c of cases) {
it(c.name, () => {
const answers: Record<string, string> = { 'lm-get': c.body };
if (c.code) answers['lm-get.code'] = c.code;
const run = runSkill('local_model', answers);
expect(run.rc).toBe(c.rc);
expect(run.stdout.trim().split('\n')).toHaveLength(1);
expect(lastJson(run.stdout)).toMatchObject(c.expect);
// One read-only GET of the alias path, and nothing else.
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual(['GET lm-get']);
});
}
});
describe.skipIf(!hasJq)('preamble spawn_worker <name> local', () => {
it('starts a stopped runtime, then quick-starts the harness on the endpoint and waits for its composer', () => {
const run = runSkill(
'spawn_worker summ local',
{
'lm-get.1': STOPPED,
'lm-runtime': JSON.stringify({ success: true, data: { state: 'installed', running: false } }),
'lm-get.2': RUNNING,
'quick-start': QUICK_START,
'wait-output': MATCHED,
},
// A cloud advisor must never be attached to a worker on the local model.
{ CODEMAN_WORKER_ADVISOR: 'opus' }
);
expect(run.stderr).toBe('');
expect(run.rc).toBe(0);
expect(run.stdout).toBe(`${SID}\n`);
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
'GET lm-get',
'POST lm-runtime',
'GET lm-get',
'POST quick-start',
'GET wait-output',
]);
expect(JSON.parse(run.calls[1].d)).toEqual({ action: 'start' });
expect(JSON.parse(run.calls[3].d)).toEqual({
caseName: 'summ',
mode: 'opencode',
parentSessionId: SELF,
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(run.calls[4].q).toContain('match=┃');
expect(run.calls[4].q).toContain('from=buffer');
});
it('does not touch a runtime that already runs', () => {
const run = runSkill('spawn_worker summ local', {
'lm-get': RUNNING,
'quick-start': QUICK_START,
'wait-output': MATCHED,
});
expect(run.rc).toBe(0);
expect(run.stdout).toBe(`${SID}\n`);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
});
for (const answer of [
{ requiresContextWarning: true, modelId: 'qwen3.6:35b-a3b', contextLength: 32768, minSafeContextTokens: 40000 },
{ requiresConfirmation: true, currentlyLoadedModel: 'other', affectedSessions: [{ id: 'x', name: 'w9' }] },
]) {
it(`never confirms on anyone's behalf: ${Object.keys(answer)[0]} fails the spawn with empty stdout`, () => {
// The route returns these bare; the server's envelope wraps them as success data.
const run = runSkill('spawn_worker summ local', {
'lm-get': RUNNING,
'quick-start': JSON.stringify({ success: true, data: answer }),
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/wants a person to confirm/);
expect(run.stderr).toContain(Object.keys(answer)[0]);
// One quick-start, sent without any confirmation flag, and nothing after it.
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start']);
expect(JSON.parse(run.calls[1].d).customModel).toEqual({
endpointId: 'local-ollama',
modelId: 'qwen3.6:35b-a3b',
});
});
}
it('reports a refused launch (the runtime check in quick-start) and spawns nothing', () => {
const run = runSkill('spawn_worker summ local', {
'lm-get': RUNNING,
'quick-start': JSON.stringify({
success: false,
error: 'The model on this Mac is not running.',
errorCode: 'OPERATION_FAILED',
}),
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toContain('OPERATION_FAILED');
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start']);
});
it('refuses before any quick-start when no local model is installed', () => {
const run = runSkill('spawn_worker summ local', { 'lm-get': status({ state: 'idle', actions: ['install'] }) });
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/no local model to spawn on \(not-installed\)/);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('reports a runtime that was started but did not come up, and spawns nothing', () => {
const failed = status({
...INSTALLED,
actions: ['start', 'remove'],
error: { code: 'runtime-start-failed', next: 'start', message: { text: 'The runtime did not start.' } },
});
const run = runSkill('spawn_worker summ local', {
'lm-get.1': STOPPED,
'lm-runtime': JSON.stringify({ success: true, data: { state: 'installed', running: false } }),
'lm-get.2': failed,
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/started, but \(blocked\): The runtime did not start\./);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'lm-runtime', 'lm-get']);
});
it('deletes a worker whose composer never drew and hands back nothing', () => {
const run = runSkill('spawn_worker summ local', {
'lm-get': RUNNING,
'quick-start': QUICK_START,
'wait-output': JSON.stringify({ success: true, data: { wait: { matched: false, timedOut: true } } }),
delete: JSON.stringify({ success: true, data: {} }),
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/never drew/);
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
'GET lm-get',
'POST quick-start',
'GET wait-output',
'DELETE delete',
]);
});
it('refuses a harness it knows no composer mark for, unless LOCAL_READY_MARK names one', () => {
const pi = status({ ...INSTALLED, cli: 'pi', running: true, actions: ['start-session', 'stop', 'remove'] });
const refused = runSkill('spawn_worker summ local', { 'lm-get': pi });
expect(refused.rc).toBe(1);
expect(refused.stdout).toBe('');
expect(refused.stderr).toMatch(/LOCAL_READY_MARK/);
expect(refused.calls.map((call) => call.key)).toEqual(['lm-get']);
const marked = runSkill(
'spawn_worker summ local',
{ 'lm-get': pi, 'quick-start': QUICK_START, 'wait-output': MATCHED },
{ LOCAL_READY_MARK: 'pi-ready' }
);
expect(marked.rc).toBe(0);
expect(marked.stdout).toBe(`${SID}\n`);
expect(JSON.parse(marked.calls[1].d).mode).toBe('pi');
expect(marked.calls[2].q).toContain('match=pi-ready');
});
it('runs a claude harness through the claude readiness path (hooks vetted, shift+tab)', () => {
const claude = status({ ...INSTALLED, cli: 'claude', running: true, actions: ['start-session', 'stop', 'remove'] });
const run = runSkill(
`mkdir -p "$WD/.claude" && printf '%s' '{"hooks":{"Stop":[{"command":"curl /api/hook-event"}]}}' > "$WD/.claude/settings.local.json"
printf '%s' "{\\"success\\":true,\\"data\\":{\\"sessionId\\":\\"${SID}\\",\\"casePath\\":\\"$WD\\"}}" > "$ANSWERS/quick-start"
spawn_worker summ local`,
{ 'lm-get': claude, 'wait-output': MATCHED },
{ CODEMAN_WORKER_ADVISOR: 'opus' }
);
expect(run.stderr).toBe('');
expect(run.rc).toBe(0);
expect(run.stdout).toBe(`${SID}\n`);
const body = JSON.parse(run.calls[1].d);
expect(body.mode).toBe('claude');
expect(body.advisorModel).toBeUndefined();
expect(body.customModel).toEqual({ endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
expect(run.calls[2].q).toContain('match=shift+tab');
// The advisor read-back is skipped too: no GET of the session.
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
});
});
describe.skipIf(!hasJq)('preamble sendmark', () => {
const delivered = JSON.stringify({
success: true,
data: { delivered: true, duplicate: false, wait: { timedOut: true, ended: false, signal: null } },
});
/** Answers GET /api/sessions/:id with the run's own working directory, then runs `body`. */
function withSession(body: string): string {
return `printf '%s' "{\\"success\\":true,\\"data\\":{\\"id\\":\\"${SID}\\",\\"workingDir\\":\\"$WD\\"}}" > "$ANSWERS/session"; ${body}`;
}
it('sends once, waits for the split marker, and returns the answer file', () => {
const run = runSkill(
withSession(`sendmark ${SID} $'Summarize these files:\\n- a.md\\n- b.md' 30`),
{ input: delivered, 'wait-output': MATCHED },
{ ANSWER_TEXT: 'a.md: setup. b.md: usage.' }
);
expect(run.stderr).toBe('');
expect(run.rc).toBe(0);
const out = lastJson(run.stdout);
expect(out).toMatchObject({
delivered: true,
matched: true,
answer: 'a.md: setup. b.md: usage.',
truncated: false,
});
const token = out.token as string;
expect(token).toMatch(/^m[0-9a-f]+$/);
expect(out.answerFile).toBe(join(run.wd, `codeman-answer-${token}.md`));
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual(['GET session', 'POST input', 'GET wait-output']);
const input = JSON.parse(run.calls[1].d) as Record<string, unknown>;
// The exactly-once body: the per-worker clientId, an epoch-second seq, one Enter at the end.
expect(input.clientId).toBe(`codeman-agent-1-${SID}`);
expect(typeof input.seq).toBe('number');
expect(input).toMatchObject({ useMux: true, wait: 'exit', waitTimeout: 1000 });
const text = input.input as string;
expect(text.endsWith('\r')).toBe(true);
expect(text.slice(0, -1)).not.toMatch(/[\r\n]/);
expect(text.startsWith('Summarize these files: - a.md - b.md (When you are done')).toBe(true);
expect(text).toContain(`codeman-answer-${token}.md`);
expect(text).toContain(`the word DONE immediately followed by _${token}`);
// Split: the typed prompt never holds the joined marker, so its echo cannot match.
expect(text).not.toContain(`DONE_${token}`);
expect(run.calls[2].q).toContain(`match=DONE_${token}`);
expect(run.calls[2].q).toContain('from=buffer');
});
it('answers 2 when no marker prints before the timeout, and deletes nothing', () => {
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 1`), {
input: delivered,
'wait-output': NOT_MATCHED,
'wait-output.delay': '1',
});
expect(run.rc).toBe(2);
expect(lastJson(run.stdout)).toMatchObject({ delivered: true, matched: false, answer: null });
expect(run.calls.some((call) => call.key === 'delete')).toBe(false);
});
it('reads the answer file without a marker once it held still for a round (a full-screen pane draws the marker in pieces)', () => {
// Measured on opencode 1.3.0: a reply streamed a character or two at a time reaches the
// stripped stream as D, spinner, O, spinner, N, ... and never matches, while the screen
// shows the whole token. The file must still end the wait.
const run = runSkill(
withSession(`sendmark ${SID} 'count to three' 30`),
{ input: delivered, 'wait-output': NOT_MATCHED },
{ ANSWER_TEXT: 'one two three' }
);
expect(run.stderr).toBe('');
expect(run.rc).toBe(0);
expect(lastJson(run.stdout)).toMatchObject({ delivered: true, matched: false, answer: 'one two three' });
// The first round finds the file, the second finds it unchanged: two marker waits, then done.
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait-output']);
for (const call of run.calls.slice(2)) expect(call.q).toContain('timeout=10000');
});
it('does not read an answer file that is still changing, and answers 2 at the timeout', () => {
const run = runSkill(
withSession(`sendmark ${SID} 'count to three' 3`),
{ input: delivered, 'wait-output': NOT_MATCHED, 'wait-output.delay': '1' },
{ ANSWER_APPEND: 'one more line' }
);
expect(run.rc).toBe(2);
expect(lastJson(run.stdout)).toMatchObject({ matched: false, answer: null });
expect(run.calls.filter((call) => call.key === 'wait-output').length).toBeGreaterThanOrEqual(2);
});
it('answers 3 when the worker ends while it waits for the answer file after the marker', () => {
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
input: delivered,
'wait-output': MATCHED,
wait: JSON.stringify({ success: true, data: { wait: { signal: 'exit', ended: false, timedOut: false } } }),
});
expect(run.rc).toBe(3);
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null, ended: true });
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait']);
});
it('still waits two seconds per check for the answer file when the exit wait gets no answer', () => {
const started = Date.now();
const run = runSkill(
withSession(`sendmark ${SID} 'count to three' 30`),
// No answer file for `wait`: the stub answers success:false, as a server that is gone.
{ input: delivered, 'wait-output': MATCHED },
{ SENDMARK_FILE_WAIT: '2' }
);
expect(run.rc).toBe(4);
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait']);
expect(Date.now() - started).toBeGreaterThanOrEqual(2000);
});
it('answers 4 when the marker printed but no answer file turned up', () => {
const run = runSkill(
withSession(`sendmark ${SID} 'count to three' 30`),
{ input: delivered, 'wait-output': MATCHED },
{ SENDMARK_FILE_WAIT: '0' }
);
expect(run.rc).toBe(4);
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null });
});
it('refuses to read an answer file that is a symlink', () => {
const secret = join(tmpdir(), 'codeman-sendmark-not-read');
const run = runSkill(
withSession(
`printf 'private' > "${secret}"; sendmark ${SID} 'count to three' 30; rc=$?; rm -f "${secret}"; exit $rc`
),
{ input: delivered, 'wait-output': MATCHED },
{ ANSWER_SYMLINK: secret, SENDMARK_FILE_WAIT: '0' }
);
expect(run.rc).toBe(4);
expect(run.stderr).toMatch(/is a symlink, not read/);
expect(run.stdout).not.toContain('private');
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null });
});
it('answers 3 when the pane did not take the prompt because the worker ended', () => {
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
input: JSON.stringify({
success: true,
data: { delivered: false, duplicate: false, wait: { ended: true, aborted: false } },
}),
});
expect(run.rc).toBe(3);
expect(lastJson(run.stdout)).toMatchObject({ delivered: false, matched: false });
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input']);
});
it('answers 3 when the worker ends while it waits for the marker', () => {
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
input: delivered,
'wait-output': JSON.stringify({
success: true,
data: { wait: { matched: false, timedOut: false, ended: true } },
}),
});
expect(run.rc).toBe(3);
expect(lastJson(run.stdout)).toMatchObject({ matched: false, ended: true });
});
it('with SENDMARK_ANSWER_FILE=0 asks for the marker alone and reads no session', () => {
const run = runSkill(
`sendmark ${SID} 'review this' 30`,
{ input: delivered, 'wait-output': MATCHED },
{ SENDMARK_ANSWER_FILE: '0' }
);
expect(run.rc).toBe(0);
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answerFile: null, answer: null });
expect(run.calls.map((call) => call.key)).toEqual(['input', 'wait-output']);
const text = (JSON.parse(run.calls[0].d) as { input: string }).input;
expect(text).not.toContain('codeman-answer-');
expect(text).toContain('the word DONE immediately followed by _');
});
it('refuses an empty prompt before sending anything', () => {
const run = runSkill(withSession(`sendmark ${SID} $' \\n\\t\\n'`));
expect(run.rc).toBe(1);
expect(run.stderr).toMatch(/sendmark: empty prompt/);
expect(run.calls).toEqual([]);
});
});