2026-11-08 07:48:08 +01:00
# ---- Codeman agent preamble 1.50.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
2026-10-15 19:33:09 +02:00
# 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).
2026-08-14 14:46:23 +02:00
API = " ${ CODEMAN_API_URL :?CODEMAN_API_URL not set; refusing to guess } "
SELF = " ${ CODEMAN_SESSION_ID :?CODEMAN_SESSION_ID not set } "
# Credentials, cheapest first. Your session has usually INHERITED the server's
# CODEMAN_PASSWORD already (§6 explains why, and what to do when it has not);
# the data dir's .env is the documented fallback, the same one `codeman attach`
# reads. The data dir is wherever the hook-secret file lives. Values may be
# quoted or `export`-prefixed.
ENV_FILE = " ${ CODEMAN_HOOK_SECRET_FILE :+ ${ CODEMAN_HOOK_SECRET_FILE %hook-secret } .env } "
2026-10-15 19:33:09 +02:00
envval() { sed -n "s/^\(export \)\{0,1\} ${ 1 } =//p" " $ENV_FILE " | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^' \' '\(.*\)' \' '$/\1/' ; }
2026-08-14 14:46:23 +02:00
if [ -z " ${ CODEMAN_PASSWORD :- } " ] && [ -n " $ENV_FILE " ] && [ -f " $ENV_FILE " ] ; then
CODEMAN_USERNAME = $( envval CODEMAN_USERNAME)
CODEMAN_PASSWORD = $( envval CODEMAN_PASSWORD)
fi
AUTH =() ; [ -n " ${ CODEMAN_PASSWORD :- } " ] && AUTH =( -u " ${ CODEMAN_USERNAME :- admin } : $CODEMAN_PASSWORD " )
# -k: harmless on http, required on https (self-signed cert).
# X-Codeman-Parent-Session: tags workers YOU spawn as your children, so the web UI can
2026-10-15 09:53:17 +02:00
# draw the lineage. Set once here and every present and future create call carries it.
# The agent inbox reads it too, as "who is calling" (§5.15); every other endpoint ignores
# it. It can never fail a spawn, so there is no case where you would want to leave it off.
2026-09-07 19:09:24 +02:00
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
CURL =( curl -sk " ${ AUTH [@] } " -H "X-Codeman-Parent-Session: $SELF " -H "X-Codeman-Agent-Origin: codeman-skill" )
2026-08-14 14:46:23 +02:00
CID = codeman-agent-1 # FIXED literal, never "agent-$$": see below
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
# `is_self "$SID" || curl -X DELETE ...` shape failed OPEN, because an undefined
# is_self exits 127 and the `||` branch then ran the delete completely unguarded.
# Undefined delete_session is "command not found", which deletes nothing.
delete_session() {
local id = " ${ 1 :- } "
[ -n " $id " ] || { echo "refusing: empty session id" ; return 1; }
[ " ${# SELF } " -ge 8 ] || { echo "refusing: \$SELF unset or too short to prove this is not me" ; return 1; }
# ids appear in full AND 8-char form (Docker exports a truncated $SELF; mux names and
# UI surfaces carry 8-char ids), so compare by prefix in BOTH directions. Equality or
# a one-directional check each miss a real combination, and the miss deletes you.
case " $id " in " $SELF " *) echo "refusing: $id is me" ; return 1 ;; esac
case " $SELF " in " $id " *) echo "refusing: $id is me" ; return 1 ;; esac
" ${ CURL [@] } " -X DELETE " $API /api/v1/sessions/ $id "
}
# ---- fast path: the four verbs, already written. §1 composes them. ----
_composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one token
2026-10-15 19:33:09 +02:00
" ${ CURL [@] } " -G " $API /api/v1/sessions/ ${ 1 } /wait-output" \
2026-08-14 14:46:23 +02:00
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
2026-10-15 19:33:09 +02:00
--data-urlencode "timeout= ${ 2 } " | jq -r '.data.wait.matched // false'
2026-08-14 14:46:23 +02:00
}
2026-11-08 07:48:08 +01:00
_mark_up() { # <sid> <mark> <timeoutMs> -> "true"/"false": a literal, space-free token
# in the pane's output, read from the buffer so a composer drawn before the wait counts.
" ${ CURL [@] } " -G " $API /api/v1/sessions/ ${ 1 } /wait-output" \
--data-urlencode "match= ${ 2 } " --data-urlencode 'from=buffer' \
--data-urlencode "timeout= ${ 3 } " | jq -r '.data.wait.matched // false'
}
2026-08-25 04:14:45 +02:00
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
2026-11-08 07:48:08 +01:00
_mark_up " ${ 1 } " " ${ DSH_READY_MARK :- ❯ } " " ${ 2 } "
2026-08-25 04:14:45 +02:00
}
2026-09-01 02:31:26 +02:00
# ---- 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:
# Security guide
# ❯ No, exit
# Yes, I trust this folder
# Enter to confirm . Esc to cancel
# so the bare \r that answered the old layout now answers *exit* and the pane is
# dead (`status 1`) seconds after the spawn -- measured on a live 2.1.252 case.
# These two read the rendered pane and steer onto the trust option instead.
_trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
2026-10-15 19:33:09 +02:00
" ${ CURL [@] } " -G " $API /api/v1/sessions/ ${ 1 } /terminal" --data-urlencode 'full=1' \
2026-09-01 02:31:26 +02:00
| 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 -i '❯ [0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
}
2026-09-19 11:38:59 +02:00
# ---- the composer: is the prompt still sitting there, unsent? ----
# ⚠️ Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment the
# composer paints but IGNORES Enter for the first 30-50 seconds after it: the \r that
# Codeman sends 50 ms after the text and a lone nudge at 20 s both leave the prompt
# stranded, with `0 tokens`, while the wait burns its whole timeout. Measured through
# this very route: Enter at 28 s stranded, Enter at 51 s submitted. So sendwait READS
# the composer and keeps pressing Enter while the prompt is still there.
_composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "" once
# the prompt was taken, "?" when the pane shows no composer at all. The composer is
# the LAST `❯ ` line: Claude Code echoes a submitted prompt with the same glyph higher
# up in the transcript, so only the last one says whether the text was taken.
local t
2026-10-15 19:33:09 +02:00
t = $( " ${ CURL [@] } " -G " $API /api/v1/sessions/ ${ 1 } /terminal" --data-urlencode 'full=1' \
2026-09-19 11:38:59 +02:00
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/ $( printf '\033' ) \[[0-9;?]*[a-zA-Z]//g" -e "s/ $( printf '\033' ) [()][AB0]//g" \
| tr -d '\r' | grep -a '^[[:space:]]*❯ ' | tail -1)
[ -n " $t " ] || { printf '?' ; return 0; }
# Claude Code draws a NO-BREAK SPACE (U+00A0) after the glyph, which [:space:] does
# not cover, so it is stripped by its bytes, portably (BSD sed has no \xHH).
printf '%s' " $t " | sed 's/^[[:space:]]*❯ //' | tr -d '[:space:]' | sed "s/ $( printf '\302\240' ) //g"
}
2026-09-01 02:31:26 +02:00
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
2026-10-15 19:33:09 +02:00
local sid = " ${ 1 } " k i = 1
2026-09-01 02:31:26 +02:00
while [ " $i " -le 6 ] ; do
k = $( _trust_key " $sid " )
[ -n " $k " ] || return 1 # no dialog on screen, or a layout this cannot read
# A SEPARATE clientId for these keys. seq is monotonic per clientId, so
# spending prompt numbers here would make the next sendwait -- whose default
# seq is the epoch second -- look like a stale duplicate and vanish silently.
" ${ CURL [@] } " -X POST " $API /api/v1/sessions/ $sid /input" -H 'Content-Type: application/json' \
-d " $( jq -nc --arg k " $( [ " $k " = confirm ] && printf '\r' || printf '\033[B' ) " \
--arg c " $CID -trust- $sid " --argjson s " $i " \
'{input:$k,useMux:true,clientId:$c,seq:$s}' ) " >/dev/null
[ " $k " = confirm ] && return 0
sleep 1; i = $(( i+1)) # re-read: the arrow is CONFIRMED before Enter goes out
done
return 1
}
2026-11-08 07:48:08 +01:00
# ---- the local model: the model Codeman installed on this Mac, as a worker ----
# local_model -> ONE compact JSON object on stdout. rc 0 only when a `local` worker can be
# spawned now (`running:true`) or once spawn_worker has started the runtime (`running:false`):
# {"spawnable":true,"state":"installed","running":true,"cli":"opencode","cliLabel":"OpenCode",
# "endpointId":"local-ollama","modelId":"<id>","modelLabel":"<label>"}
# Otherwise rc 1 and {"spawnable":false,"reason":"<reason>","message":"<why>",...}, the reason
# one of: not-mac, not-installed, install-unfinished, blocked (installed, but the server
# refuses a session now: the model changed on disk, another program holds the port, the
# runtime does not start), admin-only (multi-user mode, 403), unsupported (a Codeman without
# local models, 404), unauthorized (401, §6), unreachable, error.
# One GET of an EXPERIMENTAL route. It downloads, installs and starts nothing: installing is
# a person's decision, made in Codeman's "Run a local model" window.
local_model() {
local r code body out
r = $( " ${ CURL [@] } " -w '\n%{http_code}' " $API /api/v1/local-model" )
code = ${ r ##* $'\n' } ; body = ${ r % $'\n' * }
case " $code " in
200) out = $( jq -c '
def say: (.text // "") as $t
| reduce ((.vars // {}) | to_entries[]) as $e ($t; gsub("\\{" + $e.key + "\\}"; $e.value));
.data as $d | ($d.install // {}) as $i | ($i.actions // []) as $a
| {state:$i.state, running:($i.running == true), cli:$i.cli, cliLabel:$i.cliLabel,
endpointId:$i.endpointId, modelId:$i.model.id, modelLabel:$i.model.label} as $base
| if ($d | type) != "object" then {spawnable:false, reason:"error", message:"no data in the answer"}
elif $i.state == "installed" then
# `start` alone over-promises: it is offered too while another program holds the
# port. Only an error-free start is a runtime spawn_worker can bring up.
if ($i.cli and $i.endpointId and $i.model.id)
and (($a | index("start-session")) != null or (($a | index("start")) != null and $i.error == null))
then {spawnable:true} + $base
else {spawnable:false, reason:"blocked", errorCode:$i.error.code,
message:(if $i.error then ($i.error.message | say)
else "The model is installed, but the server offers no session on it right now." end)} + $base
end
elif $d.recommendation.reason == "not-macos" then
{spawnable:false, reason:"not-mac",
message:("Local models need a Mac with Apple silicon, and this Codeman runs on " + ($d.hardware.platform // "another system") + ".")}
elif $i.state == "idle" then
{spawnable:false, reason:"not-installed",
message:("No local model is installed. " + (if $d.recommendation.verdict == "none" then ($d.recommendation.headline | say)
else "A person installs one with Run a local model on the Codeman welcome screen." end))}
else
{spawnable:false, reason:"install-unfinished", step:$i.step, errorCode:$i.error.code,
message:("The install is " + ($i.state // "unfinished") + (if $i.step then " at its " + $i.step + " step" else "" end)
+ (if $i.error then ": " + ($i.error.message | say) else "." end)
+ " Only the Run a local model window continues it.")} + $base
end
| with_entries(select(.value != null))' <<< " $body " 2>/dev/null) ;;
000) out = $( jq -nc --arg a " $API " '{spawnable:false,reason:"unreachable",message:("No answer from Codeman at " + $a + ".")}' ) ;;
401) out = '{"spawnable":false,"reason":"unauthorized","message":"Codeman refused the credentials (401): see the codeman skill, section 6."}' ;;
403) jq -e '.errorCode == "FORBIDDEN"' <<< " $body " >/dev/null 2>& 1 &&
out = '{"spawnable":false,"reason":"admin-only","message":"In multi-user mode the local model is for admins only: ask an admin of this Codeman."}' ;;
404) out = '{"spawnable":false,"reason":"unsupported","message":"This Codeman has no local model support: it predates it."}' ;;
esac
[ -n " ${ out :- } " ] || out = $( jq -nc --arg c " $code " --arg t " $body " \
'{spawnable:false,reason:"error",status:$c,message:((($t | fromjson? | .error?) // $t)[0:300])}' )
printf '%s\n' " $out "
[ " $( jq -r .spawnable <<< " $out " ) " = true ]
}
# _local_ready_model -> local_model's JSON once a `local` worker can start, rc 1 with the
# reason on stderr otherwise. An installed model whose runtime is stopped is started first,
# with exactly what the local model window's Start button sends, and read again until it
# runs (bounded, about 30 s). ⚠️ That start also undoes a Stop the person made: the runtime
# starts with Codeman again from then on. Concurrent spawns may both send it; the server
# runs one runtime verb at a time and a second start keeps the runtime that runs.
_local_ready_model() {
local lm r i = 0
lm = $( local_model) || { jq -r '"no local model to spawn on (" + .reason + "): " + .message' <<< " $lm " >& 2; return 1; }
[ " $( jq -r .running <<< " $lm " ) " = true ] && { printf '%s\n' " $lm " ; return 0; }
r = $( " ${ CURL [@] } " -X POST " $API /api/v1/local-model/runtime" -H 'Content-Type: application/json' -d '{"action":"start"}' )
jq -e '.success == true' <<< " $r " >/dev/null 2>& 1 || {
echo "could not start the local model runtime: $( jq -r '.error // empty' <<< " $r " 2>/dev/null || printf '%s' " $r " | head -c 300) " >& 2
return 1; }
while :; do
lm = $( local_model) || { jq -r '"the local model runtime was started, but (" + .reason + "): " + .message' <<< " $lm " >& 2; return 1; }
[ " $( jq -r .running <<< " $lm " ) " = true ] && { printf '%s\n' " $lm " ; return 0; }
i = $(( i+1)) ; [ " $i " -lt 15 ] || { echo "the local model runtime did not report running within 30 s" >& 2; return 1; }
sleep 2
done
}
2026-08-14 14:46:23 +02:00
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
2026-08-25 04:14:45 +02:00
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
2026-11-08 07:48:08 +01:00
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer -- or a
# `local` worker (below) whose composer drew, which takes a prompt but has NO end-of-turn
# signal unless its harness is claude: drive that one with sendmark.
2026-08-25 04:14:45 +02:00
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
2026-11-08 07:48:08 +01:00
# 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.
2026-10-04 19:05:49 +02:00
# 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.
2026-08-14 14:46:23 +02:00
spawn_worker() {
2026-11-08 07:48:08 +01:00
local name = " ${ 1 :?spawn_worker needs a case name } " mode = " ${ 2 :- claude } " q sid cp r lm cm = '' mark = ''
if [ " $mode " = local ] ; then
lm = $( _local_ready_model) || return 1
mode = $( jq -r .cli <<< " $lm " )
cm = $( jq -c '{endpointId, modelId}' <<< " $lm " )
# Readiness is a token the agent's composer draws. The registry's composer data never
2026-11-08 08:47:26 +01:00
# 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
2026-11-08 08:50:24 +01:00
# on opencode 1.3.0 on Linux, fresh home, custom provider, the first `┃` came with the composer,
2026-11-08 08:47:26 +01:00
# 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.
2026-11-08 07:48:08 +01:00
[ " $mode " = opencode ] && mark = '┃'
mark = ${ LOCAL_READY_MARK :- $mark }
[ " $mode " = claude ] || [ -n " $mark " ] || {
echo "the local model runs through $mode , and this skill knows no composer mark for it: set LOCAL_READY_MARK to a space-free token its composer draws" >& 2
return 1; }
fi
2026-08-14 14:46:23 +02:00
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
2026-08-25 04:14:45 +02:00
# deepseek: ask for the same permission posture the Run button sends, because the
# harness's own default (`workspace-write`) still ASKS, and a worker that stops on
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# server clamps this back to `workspace-write` for an owner without the grant.
2026-11-08 07:48:08 +01:00
# 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.
2026-08-14 14:46:23 +02:00
q = $( " ${ CURL [@] } " -X POST " $API /api/v1/quick-start" -H 'Content-Type: application/json' \
2026-11-08 07:48:08 +01:00
-d " $( jq -nc --arg n " $name " --arg m " $mode " --arg p " $SELF " --arg a " ${ CODEMAN_WORKER_ADVISOR :- } " --arg cm " $cm " \
2026-08-25 04:14:45 +02:00
'{caseName:$n,mode:$m,parentSessionId:$p}
2026-10-04 19:05:49 +02:00
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)
2026-11-08 07:48:08 +01:00
+ (if $m == "claude" and $a != "" and $cm == "" then {advisorModel:$a} else {} end)
+ (if $cm != "" then {customModel:($cm | fromjson)} else {} end)' ) " )
# The answers that want a PERSON to decide come back as success with no session id.
if jq -e '(.data // .) | (.requiresContextWarning or .requiresConfirmation)' <<< " $q " >/dev/null 2>& 1; then
jq -r '"quick-start wants a person to confirm something, so no worker was started: " + ((.data // .) | tojson)' <<< " $q " >& 2
return 1
fi
2026-08-14 14:46:23 +02:00
sid = $( jq -r 'if .success then .data.sessionId else empty end' <<< " $q " )
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n " $sid " ] || { jq -c '{error,errorCode}' <<< " $q " >& 2; return 1; }
2026-10-04 19:05:49 +02:00
# A server without advisor support DROPS the field instead of refusing it, so read it
# back: a worker silently missing the advisor it was asked for is worth one line.
2026-11-08 07:48:08 +01:00
if [ " $mode " = claude ] && [ -z " $cm " ] && [ -n " ${ CODEMAN_WORKER_ADVISOR :- } " ] &&
2026-10-04 19:05:49 +02:00
[ " $( " ${ CURL [@] } " " $API /api/v1/sessions/ $sid " | jq -r '.data.advisorModel // empty' ) " != " $CODEMAN_WORKER_ADVISOR " ] ; then
echo "worker $sid : this Codeman server ignored CODEMAN_WORKER_ADVISOR (no advisor support); it runs without one" >& 2
fi
2026-11-08 07:48:08 +01:00
if [ -n " $cm " ] && [ " $mode " != claude ] ; then
# No trust dialog and no hook file to vet: the agent has no hooks, which is exactly
# why sendwait refuses it (400) and sendmark drives it.
r = $( _mark_up " $sid " " $mark " 45000)
[ " $r " = true ] || { echo "local worker $sid ( $mode ) never drew ' $mark ' within 45 s; deleted it. Look at GET /api/v1/local-model and the pane of a hand-made session before retrying" >& 2
delete_session " $sid " >/dev/null; return 1; }
printf '%s\n' " $sid " ; return 0
fi
2026-08-25 04:14:45 +02:00
if [ " $mode " = deepseek ] ; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
# Inbox all work here exactly as they do for claude. No hook file to vet
# (the bridge is env-injected, not a workspace file) and no trust dialog.
# ⚠️ Readiness is still not optional, and NOT interchangeable with the stop
# signal: the harness's boot report lands ~300ms BEFORE the composer paints
# (measured 2.26s vs 2.56s after spawn), so a sendwait fired straight after
# quick-start returns on that BOOT signal, reports a turn that never ran, and
# strands the prompt in a pane that was not yet taking input.
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; }
printf '%s\n' " $sid " ; return 0
fi
[ " $mode " = claude ] || { printf '%s\n' " $sid " ; return 0; } # no other mode draws a composer to wait on
2026-08-16 07:30:10 +02:00
# The server installs hooks into every claude workspace now, so this grep normally
# passes; it stays because the install is gated on a setting the operator can turn
# off, remote sessions never get hooks, and a session created by an older server
# still has none. No marker means sendwait would false-resolve on flapping idle,
# possibly inside the user's REAL repo: refuse rather than run the job there.
2026-08-14 14:46:23 +02:00
cp = $( jq -r '.data.casePath // empty' <<< " $q " )
grep -qs '/api/hook-event' " $cp /.claude/settings.local.json" || {
2026-08-16 07:30:10 +02:00
echo "case ' $name ' resolved to ' $cp ', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >& 2
2026-08-14 14:46:23 +02:00
delete_session " $sid " >/dev/null; return 1; }
2026-09-01 02:31:26 +02:00
# Short composer wait FIRST, then the trust dialog: a case still showing the
# dialog can never pass the composer wait, so acting early keeps a cold case from
2026-08-14 14:46:23 +02:00
# paying the whole long wait before the fallback even runs (§5.2). A warm case
2026-09-01 02:31:26 +02:00
# matches in under a second and never reaches it, and _accept_trust returns in a
# blink when there is no dialog, so this costs nothing in the ordinary slow case.
2026-08-14 14:46:23 +02:00
r = $( _composer_up " $sid " 5000)
if [ " $r " != true ] ; then
2026-09-01 02:31:26 +02:00
# Codeman answers this dialog itself and normally wins the race; this is the
# bounded fallback for when its 90 s window / 6-keystroke cap has run out.
_accept_trust " $sid "
2026-08-14 14:46:23 +02:00
r = $( _composer_up " $sid " 45000)
fi
[ " $r " = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >& 2
delete_session " $sid " >/dev/null; return 1; }
printf '%s\n' " $sid "
}
2026-08-25 04:14:45 +02:00
# spawn_workers <caseName[:mode]>... -> one "<caseName> <sessionId>" line per worker, in
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
2026-11-08 07:48:08 +01:00
# `beta:deepseek` makes that one a DeepSeek Harness worker, `summ:local` a worker on the
# local model, and a mixed fleet is one call. Case names must be UNIQUE: two workers in
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race
# (the mode never disambiguates two workers, since they would still share the directory).
2026-08-14 14:46:23 +02:00
spawn_workers() {
2026-08-25 04:14:45 +02:00
local d spec n m i = 0
2026-08-14 14:46:23 +02:00
[ " $# " -gt 0 ] || { echo "spawn_workers: no case names given" >& 2; return 1; }
2026-08-25 04:14:45 +02:00
[ -z " $( printf '%s\n' " $@ " | sed 's/:.*//' | sort | uniq -d) " ] || { echo "spawn_workers: duplicate case names" >& 2; return 1; }
2026-08-14 14:46:23 +02:00
d = $( mktemp -d " ${ TMPDIR :- /tmp } /codeman-spawn.XXXXXX" ) || return 1
2026-08-25 04:14:45 +02:00
for spec in " $@ " ; do
n = ${ spec %%:* } ; m = ${ spec #*: } ; [ " $m " = " $spec " ] && m = claude
( spawn_worker " $n " " $m " > " $d / $i " ) & i = $(( i+1))
done
2026-08-14 14:46:23 +02:00
wait
2026-08-25 04:14:45 +02:00
i = 0; for spec in " $@ " ; do printf '%s %s\n' " ${ spec %%:* } " " $( cat " $d / $i " 2>/dev/null) " ; i = $(( i+1)) ; done
2026-08-14 14:46:23 +02:00
rm -rf " $d "
}
# sendwait <sid> <prompt> [seq] -> blocks until that worker's turn ENDS (~10 min ceiling
# across its two waits). One billed turn. The \r and the per-worker clientId are applied
# here, which is why you never hand-build this body. seq defaults to the CURRENT EPOCH
# SECOND so that every new prompt is a new frame: the server drops any (clientId,seq)
# pair it has already applied, so a fixed default would make every later prompt to that
# worker a silent no-op that still "succeeds" and reports the previous turn's state.
# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a
# deliberate duplicate, at the SAME number (§5.3).
2026-09-19 11:38:59 +02:00
# Delivery is SELF-HEALING: the Enter can be lost (an Ink repaint eats it, and Claude
# Code 2.1.277+ ignores it outright for the first 30-50 s after the composer paints),
# leaving the typed prompt stranded on the composer while a long wait runs its whole
# timeout (observed live, twelve reviews in a row). So the first wait is short; on its
2026-09-19 11:49:02 +02:00
# timeout the ORIGINAL frame is resent unchanged as a long re-wait (a tagged duplicate:
# the server re-waits without retyping, §5.3) and kept open in the background, while
# the composer is READ (_composer_text) and, as long as the prompt is still sitting
# there, a bare \r goes out about every ten seconds, up to twelve times. An empty
# composer ends the loop, so a prompt that was taken is never nudged again, and the
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
2026-08-25 04:14:45 +02:00
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
2026-11-08 07:48:08 +01:00
# idle: markers instead (§5.5, sendmark below). ⚠️ A dsh worker running a profile that does not
2026-08-25 04:14:45 +02:00
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
2026-08-14 14:46:23 +02:00
sendwait() {
2026-09-19 11:49:02 +02:00
local sid = " ${ 1 :? } " p = " ${ 2 :? } " seq = " ${ 3 :-$( date +%s) } " body r c head n = 0 tmp bg i
2026-10-21 23:31:05 +02:00
# One line, like every other waiting sender (`toSingleLine`, `codeman agent send`,
# the TUI): the server pastes a multi-line prompt as one block, and the loop below,
# like the server's own submit check, cannot recognise a pasted block that is still
# on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for
# the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are
# joined with a space. For a long multi-line task, write a file and send its path.
case " $p " in *$'\n' *| *$'\r' *)
p = $( printf '%s\n' " $p " | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
2026-10-21 23:42:12 +02:00
[ -n " $p " ] || { echo "sendwait: empty prompt" >& 2; return 1; }
2026-08-25 04:14:45 +02:00
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
# is still answering, and the re-wait below then resolved in 0 ms with
# `signal:"idle"` on a turn that had another three minutes to run (measured).
# A wait named after the end of a turn should only end with the turn, or with
# the worker. ⚠️ This is also what makes a wrong mode LOUD: the modes that
# cannot deliver `stop` answer 400 (before writing anything) instead of
# resolving on a flap, which is the answer that sends you to markers (§5.5).
2026-08-14 14:46:23 +02:00
body = $( jq -nc --arg p " $p " --arg c " $CID - $sid " --argjson s " $seq " \
2026-08-25 04:14:45 +02:00
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"stop,exit",waitTimeout:20000}' )
2026-08-14 14:46:23 +02:00
r = $( " ${ CURL [@] } " -X POST " $API /api/v1/sessions/ $sid /input" \
-H 'Content-Type: application/json' --data-binary " $body " )
if jq -e '.data.delivered and .data.wait.timedOut' <<< " $r " >/dev/null 2>& 1; then
2026-09-19 11:49:02 +02:00
# ⚠️ The long re-wait is registered FIRST and stays open for the rest of this call,
# in the background, while the Enter loop below works the composer. Signals have
# no history: a `stop` that fires while no wait is open (during a composer read
# between two short waits, measured) is lost, and the next wait then runs its
# whole timeout on a turn that already ended. The resend is a tagged DUPLICATE,
# so the server skips the write and re-waits without retyping (§5.3).
tmp = $( mktemp " ${ TMPDIR :- /tmp } /codeman-wait.XXXXXX" ) || return 1
" ${ CURL [@] } " -X POST " $API /api/v1/sessions/ $sid /input" \
-H 'Content-Type: application/json' --data-binary " $( jq -c '.waitTimeout=580000' <<< " $body " ) " > " $tmp " &
bg = $!
2026-09-19 11:38:59 +02:00
# The prompt's head with whitespace removed, matched literally (the "$head"
# quoting inside ${c#...} keeps a * or ? in the prompt from acting as a glob).
head = $( printf '%s' " $p " | tr -d '[:space:]' | sed "s/ $( printf '\302\240' ) //g" | head -c 24)
2026-09-19 11:49:02 +02:00
while [ " $n " -lt 12 ] && [ ! -s " $tmp " ] ; do # a non-empty file means the wait ended
2026-09-19 11:38:59 +02:00
c = $( _composer_text " $sid " )
if [ " $c " = '?' ] ; then
[ " $n " -eq 0 ] || break # unreadable pane: one Enter, then trust it
elif [ -z " $head " ] || [ " ${ c # " $head " } " = " $c " ] ; then
break # composer empty (taken) or holding other text
fi
n = $(( n+1))
" ${ CURL [@] } " -X POST " $API /api/v1/sessions/ $sid /input" -H 'Content-Type: application/json' \
-d " $( jq -nc --arg c " $CID - $sid " --argjson s " $( date +%s) " \
'{input:"\r",useMux:true,clientId:$c,seq:$s}' ) " >/dev/null
2026-09-19 11:49:02 +02:00
i = 0; while [ " $i " -lt 10 ] && [ ! -s " $tmp " ] ; do sleep 1; i = $(( i+1)) ; done
2026-09-19 11:38:59 +02:00
done
2026-09-19 11:49:02 +02:00
wait " $bg "
# The duplicate reports `delivered:false` -- truthfully, but about the wrong send.
# The first one delivered, so carry that forward, or §1's cleanup reads a completed
# turn as an undelivered one and keeps a finished worker forever.
r = $( jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end' < " $tmp " )
rm -f " $tmp "
2026-08-14 14:46:23 +02:00
fi
printf '%s\n' " $r "
}
2026-11-08 07:48:08 +01:00
# sendmark <sid> <prompt> [timeoutSec] -> one JSON line, for an AGENT worker with no
2026-11-08 08:24:14 +01:00
# end-of-turn signal: a `local` worker whose agent is not claude, a hook-less claude, and
# every external agent mode but deepseek (a shell worker takes §5.5's echo form instead):
2026-11-08 07:48:08 +01:00
# {"token":…,"delivered":true,"matched":true,"answerFile":"<path>","answer":"<text>",…}
2026-11-08 08:47:26 +01:00
# rc 0: the answer file was read, after the marker or once the file held still (below).
# 2: neither within the timeout (default 600 s; the worker may still be working, so nothing
# is deleted). 3: the worker's pane ended. 4: the marker printed, but no answer file turned
# up within SENDMARK_FILE_WAIT seconds of it (default 120). 1: the send or a wait failed
# (`detail` says why).
2026-11-08 07:48:08 +01:00
# It adds ONE sentence to your prompt (joined onto one line, as sendwait does): write the
# complete answer to codeman-answer-<token>.md in the working directory, and only then end
# the reply with the word DONE immediately followed by _<token>. The marker is split in what
# is typed, so the prompt echoing into the pane never matches it, and the token is new for
# every call (§5.5). The send is sendwait's exactly-once body (per-worker clientId,
# epoch-second seq) with a one-second `exit` wait, so `delivered` says the pane took it.
# ⚠️ A marker proves that the model printed the token. Not that it did the work, not that
# it did it right, not even that the turn is over: a reasoning model can restate the
2026-11-08 08:47:26 +01:00
# instruction while it plans. That is why the answer FILE is the second gate. ⚠️ And a
# full-screen agent can print the marker without it ever matching: opencode redraws its
# spinner between the pieces a model streams, so the stripped stream reads D⬝⬝O⬝⬝N⬝⬝E_⬝⬝m…
2026-11-08 08:50:24 +01:00
# (measured on opencode 1.3.0 on Linux, a reply streamed a character or two at a time; the screen
2026-11-08 08:47:26 +01:00
# showed the whole token). So the file is also the way out WITHOUT the marker: present, not
# empty, and unchanged between two rounds of the marker wait (rounds of at most 10 s), it is
# read and `matched` says false. It is read from this machine's disk, only when it is a
# regular file (a symlink is refused), at most 256 KB of it. It stays in the worker's folder
# (§5.14).
2026-11-08 07:48:08 +01:00
# SENDMARK_ANSWER_FILE=0 asks for the marker alone, for a worker that cannot write where it
# runs (codex in a folder it runs read-only, an agent that asks before it writes a file, a
# worker on another host): rc 0 then means only "the marker printed", and the answer is
2026-11-08 08:47:26 +01:00
# yours to read (last_text for codex, §5.4). Without the file there is no way out but the
# marker, so on a full-screen agent that can be rc 2 for a reply that finished.
2026-11-08 07:48:08 +01:00
sendmark() {
2026-11-08 08:47:26 +01:00
local sid = " ${ 1 :? } " p = " ${ 2 :? } " t = " ${ 3 :- 600 } " tok mark file = '' wd r w = '' rem end start got = 0 ended = 0 matched = false i = 0 rc = 2 fp prev = ''
2026-11-08 07:48:08 +01:00
case " $p " in *$'\n' *| *$'\r' *)
p = $( printf '%s\n' " $p " | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;;
esac
[ -n " $p " ] || { echo "sendmark: empty prompt" >& 2; return 1; }
tok = $( printf 'm%x%04x' " $( date +%s) " " $RANDOM " ) ; mark = "DONE_ $tok "
if [ " ${ SENDMARK_ANSWER_FILE :- 1 } " != 0 ] ; then
wd = $( " ${ CURL [@] } " " $API /api/v1/sessions/ $sid " | jq -r '.data.workingDir // empty' 2>/dev/null)
[ -n " $wd " ] || { echo "sendmark: could not read the working directory of worker $sid " >& 2; return 1; }
file = " $wd /codeman-answer- $tok .md"
p = " $p (When you are done: write your complete answer to the file codeman-answer- $tok .md in your current working directory, and only after that file is written, end your reply with the word DONE immediately followed by _ $tok with no space in between.)"
else
p = " $p (When you are done, end your reply with the word DONE immediately followed by _ $tok with no space in between.)"
fi
r = $( " ${ CURL [@] } " -X POST " $API /api/v1/sessions/ $sid /input" -H 'Content-Type: application/json' \
--data-binary " $( jq -nc --arg p " $p " --arg c " $CID - $sid " --argjson s " $( date +%s) " \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"exit",waitTimeout:1000}' ) " )
if ! jq -e '.success and .data.delivered' <<< " $r " >/dev/null 2>& 1; then
jq -e '.data.wait.ended or .data.wait.signal == "exit"' <<< " $r " >/dev/null 2>& 1 && rc = 3 || rc = 1
jq -nc --arg t " $tok " --arg r " $r " '{token:$t,delivered:false,matched:false,answerFile:null,answer:null,detail:($r | fromjson? // $r)}'
return " $rc "
fi
start = $( date +%s) ; end = $(( start + t))
2026-11-08 08:47:26 +01:00
while :; do # bounded waits of at most 10 s each, until the timeout
2026-11-08 07:48:08 +01:00
rem = $(( end - $( date +%s))) ; [ " $rem " -ge 1 ] || break
2026-11-08 08:47:26 +01:00
[ " $rem " -le 10 ] || rem = 10
2026-11-08 07:48:08 +01:00
w = $( " ${ CURL [@] } " -G " $API /api/v1/sessions/ $sid /wait-output" --data-urlencode "match= $mark " \
--data-urlencode 'from=buffer' --data-urlencode "timeout= $(( rem * 1000 )) " )
jq -e '.data.wait.matched' <<< " $w " >/dev/null 2>& 1 && { matched = true; break; }
jq -e '.data.wait.ended' <<< " $w " >/dev/null 2>& 1 && { ended = 1; break; }
jq -e '.success' <<< " $w " >/dev/null 2>& 1 || {
jq -nc --arg t " $tok " --arg w " $w " '{token:$t,delivered:true,matched:false,answerFile:null,answer:null,detail:($w | fromjson? // $w)}'
return 1; }
2026-11-08 08:47:26 +01:00
# No marker this round. The answer file is the other way out (see above): it counts once
# it is a non-empty regular file whose checksum did not change since the last round.
if [ -n " $file " ] && [ ! -L " $file " ] && [ -f " $file " ] && [ -s " $file " ] ; then
fp = $( cksum < " $file " ) ; [ " $fp " = " $prev " ] && { got = 1; break; } ; prev = $fp
else
prev = ''
fi
2026-11-08 07:48:08 +01:00
done
if [ " $matched " = true ] && [ -n " $file " ] ; then
while :; do
[ -L " $file " ] && { echo "sendmark: $file is a symlink, not read" >& 2; break; }
[ -f " $file " ] && { got = 1; break; }
[ " $i " -lt " ${ SENDMARK_FILE_WAIT :- 120 } " ] || break
2026-11-08 08:47:26 +01:00
# Two seconds of waiting that also notices a worker that died: an exit wait answers at
# once then. A wait that did not answer (server gone) still costs its two seconds.
w = $( " ${ CURL [@] } " -G " $API /api/v1/sessions/ $sid /wait" --data-urlencode 'until=exit' --data-urlencode 'timeout=2000' )
jq -e '.data.wait.signal == "exit" or .data.wait.ended' <<< " $w " >/dev/null 2>& 1 && { ended = 1; break; }
jq -e '.success' <<< " $w " >/dev/null 2>& 1 || sleep 2
2026-11-08 07:48:08 +01:00
i = $(( i + 2 ))
done
fi
if [ " $got " = 1 ] ; then
2026-11-08 08:47:26 +01:00
head -c 262144 " $file " | jq -Rsc --arg t " $tok " --arg f " $file " --argjson m " $matched " \
--argjson s " $(($( date +%s) - start)) " --argjson c " $(($( wc -c < " $file " ))) " \
'{token:$t,delivered:true,matched:$m,answerFile:$f,answer:.,truncated:($c > 262144),waitedSec:$s}'
2026-11-08 07:48:08 +01:00
return 0
fi
jq -nc --arg t " $tok " --arg f " $file " --argjson m " $matched " --argjson e " $ended " --argjson s " $(($( date +%s) - start)) " \
'{token:$t,delivered:true,matched:$m,answerFile:(if $f == "" then null else $f end),answer:null,ended:($e == 1),waitedSec:$s}'
if [ " $matched " = true ] ; then
[ -z " $file " ] && return 0
[ " $ended " = 1 ] && return 3
return 4
fi
[ " $ended " = 1 ] && return 3
return 2
}
2026-08-25 04:14:45 +02:00
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
# "some text exists" is not "THIS turn's text exists": right after a SECOND turn on the same worker the endpoint still serves
2026-08-14 14:46:23 +02:00
# the previous answer for a beat (observed live). When reading consecutive turns, pass
# the previous answer as [prev]: the poll then holds out for text that differs from it,
# falling back to whatever it last saw if the budget runs dry, so an honestly repeated
# answer still comes back. Non-zero exit means the worker really never wrote one.
last_text() {
local t = "" prev = " ${ 2 :- } "
for _ in $( seq 1 15) ; do
2026-10-15 19:33:09 +02:00
t = $( " ${ CURL [@] } " " $API /api/v1/sessions/ ${ 1 } /last-response" | jq -r '.data.text // empty' )
2026-08-14 14:46:23 +02:00
[ -n " $t " ] && [ " $t " != " $prev " ] && { printf '%s\n' " $t " ; return 0; }
sleep 1
done
[ -n " $t " ] && { printf '%s\n' " $t " ; return 0; }
return 1
}
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
2026-11-08 07:48:08 +01:00
CODEMAN_PREAMBLE = 1.50.0