mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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:
commit
560377b228
10 files changed
+800
-159
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)):
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in new issue
Block a user