Merge #807: the skill spawns a local model worker with the coding agent of your choice (local:pi, local:deepseek)

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 20:15:43 +01:00
commit 560377b228
10 files changed
+800 -159

No files matched your search

+5 -3
View File
@@ -339,10 +339,12 @@ The codeman skill ([`skills/codeman`](../skills/codeman/SKILL.md#claude-in-the-c
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 default coding agent (`install.cli`) on `local-ollama` through the same
is installed or why nothing can run, with the coding agents the model can be run with and the
models on the Mac; `spawn_worker <name> local` starts the runtime when it was
stopped (as Start does), then starts the default coding agent (`install.cli`), or with
`local:<agent>` (`local:pi`, `local:deepseek`) another one that is ready, 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
answer back from a file it writes in its own folder. The skill never installs a model or a coding agent 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.
+138 -39
View File
@@ -107,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.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.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
@@ -135,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.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) ----
grep -qs '^CODEMAN_PREAMBLE=1.51.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.51.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).
@@ -197,6 +197,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
# RENDERED pane with its whitespace removed, and never handed back while it shows.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
@@ -259,9 +270,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# 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>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# 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
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), 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.
@@ -277,12 +297,23 @@ local_model() {
| 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
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `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)
elif ($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,
@@ -345,31 +376,55 @@ _local_ready_model() {
# 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.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. 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, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# 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 lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
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='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
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
@@ -384,11 +439,16 @@ spawn_worker() {
# 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. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
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:-}" --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 != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} 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
@@ -405,11 +465,18 @@ spawn_worker() {
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.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for 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; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -425,6 +492,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -683,10 +754,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.50.0
CODEMAN_PREAMBLE=1.51.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.51.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
@@ -737,7 +808,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.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.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'
@@ -851,9 +922,25 @@ Three things to know before you spawn one:
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`.
on the installed model, run by its default coding agent (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`.
**Which coding agent runs the worker is yours to choose.** `local_model` lists them as
`agents`, each with a `status`; `spawn_worker <name> local:<cli>` starts one that is `ready`:
| Worker kind | Agent | Why you would pick it | Drive it with |
| --- | --- | --- | --- |
| `local` | the default one (`cli` of `local_model`) | you have no reason to choose | `sendmark` (`sendwait` when it is claude) |
| `local:opencode` | OpenCode | the tested pair; a full tool set | `sendmark` |
| `local:pi` | Pi | the smallest fixed prompt (about 2,100 tokens against 8,000 and more), so the first answer comes soonest and the most context is left for the work | `sendmark` |
| `local:deepseek` | DeepSeek Harness | the user already works with it | `sendmark` (it reports the end of a turn, but on dsh 0.2.2 its answer cannot be read back through `sendwait` yet) |
| `local:claude` | Claude Code | hooks and `sendwait`, at the price of a first answer that takes minutes | `sendwait` |
An agent whose status is `installable`, `installing` or `setup-needed` is not startable, and
the spawn says so with the ones that are. Installing an agent is a person's click in the
**Run a local model** window, like installing a model: tell the user, do not work around it.
A Codeman older than 1.51 lists no agents, and only plain `local` works there.
Reach for it when:
@@ -867,9 +954,9 @@ Reach for it when:
```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
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agents, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # or local:pi, local:deepseek; 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=$?
@@ -892,8 +979,9 @@ 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.
`install-unfinished`, `no-agent`, `blocked`, `admin-only`, `unsupported`), so tell the
user that instead of guessing. It also lists the models on this Mac (`models`); only the
`active` one runs, and switching to another is a person's decision in the window.
- **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.
@@ -901,9 +989,20 @@ The limits, before you promise the user anything:
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.
- **Markers, not hooks.** OpenCode and Pi report no end of turn, so `sendwait` answers 400
for those workers; `sendmark` is the way through. A claude worker has hooks, so
`sendwait` works for it. A DeepSeek Harness worker reports its turns too, but drive it
with `sendmark` on the local model: dsh 0.2.2 renamed its transcript file, and Codeman
reads an empty answer from it for now.
- **DeepSeek Harness has a setup guide a person finishes once.** Until then every dsh
session opens on it, and the spawn refuses the worker (and deletes it) instead of typing
a task into a sign-in screen. Tell the user to start one DeepSeek session from the Run
menu and finish the guide.
- **Pi starts without trusting the folder.** A `local:pi` worker is started with
`--no-approve`, so it loads no project extensions or skills from the case folder: on the
local model Pi would otherwise ask to trust the folder in every session, and a task
prompt must never answer that question. Pi also fetches its two search tools at each such
start, which needs the network for a moment.
- **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
+95 -24
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.51.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).
@@ -59,6 +59,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
# RENDERED pane with its whitespace removed, and never handed back while it shows.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
@@ -121,9 +132,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# 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>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# 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
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), 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.
@@ -139,12 +159,23 @@ local_model() {
| 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
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `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)
elif ($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,
@@ -207,31 +238,55 @@ _local_ready_model() {
# 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.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. 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, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# 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 lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
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='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
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
@@ -246,11 +301,16 @@ spawn_worker() {
# 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. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
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:-}" --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 != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} 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
@@ -267,11 +327,18 @@ spawn_worker() {
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.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for 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; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -287,6 +354,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -545,4 +616,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.50.0
CODEMAN_PREAMBLE=1.51.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.50.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.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
@@ -836,9 +836,19 @@ can be spawned now (`running: true`) or once `spawn_worker` has started the runt
```json
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B",
"agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
{"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
"models":[{"id":"qwen3.6:35b-a3b","label":"Qwen3.6 35B-A3B","active":true,"status":"ready","sizeGb":"23.1"}]}
```
`cli` is the default coding agent. `agents` is every agent the model can be run with
(`install.harnesses` of the route): `ready` can be started, `installable` and `installing`
are an install a person makes or made in the window, `setup-needed` is an agent with a setup
of its own (DeepSeek Harness without a terminal profile). `models` is what is on the Mac
(`install.library`); only the `active` one runs. A Codeman older than 1.51 sends neither
list, and both keys are then absent.
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
| `reason` | What it means | What to tell the user |
@@ -846,6 +856,7 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
| `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 |
| `no-agent` | installed, and `agents` holds none that is `ready` | install a coding agent in that window (a chip that says "Not installed") |
| `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 |
@@ -854,29 +865,44 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
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:
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), and
**`spawn_worker <name> local:<cli>`** to choose the coding agent, in order:
1. `local_model`; anything but exit 0 fails the spawn with that reason.
1. `local_model`; anything but exit 0 fails the spawn with that reason. With `local:<cli>`
the agent must be a row of `agents` whose status is `ready`; any other status, or an
agent the model cannot be run with, fails the spawn with the ones that are startable.
On a Codeman that lists no agents only its one `cli` can be named. No agent is ever
installed from here.
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.
session", with the chosen agent as the mode. No advisor, which is a cloud model. A Pi
worker also carries `"piConfig":{"approveProjectTrust":false}` (`--no-approve`): on a
custom model Pi keeps no home, so it would ask to trust the folder in every session with
Trust preselected, and the task prompt's Enter would answer it.
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.
5. Readiness: a token the agent's composer draws once it takes input, through
`wait-output`, 45 s, and the session deleted on a miss. The token is the agent's
`readyMark` from `local_model` when the server sends one, else the one measured for this
skill, and `LOCAL_READY_MARK` overrides both. All of it was measured on Linux through a
custom model launch, none of it on a Mac yet:
| Agent | Token | What was measured |
|-------|-------|-------------------|
| OpenCode | `┃` | 1.3.0 and 1.19.5: nothing draws it before the composer, no first-run screen, and a prompt pasted at the first `┃` was submitted (2 of 2 on 1.19.5) |
| DeepSeek Harness | `❯`, then a look at the pane | 0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1: splash, composer and status line arrive in one frame; a prompt sent at the mark was submitted (2 of 2). The harness reports idle about 150 ms BEFORE that frame, so `stop` is no readiness signal. 0.2.2-rc.1 with dsh-TUI 0.19.0: a setup guide ("Step 1 of 4") opens in every session until a person has finished it once, and its cursor is `❯` too (the mark matched on it, 2 of 2), so after the mark the rendered pane is read and a worker that shows the guide is deleted, never handed back; with the guide finished, a prompt sent at the mark was submitted |
| Pi | `extended-keys` | 1.5.2: Pi paints its composer rules in its first frame and ignores Enter until startup has finished, so the rule `─` is NOT a mark (a prompt sent at it stayed in the composer, 2 of 2). The last line of its startup is a warning that tmux's `extended-keys` option is off, which it is on Codeman's socket; a prompt sent once that word showed was submitted (4 of 4). A side effect, not a contract: a Pi that stops printing it fails the spawn until `LOCAL_READY_MARK` names what it draws instead |
A claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). An agent with no token at all 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)):
+138 -39
View File
@@ -107,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.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.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
@@ -135,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.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) ----
grep -qs '^CODEMAN_PREAMBLE=1.51.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.51.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).
@@ -197,6 +197,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
# RENDERED pane with its whitespace removed, and never handed back while it shows.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
@@ -259,9 +270,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# 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>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# 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
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), 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.
@@ -277,12 +297,23 @@ local_model() {
| 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
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `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)
elif ($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,
@@ -345,31 +376,55 @@ _local_ready_model() {
# 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.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. 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, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# 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 lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
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='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
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
@@ -384,11 +439,16 @@ spawn_worker() {
# 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. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
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:-}" --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 != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} 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
@@ -405,11 +465,18 @@ spawn_worker() {
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.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for 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; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -425,6 +492,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -683,10 +754,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.50.0
CODEMAN_PREAMBLE=1.51.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.50.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.51.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
@@ -737,7 +808,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.50.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.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'
@@ -851,9 +922,25 @@ Three things to know before you spawn one:
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`.
on the installed model, run by its default coding agent (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`.
**Which coding agent runs the worker is yours to choose.** `local_model` lists them as
`agents`, each with a `status`; `spawn_worker <name> local:<cli>` starts one that is `ready`:
| Worker kind | Agent | Why you would pick it | Drive it with |
| --- | --- | --- | --- |
| `local` | the default one (`cli` of `local_model`) | you have no reason to choose | `sendmark` (`sendwait` when it is claude) |
| `local:opencode` | OpenCode | the tested pair; a full tool set | `sendmark` |
| `local:pi` | Pi | the smallest fixed prompt (about 2,100 tokens against 8,000 and more), so the first answer comes soonest and the most context is left for the work | `sendmark` |
| `local:deepseek` | DeepSeek Harness | the user already works with it | `sendmark` (it reports the end of a turn, but on dsh 0.2.2 its answer cannot be read back through `sendwait` yet) |
| `local:claude` | Claude Code | hooks and `sendwait`, at the price of a first answer that takes minutes | `sendwait` |
An agent whose status is `installable`, `installing` or `setup-needed` is not startable, and
the spawn says so with the ones that are. Installing an agent is a person's click in the
**Run a local model** window, like installing a model: tell the user, do not work around it.
A Codeman older than 1.51 lists no agents, and only plain `local` works there.
Reach for it when:
@@ -867,9 +954,9 @@ Reach for it when:
```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
[ "${CODEMAN_PREAMBLE:-}" = 1.51.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
local_model || exit 1 # one JSON line: model, agents, endpoint; or the reason there is none
SID=$(spawn_worker summ local) || exit 1 # or local:pi, local:deepseek; 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=$?
@@ -892,8 +979,9 @@ 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.
`install-unfinished`, `no-agent`, `blocked`, `admin-only`, `unsupported`), so tell the
user that instead of guessing. It also lists the models on this Mac (`models`); only the
`active` one runs, and switching to another is a person's decision in the window.
- **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.
@@ -901,9 +989,20 @@ The limits, before you promise the user anything:
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.
- **Markers, not hooks.** OpenCode and Pi report no end of turn, so `sendwait` answers 400
for those workers; `sendmark` is the way through. A claude worker has hooks, so
`sendwait` works for it. A DeepSeek Harness worker reports its turns too, but drive it
with `sendmark` on the local model: dsh 0.2.2 renamed its transcript file, and Codeman
reads an empty answer from it for now.
- **DeepSeek Harness has a setup guide a person finishes once.** Until then every dsh
session opens on it, and the spawn refuses the worker (and deletes it) instead of typing
a task into a sign-in screen. Tell the user to start one DeepSeek session from the Run
menu and finish the guide.
- **Pi starts without trusting the folder.** A `local:pi` worker is started with
`--no-approve`, so it loads no project extensions or skills from the case folder: on the
local model Pi would otherwise ask to trust the folder in every session, and a task
prompt must never answer that question. Pi also fetches its two search tools at each such
start, which needs the network for a moment.
- **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
+95 -24
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.51.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).
@@ -59,6 +59,17 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
_mark_up "${1}" "${DSH_READY_MARK:-❯}" "${2}"
}
_dsh_guide() { # <sid> -> 0 when the pane shows dsh-TUI's first-run SETUP GUIDE, not a composer.
# dsh-TUI 0.19.0 opens a guide ("Welcome to dsh-TUI Step 1 of 4") in every session until a
# person has finished it once, and the guide's own cursor is `❯`: the readiness mark matches
# on it (measured, 2 of 2 launches), and an Enter there acts on the highlighted step, which
# is a sign-in. So a worker whose mark matched is still checked for the guide, on the
# RENDERED pane with its whitespace removed, and never handed back while it shows.
"${CURL[@]}" -G "$API/api/v1/sessions/${1}/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -qi 'Step[0-9]of[0-9]'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
@@ -121,9 +132,18 @@ _accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could n
# 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>"}
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>",
# "agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
# {"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
# "models":[{"id":"<id>","label":"<label>","active":true,"status":"ready","sizeGb":"23.1"}]}
# `cli` is the DEFAULT coding agent, the one `spawn_worker <name> local` starts. `agents` is
# every agent the model can be run with: `local:<cli>` starts one whose status is `ready`
# (`installable`, `installing` and `setup-needed` are a person's to resolve in the window).
# `models` is what is on this Mac; only the `active` one runs, and switching is a person's
# decision too. A Codeman older than 1.51 sends neither list: only `local` works there.
# 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
# one of: not-mac, not-installed, install-unfinished, no-agent (installed, but no coding agent
# that can run the model is on this Mac), 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.
@@ -139,12 +159,23 @@ local_model() {
| 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
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label,
agents:(if ($i.harnesses | type) == "array"
then [$i.harnesses[] | {cli, cliLabel, status, default, readyMark} | with_entries(select(.value != null))]
else null end),
models:(if ($i.library | type) == "array" and ($i.library | length) > 0
then [$i.library[] | {id, label, active, status, sizeGb} | with_entries(select(.value != null))]
else null end)} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# With the agents listed and none of them startable there is nothing to spawn,
# running or not: say that, not "blocked".
if $i.error == null and $base.agents != null and ([$base.agents[] | select(.status == "ready")] | length) == 0
then {spawnable:false, reason:"no-agent",
message:"The model is installed, but no coding agent that can run it is on this Mac. A person installs one in the Run a local model window."} + $base
# `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)
elif ($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,
@@ -207,31 +238,55 @@ _local_ready_model() {
# 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.
# mode `local` is the model Codeman installed on this Mac (local_model), run by its default
# coding agent; `local:<cli>` (`local:pi`, `local:deepseek`, `local:opencode`, `local:claude`)
# picks another one of local_model's `agents`, which must be `ready`. 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, and it never installs an
# agent. All local workers share ONE model that answers one request at a time.
# 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 lm cm='' mark=''
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r lm cm='' mark='' want=''
case "$mode" in local:*) want=${mode#local:}; mode=local ;; esac
if [ "$mode" = local ]; then
lm=$(_local_ready_model) || return 1
mode=$(jq -r .cli <<<"$lm")
if [ -n "$want" ]; then
# Only an agent the server lists as startable. A Codeman that lists none (older than
# 1.51) knows one agent, its `cli`: naming that one is fine, any other is not.
r=$(jq -r --arg w "$want" 'if (.agents | type) == "array" then ([.agents[] | select(.cli == $w) | .status][0] // "absent")
elif .cli == $w then "ready" else "unlisted" end' <<<"$lm")
case "$r" in
ready) ;;
unlisted) echo "this Codeman lists one coding agent for the local model, $(jq -r .cli <<<"$lm"): use plain \`local\`, or update Codeman to choose another" >&2; return 1 ;;
absent) echo "the local model cannot be run with '$want' here. Startable: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
*) echo "the coding agent '$want' is not startable on this Mac ($r): a person installs or sets it up in the Run a local model window. Startable now: $(jq -r '[.agents[] | select(.status == "ready") | .cli] | join(", ")' <<<"$lm")" >&2; return 1 ;;
esac
mode=$want
else
mode=$(jq -r .cli <<<"$lm")
fi
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='┃'
# Readiness is a token the agent's composer draws once it takes input. The server names
# the measured one per agent (`readyMark`: opencode `┃`, deepseek `❯`, claude `shift+tab`).
# For a server that sends none, the two this skill measured itself:
# - opencode `┃`: at boot only the composer draws it (1.3.0 and 1.19.5, a prompt pasted
# at the mark was submitted every time).
# - pi `extended-keys`: pi paints its composer rules in its FIRST frame and ignores Enter
# until startup has finished, so its rule is no mark (a prompt sent at it stayed
# unsent, 2 of 2). The last line of its startup is a warning that tmux's
# `extended-keys` option is off, which it is on Codeman's socket: a prompt sent once
# that word showed was submitted, 4 of 4 on pi 1.5.2. It is a side effect, not a
# contract, so a pi that stops printing it fails the spawn (the worker is deleted, never
# handed back on a guess) until LOCAL_READY_MARK names what it draws instead.
# claude takes the claude path below.
mark=$(jq -r --arg m "$mode" '[(.agents // [])[] | select(.cli == $m) | .readyMark // empty][0] // empty' <<<"$lm")
[ -n "$mark" ] || case "$mode" in opencode) mark='┃' ;; pi) mark='extended-keys' ;; esac
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
@@ -246,11 +301,16 @@ spawn_worker() {
# 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. The advisor is a cloud model,
# so a claude worker on the local model never gets one.
# pi on the local model: a custom model session gives pi a home of its own, so it would
# ask "Trust project folder?" in EVERY session whose parent folders hold agent skills, with
# Trust preselected, and the task prompt's Enter would answer it. `approveProjectTrust:
# false` starts it with --no-approve: no dialog, and no project code is trusted for anyone.
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:-}" --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 != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $m == "pi" and $cm != "" then {piConfig:{approveProjectTrust:false}} 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
@@ -267,11 +327,18 @@ spawn_worker() {
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.
# No trust dialog and no hook file to vet. opencode and pi have no end-of-turn signal,
# which is exactly why sendwait refuses them (400) and sendmark drives them. A deepseek
# worker reports the end of a turn on the local model too, but sendmark is still the way
# to get its ANSWER: dsh 0.2.2 renamed its transcript file, and until Codeman reads the
# new one `last-response` comes back empty for 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; }
if [ "$mode" = deepseek ] && _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
if [ "$mode" = deepseek ]; then
@@ -287,6 +354,10 @@ spawn_worker() {
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
if _dsh_guide "$sid"; then
echo "dsh worker $sid opened DeepSeek Harness's setup guide instead of a composer; deleted it. A person finishes that guide once (start a DeepSeek session from the Run menu), then spawn again" >&2
delete_session "$sid" >/dev/null; return 1
fi
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
@@ -545,4 +616,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.50.0
CODEMAN_PREAMBLE=1.51.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.50.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.51.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
+37 -11
View File
@@ -836,9 +836,19 @@ can be spawned now (`running: true`) or once `spawn_worker` has started the runt
```json
{"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B"}
"endpointId":"local-ollama","modelId":"qwen3.6:35b-a3b","modelLabel":"Qwen3.6 35B-A3B",
"agents":[{"cli":"opencode","cliLabel":"OpenCode","status":"ready","default":true,"readyMark":"┃"},
{"cli":"pi","cliLabel":"Pi","status":"installable","default":false}],
"models":[{"id":"qwen3.6:35b-a3b","label":"Qwen3.6 35B-A3B","active":true,"status":"ready","sizeGb":"23.1"}]}
```
`cli` is the default coding agent. `agents` is every agent the model can be run with
(`install.harnesses` of the route): `ready` can be started, `installable` and `installing`
are an install a person makes or made in the window, `setup-needed` is an agent with a setup
of its own (DeepSeek Harness without a terminal profile). `models` is what is on the Mac
(`install.library`); only the `active` one runs. A Codeman older than 1.51 sends neither
list, and both keys are then absent.
Exit 1 carries `reason` and `message` (the server's own sentence where it has one):
| `reason` | What it means | What to tell the user |
@@ -846,6 +856,7 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
| `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 |
| `no-agent` | installed, and `agents` holds none that is `ready` | install a coding agent in that window (a chip that says "Not installed") |
| `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 |
@@ -854,29 +865,44 @@ Exit 1 carries `reason` and `message` (the server's own sentence where it has on
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:
**`spawn_worker <name> local`** (also `name:local` in `spawn_workers`), and
**`spawn_worker <name> local:<cli>`** to choose the coding agent, in order:
1. `local_model`; anything but exit 0 fails the spawn with that reason.
1. `local_model`; anything but exit 0 fails the spawn with that reason. With `local:<cli>`
the agent must be a row of `agents` whose status is `ready`; any other status, or an
agent the model cannot be run with, fails the spawn with the ones that are startable.
On a Codeman that lists no agents only its one `cli` can be named. No agent is ever
installed from here.
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.
session", with the chosen agent as the mode. No advisor, which is a cloud model. A Pi
worker also carries `"piConfig":{"approveProjectTrust":false}` (`--no-approve`): on a
custom model Pi keeps no home, so it would ask to trust the folder in every session with
Trust preselected, and the task prompt's Enter would answer it.
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.
5. Readiness: a token the agent's composer draws once it takes input, through
`wait-output`, 45 s, and the session deleted on a miss. The token is the agent's
`readyMark` from `local_model` when the server sends one, else the one measured for this
skill, and `LOCAL_READY_MARK` overrides both. All of it was measured on Linux through a
custom model launch, none of it on a Mac yet:
| Agent | Token | What was measured |
|-------|-------|-------------------|
| OpenCode | `┃` | 1.3.0 and 1.19.5: nothing draws it before the composer, no first-run screen, and a prompt pasted at the first `┃` was submitted (2 of 2 on 1.19.5) |
| DeepSeek Harness | `❯`, then a look at the pane | 0.1.1-rc.2 with dsh-TUI 0.10.0-beta.1: splash, composer and status line arrive in one frame; a prompt sent at the mark was submitted (2 of 2). The harness reports idle about 150 ms BEFORE that frame, so `stop` is no readiness signal. 0.2.2-rc.1 with dsh-TUI 0.19.0: a setup guide ("Step 1 of 4") opens in every session until a person has finished it once, and its cursor is `❯` too (the mark matched on it, 2 of 2), so after the mark the rendered pane is read and a worker that shows the guide is deleted, never handed back; with the guide finished, a prompt sent at the mark was submitted |
| Pi | `extended-keys` | 1.5.2: Pi paints its composer rules in its first frame and ignores Enter until startup has finished, so the rule `─` is NOT a mark (a prompt sent at it stayed in the composer, 2 of 2). The last line of its startup is a warning that tmux's `extended-keys` option is off, which it is on Codeman's socket; a prompt sent once that word showed was submitted (4 of 4). A side effect, not a contract: a Pi that stops printing it fails the spawn until `LOCAL_READY_MARK` names what it draws instead |
A claude agent takes the claude path instead (hooks vetted, `shift+tab`, the trust
dialog). An agent with no token at all 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)):
+253 -6
View File
@@ -156,6 +156,31 @@ const INSTALLED = {
};
const RUNNING = status({ ...INSTALLED, running: true, actions: ['start-session', 'stop', 'remove'] });
const STOPPED = status({ ...INSTALLED, running: false, actions: ['start', 'remove'] });
/** What a 1.51 server lists beside `cli`: the agents the model can be run with, and the models on the Mac. */
const AGENTS = [
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: ['start-session'] },
{ cli: 'pi', cliLabel: 'Pi', default: false, status: 'ready', actions: ['start-session'] },
{
cli: 'deepseek',
cliLabel: 'DeepSeek',
default: false,
status: 'ready',
readyMark: '❯',
actions: ['start-session'],
},
{ cli: 'claude', cliLabel: 'Claude Code', default: false, status: 'installable', installCommand: 'x', actions: [] },
];
const LIBRARY = [
{ id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', active: true, verified: true, status: 'ready', sizeGb: '23.1' },
{ id: 'gpt-oss:20b', label: 'GPT-OSS 20B', active: false, verified: true, status: 'ready', sizeGb: '13.8' },
];
const WITH_AGENTS = status({
...INSTALLED,
running: true,
harnesses: AGENTS,
library: LIBRARY,
actions: ['start-session', 'stop', '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 } } });
@@ -181,6 +206,39 @@ describe.skipIf(!hasJq)('preamble local_model', () => {
modelLabel: 'Qwen3.6 35B-A3B',
},
},
{
name: 'a server that lists them: the agents with their status and mark, and the models on this Mac',
body: WITH_AGENTS,
rc: 0,
expect: {
spawnable: true,
cli: 'opencode',
agents: [
{ cli: 'opencode', cliLabel: 'OpenCode', status: 'ready', default: true, readyMark: '┃' },
{ cli: 'pi', cliLabel: 'Pi', status: 'ready', default: false },
{ cli: 'deepseek', cliLabel: 'DeepSeek', status: 'ready', default: false, readyMark: '❯' },
{ cli: 'claude', cliLabel: 'Claude Code', status: 'installable', default: false },
],
models: [
{ id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', active: true, status: 'ready', sizeGb: '23.1' },
{ id: 'gpt-oss:20b', label: 'GPT-OSS 20B', active: false, status: 'ready', sizeGb: '13.8' },
],
},
},
{
name: 'installed, but no coding agent that can run it is on this Mac: no-agent, running or stopped',
body: status({
...INSTALLED,
running: false,
harnesses: [
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'installable', actions: ['install'] },
],
// `start` with no error alone would read as spawnable.
actions: ['start', 'install-agent', 'remove'],
}),
rc: 1,
expect: { spawnable: false, reason: 'no-agent', agents: [{ cli: 'opencode', status: 'installable' }] },
},
{
name: 'an installed model whose runtime is stopped: spawnable once started',
body: STOPPED,
@@ -427,8 +485,8 @@ describe.skipIf(!hasJq)('preamble spawn_worker <name> local', () => {
});
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 });
const other = status({ ...INSTALLED, cli: 'omp', running: true, actions: ['start-session', 'stop', 'remove'] });
const refused = runSkill('spawn_worker summ local', { 'lm-get': other });
expect(refused.rc).toBe(1);
expect(refused.stdout).toBe('');
expect(refused.stderr).toMatch(/LOCAL_READY_MARK/);
@@ -436,13 +494,202 @@ describe.skipIf(!hasJq)('preamble spawn_worker <name> local', () => {
const marked = runSkill(
'spawn_worker summ local',
{ 'lm-get': pi, 'quick-start': QUICK_START, 'wait-output': MATCHED },
{ LOCAL_READY_MARK: 'pi-ready' }
{ 'lm-get': other, 'quick-start': QUICK_START, 'wait-output': MATCHED },
{ LOCAL_READY_MARK: 'omp-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');
expect(JSON.parse(marked.calls[1].d).mode).toBe('omp');
expect(marked.calls[2].q).toContain('match=omp-ready');
});
describe("local:<agent>: the coding agent is the caller's choice", () => {
const spawn = (kind: string, lm: string = WITH_AGENTS, env: Record<string, string> = {}) =>
runSkill(`spawn_worker summ ${kind}`, { 'lm-get': lm, 'quick-start': QUICK_START, 'wait-output': MATCHED }, env);
it('plain local still starts the default agent, with the mark the server names', () => {
const run = spawn('local');
expect(run.rc).toBe(0);
expect(JSON.parse(run.calls[1].d)).toEqual({
caseName: 'summ',
mode: 'opencode',
parentSessionId: SELF,
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(run.calls[2].q).toContain('match=┃');
});
it('local:pi starts Pi without trusting the folder, and waits for the end of its startup', () => {
const run = spawn('local:pi');
expect(run.stderr).toBe('');
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']);
expect(JSON.parse(run.calls[1].d)).toEqual({
caseName: 'summ',
mode: 'pi',
parentSessionId: SELF,
// --no-approve: the task prompt must never answer Pi's trust question.
piConfig: { approveProjectTrust: false },
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
// Not its composer rule, which is painted before Pi takes Enter.
expect(run.calls[2].q).toContain('match=extended-keys');
expect(run.calls[2].q).not.toContain('match=─');
});
it('local:deepseek starts DeepSeek Harness with the permission posture of the Run button, at its composer glyph', () => {
const run = spawn('local:deepseek');
expect(run.rc).toBe(0);
expect(JSON.parse(run.calls[1].d)).toEqual({
caseName: 'summ',
mode: 'deepseek',
parentSessionId: SELF,
deepSeekConfig: { permissionMode: 'danger-full-access' },
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(run.calls[2].q).toContain('match=❯');
});
it('a DeepSeek Harness worker that opened its setup guide is deleted, never handed back: its cursor is the mark too', () => {
// dsh-TUI 0.19.0, as the rendered pane reads: the guide, with `❯` on its first step.
const guide = JSON.stringify({
success: true,
data: {
terminalBuffer:
'\u001b[1mWelcome to dsh-TUI\u001b[0m Step 1 of 4\r\n ✗ No DEEPSEEK_API_KEY yet\r\n❯ Sign in to a subscription (OAuth)\r\n',
},
});
const composer = JSON.stringify({
success: true,
data: {
terminalBuffer:
'╭────╮\r\n│ ❯ Say something, or type / for commands…\r\n╰────╯\r\n qwen3.6:35b-a3b · standard\r\n',
},
});
const answers = { 'lm-get': WITH_AGENTS, 'quick-start': QUICK_START, 'wait-output': MATCHED };
const blocked = runSkill('spawn_worker rev local:deepseek', {
...answers,
terminal: guide,
delete: JSON.stringify({ success: true, data: {} }),
});
expect(blocked.rc).toBe(1);
expect(blocked.stdout).toBe('');
expect(blocked.stderr).toMatch(/setup guide/);
expect(blocked.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
'GET lm-get',
'POST quick-start',
'GET wait-output',
'GET terminal',
'DELETE delete',
]);
const ready = runSkill('spawn_worker rev local:deepseek', { ...answers, terminal: composer });
expect(ready.stderr).toBe('');
expect(ready.rc).toBe(0);
expect(ready.stdout).toBe(`${SID}\n`);
// The same look for a DeepSeek worker that is not on the local model.
const cloud = runSkill('spawn_worker rev deepseek', {
'quick-start': QUICK_START,
'wait-output': MATCHED,
terminal: guide,
delete: JSON.stringify({ success: true, data: {} }),
});
expect(cloud.rc).toBe(1);
expect(cloud.stdout).toBe('');
expect(cloud.stderr).toMatch(/setup guide/);
expect(cloud.calls.map((call) => call.key)).toEqual(['quick-start', 'wait-output', 'terminal', 'delete']);
});
it('a mark the server names wins over the one this skill measured, and LOCAL_READY_MARK over both', () => {
const named = status({
...INSTALLED,
running: true,
harnesses: AGENTS.map((agent) => (agent.cli === 'pi' ? { ...agent, readyMark: 'pi-is-up' } : agent)),
actions: ['start-session', 'stop', 'remove'],
});
expect(spawn('local:pi', named).calls[2].q).toContain('match=pi-is-up');
expect(spawn('local:pi', named, { LOCAL_READY_MARK: 'mine' }).calls[2].q).toContain('match=mine');
expect(spawn('local:opencode', WITH_AGENTS, { LOCAL_READY_MARK: 'mine' }).calls[2].q).toContain('match=mine');
});
it('refuses an agent that is not startable, names the ones that are, and starts nothing', () => {
const run = spawn('local:claude');
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toContain("'claude' is not startable on this Mac (installable)");
expect(run.stderr).toContain('Startable now: opencode, pi, deepseek');
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('refuses an agent the model cannot be run with at all', () => {
const run = spawn('local:grok');
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toContain("cannot be run with 'grok'");
expect(run.stderr).toContain('Startable: opencode, pi, deepseek');
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('a Codeman that lists no agents: its one agent can be named, another cannot', () => {
const same = spawn('local:opencode', RUNNING);
expect(same.rc).toBe(0);
expect(JSON.parse(same.calls[1].d).mode).toBe('opencode');
expect(same.calls[2].q).toContain('match=┃');
const other = spawn('local:pi', RUNNING);
expect(other.rc).toBe(1);
expect(other.stdout).toBe('');
expect(other.stderr).toMatch(/lists one coding agent for the local model, opencode/);
expect(other.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('with no agent that can be started nothing is spawned, whatever was asked for', () => {
const none = status({
...INSTALLED,
running: true,
harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'installing', actions: [] }],
actions: ['stop', 'remove'],
});
for (const kind of ['local', 'local:opencode']) {
const run = spawn(kind, none);
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/no local model to spawn on \(no-agent\)/);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
}
});
it('a worker whose mark never shows is deleted, and the message names the agent', () => {
const run = runSkill('spawn_worker summ local:pi', {
'lm-get': WITH_AGENTS,
'quick-start': QUICK_START,
'wait-output': NOT_MATCHED,
delete: JSON.stringify({ success: true, data: {} }),
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/\(pi\) never drew 'extended-keys'/);
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
'GET lm-get',
'POST quick-start',
'GET wait-output',
'DELETE delete',
]);
});
it('spawn_workers takes name:local:agent in a mixed fleet', () => {
const run = runSkill('spawn_workers a:local:pi', {
'lm-get': WITH_AGENTS,
'quick-start': QUICK_START,
'wait-output': MATCHED,
});
expect(run.calls.map((call) => call.key)).toContain('quick-start');
const body = JSON.parse(run.calls.find((call) => call.key === 'quick-start')!.d);
expect([body.caseName, body.mode]).toEqual(['a', 'pi']);
});
});
it('runs a claude harness through the claude readiness path (hooks vetted, shift+tab)', () => {