mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
endpoints.md named 1.18.x as the version where workspace hooks became a setting, but 1.18.x servers do NOT have this behavior — an agent driving one would falsely conclude its workspace has hooks. The feature ships in 1.19.0. And preamble.sh changed content this PR without bumping its CODEMAN_PREAMBLE stamp, so a cache stamped 1.18.3 would pass the staleness check while holding old bytes; stamp bumped to 1.19.0 in preamble.sh and the SKILL.md heredoc together (byte-identity pin). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
159 lines
10 KiB
Bash
159 lines
10 KiB
Bash
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
|
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}"
|
|
envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
|
|
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
|
|
# draw the lineage. Set once here and every present and future create call carries it;
|
|
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
|
|
# fail a spawn, so there is no case where you would want to leave it off.
|
|
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF")
|
|
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
|
|
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
|
|
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
|
|
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
|
|
}
|
|
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
|
|
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
|
|
# a READY claude worker in a hook-carrying case. 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.
|
|
spawn_worker() {
|
|
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
|
|
# 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.
|
|
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" '{caseName:$n,mode:$m,parentSessionId:$p}')")
|
|
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; }
|
|
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
|
|
# 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.
|
|
cp=$(jq -r '.data.casePath // empty' <<<"$q")
|
|
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
|
|
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
|
|
delete_session "$sid" >/dev/null; return 1; }
|
|
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
|
|
# dialog can never pass the composer wait, so probing early keeps a cold case from
|
|
# paying the whole long wait before the fallback even runs (§5.2). A warm case
|
|
# matches in under a second and never reaches the probe.
|
|
r=$(_composer_up "$sid" 5000)
|
|
if [ "$r" != true ]; then
|
|
if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \
|
|
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \
|
|
| jq -e '.data.wait.matched' >/dev/null; then
|
|
# Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback.
|
|
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
|
-d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null
|
|
fi
|
|
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"
|
|
}
|
|
# spawn_workers <caseName>... -> 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. 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.
|
|
spawn_workers() {
|
|
local d n i=0
|
|
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
|
|
[ -z "$(printf '%s\n' "$@" | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; }
|
|
d=$(mktemp -d "${TMPDIR:-/tmp}/codeman-spawn.XXXXXX") || return 1
|
|
for n in "$@"; do ( spawn_worker "$n" > "$d/$i" ) & i=$((i+1)); done
|
|
wait
|
|
i=0; for n in "$@"; do printf '%s %s\n' "$n" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done
|
|
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).
|
|
# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the
|
|
# typed prompt stranded on the composer while a long wait runs its whole timeout
|
|
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
|
|
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
|
|
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
|
|
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy only for a claude
|
|
# worker spawn_worker handed back (hooks vetted); hook-less workspaces and other modes
|
|
# resolve on flapping idle: markers instead (§5.5).
|
|
sendwait() {
|
|
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
|
|
body=$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$seq" \
|
|
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:20000}')
|
|
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
|
|
"${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
|
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
|
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")")
|
|
fi
|
|
printf '%s\n' "$r"
|
|
}
|
|
# last_text <sid> [prev] -> that worker's last assistant message. 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
|
|
# 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
|
|
t=$("${CURL[@]}" "$API/api/v1/sessions/$1/last-response" | jq -r '.data.text // empty')
|
|
[ -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.
|
|
CODEMAN_PREAMBLE=1.19.0
|