mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(skill): spawn and drive DeepSeek Harness workers
The agent skill could spawn a worker in any mode, but it could only
DRIVE a claude one: every other CLI has neither a real end-of-turn
signal nor an answer to read, so the recipes route them through output
markers.
dsh has both halves now -- its harness reports idle/working/blocked to
Codeman, and the previous commit reads its transcript -- so it joins
claude as a mode the four verbs work on unchanged. `spawn_workers alpha
beta:deepseek` is a mixed fleet in one call, and `sendwait` / `last_text`
/ `delete_session` need no per-mode variant.
Preamble 1.20.0 (SKILL.md's §0 heredoc regenerated from it):
- `spawn_worker` grows a deepseek branch that gates on the harness
composer. ⚠️ Readiness there is NOT the stop signal: the harness
reports idle at BOOT ~300 ms before its composer paints (measured
2.26 s vs 2.56 s after spawn), so a send-and-wait fired straight after
quick-start resolves on the boot edge, reports a turn that never ran,
and strands the prompt in a pane not yet taking input. Waiting for the
composer also spends that edge, since signals are edge-triggered.
- `spawn_workers` takes `name[:mode]`, so a mixed fleet stays one
concurrent call. Case names still have to be unique -- the mode never
disambiguates two workers that would share a directory.
- `sendwait` asks for `wait:"stop,exit"` instead of the `wait:true`
default set. That set also carries `idle`, which for an external CLI is
inferred from output stabilization: on a dsh worker whose TUI repaints
rarely, the re-wait resolved in 0 ms with `signal:"idle"` on a turn
with three minutes left to run. It also makes a wrong mode loud -- the
modes that cannot deliver `stop` answer 400 before writing anything,
instead of resolving on a flap.
- The self-heal resend carries `delivered:true` forward. The resend is a
tagged duplicate, so the server truthfully reports `delivered:false`
about a write it skipped, and §1's cleanup then read a completed turn
as an undelivered one and kept a finished worker forever.
- dsh workers spawn with the permission posture the Run button sends,
because the harness default still asks and a worker parked on an
approval row cannot finish a fan-out. The multi-user clamp still
applies.
Docs: a worked dsh flow in recipes.md, readiness and the signal rules in
verbs.md, and the corrections this makes necessary -- `stop`/`blocked`
are no longer claude-only, and `last-response` is no longer permanently
empty for deepseek. The integration guide gains a section on reading a
session back and driving one as a worker; its web-UI section was also
stale (that server moved out of a shell session).
The static guard that keeps those lists from naming some external CLIs but
not others is extended rather than exempted: it now knows the three real
classes inside that family (no transcript, no hook signals, and the
positive twin -- the modes whose answers can be read), with the hook class
derived from `hooksAvailableForMode()` so the predicate and the prose
cannot drift apart. Any other partial list still fails, and a new backend
belongs to none of the classes until someone says so.
This commit is contained in:
@@ -32,6 +32,10 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
⚠️ **`agent_working` is a hook event with no Claude Code hook behind it** (157th SSE constant). It exists because a harness turn cannot run while one of its own modal approvals is on screen, so "the agent started working" proves a dialog was answered in the terminal. It joins `APPROVAL_RESOLVING_EVENTS`; without it a dsh session's red alert would survive until the next `stop`, the exact stuck-alert bug the claude path already had to fix once — and the pane-capture staleness sweep that fixed it there is Claude-dialog-shaped and cannot help here.
|
||||
|
||||
⚠️ **Answers are READ FROM DISK for this mode, not scraped off the pane** (`src/deepseek-transcript.ts`, behind `GET /api/sessions/:id/last-response`). dsh writes a structured JSONL transcript at `$DSH_HOME/sessions/<mangled-cwd>/<dsh-session-id>/session.jsonl.zstd`, so it belongs with claude and codex rather than with the pane-segmented modes — and for dsh specifically the segmenter was not merely coarse but WRONG: dsh-TUI paints a full-screen splash, so a `last-response` call on a fresh dsh session returned its ASCII-art logo, which anything polling for a worker's first answer reads as an answer. Four mechanisms in that file are load-bearing. **(1)** dsh appends **one zstd FRAME per write**, and Node's `zlib` zstd decoder — one-shot AND streaming — stops at the first frame end: a real 56-line transcript decoded as 1 line / 158 bytes, i.e. the session header alone, so every call would have reported "nothing said yet" forever. `zstdFrameRanges()` walks frame and block headers (no decompression) to find exact boundaries and decompresses each frame; splitting on the 4-byte magic instead would corrupt everything after a magic sequence that happens to occur inside compressed data. zstd itself is resolved at RUNTIME (`zstdSupported()`), because it landed in Node 22.15 while the project floor is 22.0 and `@types/node` still does not declare it; without it the mode falls back to the pane exactly as before, which is why `readDeepSeekLastResponse()` distinguishes `null` ("this reader cannot run here") from an empty result ("read fine, nothing said yet"). **(2)** Every turn also records a plugin-sourced `user/message` (dsh's runtime-context snapshot: sandbox policy, approval policy, cwd), so only `source.kind === 'user'` is a real prompt. **(3)** A turn that ends in `reason.kind: 'error'` is surfaced as `Turn error: …` (and a non-error early stop such as `max-tokens` as `Turn ended: …`) rather than as an empty string, which an agent reads as "still thinking" through fifteen polls. **(4)** Reply text is assembled per (turn, step): a finalized `assistant/message` wins, and the streamed `assistant/chunk` / `text-chunks` deltas are consulted ONLY for a step that never finalized (so a partial answer is readable mid-turn without ever being appended twice) — ⚠️ and "finalized" is tracked as a SET of steps, not as non-empty text, because a step whose whole reply was reasoning strips to `''` at the `</think>` boundary and would otherwise resurrect the raw, unstripped deltas in its place (measured on a real conversation). ⚠️ Session→transcript pairing is by the transcript's own header `cwd` plus a ±60 s boot window against the Codeman session's `createdAt`, never by reproducing dsh's directory mangling (which already has two forms on disk, `<uuid>` and `session-<uuid>`) and never by newest-mtime alone: mtime alone handed a freshly spawned worker its PREDECESSOR's answer in the same case directory, which is worse than saying nothing because an agent cannot tell a stale answer from a fresh one. The `DSH_HOME` override reaches the reader through the narrow `Session.deepSeekHomeOverride` getter rather than an `envOverrides` accessor, since that map can hold provider credentials; it is ephemeral by design (never persisted), so a session that overrode it and outlived a server restart resolves the default tree and reads as "nothing said yet". Tests: `test/deepseek-transcript.test.ts`.
|
||||
|
||||
⚠️ **This is also what makes dsh the one non-claude mode the `codeman` agent skill drives like claude** (`skills/codeman/preamble.sh`, preamble 1.20.0): with a real end-of-turn signal AND a real transcript, `spawn_workers alpha beta:deepseek` is a mixed fleet in one call and `sendwait`/`last_text` need no per-mode variant. Two traps are handled in the preamble rather than left to the agent. **Readiness is not the stop signal**: the harness reports `idle` at BOOT roughly 300 ms before its composer paints (measured 2.26 s vs 2.56 s after spawn, twice), so a send-and-wait fired straight after `quick-start` resolves on the boot edge, reports a turn that never ran, and strands the prompt in a pane that was not yet accepting input — `spawn_worker`'s dsh branch gates on the composer glyph (`❯`, overridable via `DSH_READY_MARK`) instead, after which the boot edge is spent and unobservable. And `sendwait` asks for `wait:"stop,exit"` rather than the `wait:true` default set, because that set also carries `idle`, which for any external CLI is inferred from output stabilization: on a dsh worker whose TUI repaints rarely, a re-wait resolved in 0 ms with `signal:"idle"` on a turn that had three minutes left to run. The skill also sends `deepSeekConfig.permissionMode: 'danger-full-access'` for its own workers, matching what the Run button sends, because the harness default still asks and a worker parked on an approval row cannot finish a fan-out (the multi-user clamp still applies).
|
||||
|
||||
⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity.
|
||||
|
||||
Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`.
|
||||
|
||||
@@ -14,7 +14,7 @@ terminal agent:
|
||||
|
||||
| Profile | What it is | Can Codeman run it in a tab? |
|
||||
| ------------ | --------------------------------- | ---------------------------- |
|
||||
| `web` | the browser UI, served on :3080 | no — but see §5 |
|
||||
| `web` | the browser UI, served on :3080 | no — but see §6 |
|
||||
| `headless` | answers one task and exits | no |
|
||||
| (`base`) | the shared core, no app at all | no |
|
||||
|
||||
@@ -199,21 +199,66 @@ Codeman's allowlist is global, so admitting them would widen it for every mode a
|
||||
once. Authenticate those the way dsh does, from the file or the server's own
|
||||
environment.
|
||||
|
||||
## 5. The web UI as a tab
|
||||
## 5. Reading a session back, and driving one as a worker
|
||||
|
||||
dsh writes a real transcript — `$DSH_HOME/sessions/<mangled-cwd>/<id>/session.jsonl.zstd`
|
||||
— so `GET /api/sessions/:id/last-response` reads that rather than segmenting the
|
||||
pane, and the Response Viewer shows a dsh conversation the way it shows a claude
|
||||
or codex one (`?context=full` returns prompt / response / tool blocks).
|
||||
|
||||
Reading the pane instead is not merely coarse for this mode, it is wrong: dsh-TUI
|
||||
paints a full-screen splash, so the segmenter answered a `last-response` call for
|
||||
a fresh dsh session with its ASCII-art logo — which anything polling for a
|
||||
worker's first answer reads as an answer. Three things about the file shaped the
|
||||
reader (`src/deepseek-transcript.ts`):
|
||||
|
||||
- **It is one zstd FRAME per append, not one zstd stream.** `zstd -dc` decodes all
|
||||
of them, Node's `zlib` zstd decoder stops at the first: a real 56-line
|
||||
transcript came back as 1 line. The reader walks frame headers itself. On a Node
|
||||
older than 22.15 (no zstd at all) the mode falls back to the pane, as before.
|
||||
- **Not every `user/message` is the user.** Each turn also records a
|
||||
plugin-sourced runtime-context snapshot; only `source.kind === 'user'` is a
|
||||
prompt.
|
||||
- **A failed turn is not an empty one.** `turn/end` carries the provider's error,
|
||||
which is returned as `Turn error: …` (and an early stop such as `max-tokens` as
|
||||
`Turn ended: …`) instead of an empty string that reads as "still thinking".
|
||||
|
||||
### As an agent worker
|
||||
|
||||
Because dsh has both halves — a real end-of-turn signal and a real transcript — an
|
||||
agent can drive a dsh session the same way it drives a claude one, and the bundled
|
||||
`codeman` agent skill does. Spawning `beta:deepseek` in its worker list gives a
|
||||
worker that is tasked, waited on and read with the same calls as its claude
|
||||
siblings; no other external CLI mode qualifies. Two edges are worth repeating here:
|
||||
|
||||
- **Readiness is not the stop signal.** The harness reports `idle` at boot roughly
|
||||
300 ms *before* the composer paints (measured 2.26 s vs 2.56 s after spawn), so a
|
||||
send-and-wait fired immediately after create resolves on that boot report,
|
||||
reports a turn that never ran, and leaves the prompt in a pane that was not yet
|
||||
accepting input. Wait for the composer (`❯`) instead.
|
||||
- **Wait on `stop`, not on the default signal set.** That set also carries `idle`,
|
||||
which for every external CLI is inferred from output stabilization; a dsh TUI
|
||||
that repaints rarely reads as idle mid-turn.
|
||||
|
||||
## 6. The web UI as a tab
|
||||
|
||||
The browser UI is the one interactive surface DeepSeek ships itself, so it gets a
|
||||
shortcut rather than a run mode: **Run ▸ DeepSeek web UI…** starts
|
||||
`dsh web --no-open --host 127.0.0.1 --port 3080 --trusted-host <codeman-host>` in
|
||||
an ordinary shell session and opens `http://127.0.0.1:3080` as a Codeman web tab.
|
||||
`dsh web --no-open --host 127.0.0.1 --port <free> --trusted-host <codeman-host>`
|
||||
as a background child process (`src/deepseek-web-server.ts`, behind
|
||||
`POST/GET/DELETE /api/deepseek/web`) and opens it as a Codeman web tab once the
|
||||
server actually answers.
|
||||
|
||||
Nothing bespoke supervises it: the server is a normal shell session (visible,
|
||||
scrollable, killable, dies with its tab) and the UI is a normal web tab. The
|
||||
`--trusted-host` flag is load-bearing — dsh fences its `/api` behind a
|
||||
browser-trust check on the request authority, and a Codeman web tab reaches it
|
||||
through Codeman's own origin via the webview proxy, not directly. Without it the
|
||||
page renders and every API call fails.
|
||||
It is a child process rather than a shell session because the session version
|
||||
opened a terminal tab nobody asked for on every click. What the session gave for
|
||||
free is therefore explicit here: one instance with reuse, a restart when the
|
||||
requested `--trusted-host` authority differs from the running one, a kill on
|
||||
server stop, and captured boot output. The `--trusted-host` flag is load-bearing —
|
||||
dsh fences its `/api` behind a browser-trust check on the request authority, and a
|
||||
Codeman web tab reaches it through Codeman's own origin via the webview proxy, not
|
||||
directly. Without it the page renders and every API call fails.
|
||||
|
||||
## 6. Docker and remote cases
|
||||
## 7. Docker and remote cases
|
||||
|
||||
Docker cases work: the agent image installs `dsh` and bootstraps a `dsh-tui`
|
||||
profile into the container. Profiles are deliberately **not** seeded from the
|
||||
@@ -227,7 +272,7 @@ Remote SSH cases default to `dsh` through a login shell, which boots the remote
|
||||
box's default profile. If the remote has several, name one with the per-host
|
||||
`commands.deepseek` override — the local `deepSeekConfig` does not cross ssh.
|
||||
|
||||
## 7. What is not wired
|
||||
## 8. What is not wired
|
||||
|
||||
Deliberately minimal, on the same reasoning as the grok integration: the harness
|
||||
is a fast-moving developer preview and every flag added is a flag validated
|
||||
@@ -237,9 +282,6 @@ forever.
|
||||
- `dsh plugin` management beyond first-time profile install.
|
||||
- The `headless` profile as a one-shot execution backend for Codeman's own
|
||||
internal AI checks (today those are Claude-only).
|
||||
- Reading `~/.dsh/sessions/**` into the response viewer, the way codex rollouts
|
||||
are read back. DeepSeek sessions are JSONL and this is very achievable; it is
|
||||
the highest-value follow-up.
|
||||
- Model/provider selection from Session Options.
|
||||
|
||||
## Verified against
|
||||
|
||||
+121
-34
@@ -47,7 +47,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.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.20.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
|
||||
@@ -75,8 +75,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.19.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
grep -qs '^CODEMAN_PREAMBLE=1.20.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||
# ---- Codeman agent preamble 1.20.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
|
||||
@@ -121,23 +121,53 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
|
||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
|
||||
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
# 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.
|
||||
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
|
||||
# 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.
|
||||
# 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.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
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}')")
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
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
|
||||
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
|
||||
# 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
|
||||
@@ -166,19 +196,25 @@ spawn_worker() {
|
||||
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 <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;
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
|
||||
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
|
||||
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
|
||||
# workers, since they would still share the directory).
|
||||
spawn_workers() {
|
||||
local d n i=0
|
||||
local d spec n m 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; }
|
||||
[ -z "$(printf '%s\n' "$@" | sed 's/:.*//' | 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
|
||||
for spec in "$@"; do
|
||||
n=${spec%%:*}; m=${spec#*:}; [ "$m" = "$spec" ] && m=claude
|
||||
( spawn_worker "$n" "$m" > "$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
|
||||
i=0; for spec in "$@"; do printf '%s %s\n' "${spec%%:*}" "$(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
|
||||
@@ -194,27 +230,46 @@ spawn_workers() {
|
||||
# (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).
|
||||
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
|
||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||
# 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.
|
||||
sendwait() {
|
||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
|
||||
# `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).
|
||||
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}')
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"stop,exit",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
|
||||
# The resend is a tagged DUPLICATE, so the server skips the write and reports
|
||||
# `delivered:false` for it -- 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=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")")
|
||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
||||
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
@@ -233,10 +288,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.19.0
|
||||
CODEMAN_PREAMBLE=1.20.0
|
||||
PREAMBLE
|
||||
)
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.20.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
|
||||
@@ -287,8 +342,9 @@ 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.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||
[ "${CODEMAN_PREAMBLE:-}" = 1.20.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'
|
||||
'reply with one line: your model name') # tasks, same order as N
|
||||
|
||||
@@ -353,6 +409,37 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||
- Deleting the sessions does **not** remove the case directories: §5.14.
|
||||
|
||||
### DeepSeek Harness workers
|
||||
|
||||
The block above spawns claude workers. Any entry in `N` may instead name a mode
|
||||
(`beta:deepseek`), and **a `deepseek` worker is driven by the same four verbs, with no
|
||||
change to the rest of the block**: `spawn_workers` waits for its composer, `sendwait`
|
||||
blocks on its real end-of-turn signal, `last_text` reads its answer, `delete_session`
|
||||
removes it.
|
||||
|
||||
That is true of no other non-claude mode, and it is worth knowing why: the DeepSeek
|
||||
Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor contract it
|
||||
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
|
||||
instead of guessed-from-silence ones — and it writes a structured transcript, which is
|
||||
what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`,
|
||||
`pi` and `grok` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
|
||||
|
||||
Three things to know before you spawn one:
|
||||
|
||||
- **It needs a pane-capable profile.** `dsh` ships only `web`/`headless`, so the terminal
|
||||
agent is always an installed profile. `GET /api/v1/deepseek/status` answers both
|
||||
questions separately (`available` = the binary, `runnable` = a profile that can drive a
|
||||
pane); a spawn without one fails with `OPERATION_FAILED` rather than falling back.
|
||||
- **Do not task it on the strength of a `stop` alone.** The harness reports `idle` at
|
||||
boot ~300 ms *before* its composer paints (measured 2.26 s vs 2.56 s), so a `sendwait`
|
||||
fired straight after `quick-start` resolves on that boot signal, reports a turn that
|
||||
never ran, and leaves the prompt in a pane that was not yet taking input. Letting
|
||||
`spawn_worker` gate on readiness is what steps past that edge; it is not optional.
|
||||
- **A profile that does not implement the contract looks like a hang.** Codeman cannot
|
||||
know at spawn time whether one does. The tell is a `sendwait` that times out on a
|
||||
worker whose pane clearly finished: that profile is one of them, so drive it with
|
||||
markers instead.
|
||||
|
||||
## 2. What do you want to do?
|
||||
|
||||
One row per job. Acting on this table alone is correct; the §5 links are the detail.
|
||||
@@ -360,10 +447,10 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
|
||||
| I want to | Call | Detail |
|
||||
|-----------|------|--------|
|
||||
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
|
||||
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`) | [§5.2](reference/verbs.md#52-readiness) |
|
||||
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy only where the workspace **has hooks** (claude mode; installed by default, but the operator can disable it and remote sessions never get them). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
|
||||
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`); a `deepseek` worker draws `❯` instead, and its boot `stop` fires ~300 ms BEFORE that, so never read the signal as readiness | [§5.2](reference/verbs.md#52-readiness) |
|
||||
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy where the signal is real: claude mode with hooks (installed by default, but the operator can disable it and remote sessions never get them) and `deepseek` mode through its status bridge. Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
|
||||
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
|
||||
| read the answer | `GET .../last-response`, **polled** (claude/codex only; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
|
||||
| read the answer | `GET .../last-response`, **polled** (claude, codex and deepseek write a transcript; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
|
||||
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
|
||||
| make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) |
|
||||
@@ -464,7 +551,7 @@ these**; open the one row you actually hit.
|
||||
| [5.1 Where to spawn](reference/verbs.md#51-where-to-spawn) | the work is **not** a fresh scratch case: a linked case, a git worktree, any path that already existed. Hooks are absent there, which silently breaks send-and-wait. The costliest mistake in this skill |
|
||||
| [5.2 Readiness](reference/verbs.md#52-readiness) | a worker never drew its composer, or you need the trust-dialog ladder by hand |
|
||||
| [5.3 Send a task and wait](reference/verbs.md#53-send-a-task-and-wait) | the `sendwait` body, its signals, and the duplicate-resend loop |
|
||||
| [5.4 Read the answer](reference/verbs.md#54-read-the-answer) | `last_text` came back empty, or the mode is not claude/codex |
|
||||
| [5.4 Read the answer](reference/verbs.md#54-read-the-answer) | `last_text` came back empty, or the mode is not claude/codex/deepseek |
|
||||
| [5.5 Markers for hook-less workers](reference/verbs.md#55-markers-for-hook-less-workers) | the worker has no `stop` hook: synchronize on a split, unique printed marker |
|
||||
| [5.6 Alive and stuck](reference/verbs.md#56-alive-and-stuck) | is it dead or just slow? `status` and `pid` both lie |
|
||||
| [5.7 Interrupt without destroying](reference/verbs.md#57-interrupt-without-destroying) | a runaway worker you want to stop but keep |
|
||||
|
||||
+81
-26
@@ -1,4 +1,4 @@
|
||||
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||
# ---- Codeman agent preamble 1.20.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
|
||||
@@ -43,23 +43,53 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
|
||||
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
|
||||
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
|
||||
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
|
||||
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
|
||||
}
|
||||
# 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.
|
||||
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
|
||||
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
|
||||
# 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.
|
||||
# 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.
|
||||
# Spawn by hand (§5.1) when you want a worker that asks.
|
||||
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}')")
|
||||
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
|
||||
'{caseName:$n,mode:$m,parentSessionId:$p}
|
||||
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
|
||||
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
|
||||
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
|
||||
# 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
|
||||
@@ -88,19 +118,25 @@ spawn_worker() {
|
||||
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 <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;
|
||||
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
|
||||
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
|
||||
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
|
||||
# workers, since they would still share the directory).
|
||||
spawn_workers() {
|
||||
local d n i=0
|
||||
local d spec n m 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; }
|
||||
[ -z "$(printf '%s\n' "$@" | sed 's/:.*//' | 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
|
||||
for spec in "$@"; do
|
||||
n=${spec%%:*}; m=${spec#*:}; [ "$m" = "$spec" ] && m=claude
|
||||
( spawn_worker "$n" "$m" > "$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
|
||||
i=0; for spec in "$@"; do printf '%s %s\n' "${spec%%:*}" "$(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
|
||||
@@ -116,27 +152,46 @@ spawn_workers() {
|
||||
# (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).
|
||||
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
|
||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||
# 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.
|
||||
sendwait() {
|
||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
|
||||
# `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).
|
||||
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}')
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"stop,exit",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
|
||||
# The resend is a tagged DUPLICATE, so the server skips the write and reports
|
||||
# `delivered:false` for it -- 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=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")")
|
||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
||||
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
@@ -155,4 +210,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.19.0
|
||||
CODEMAN_PREAMBLE=1.20.0
|
||||
|
||||
@@ -237,7 +237,10 @@ minutes, never retry the credential.
|
||||
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
||||
returns is too early (verified live: empty on the first call, full prose seconds later).
|
||||
It is also `""` before the worker's first completed turn, and permanently `""` for
|
||||
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `deepseek`, which write no Claude transcript.
|
||||
`shell`, `opencode`, `gemini`, `antigravity`, `pi` and `grok`, which write no transcript at
|
||||
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
|
||||
for the same reason claude does (the harness finalizes the assistant message just after
|
||||
it reports `idle`), so poll it the same way.
|
||||
|
||||
**Fix** Poll it, bounded (10 tries, 1 s apart). If it is still empty on a hook-less mode,
|
||||
that is expected, not a failure: read `terminal?tail=` and strip ANSI instead.
|
||||
@@ -279,7 +282,7 @@ than into an existing checkout.
|
||||
| start case + session in one call | `POST /api/v1/quick-start` |
|
||||
| create a session in an arbitrary directory (no case, **no PTY**, id at `.data.session.id`) | `POST /api/v1/sessions`, then `POST /api/v1/sessions/:id/interactive` or `.../shell` to start it, see [Starting a worker](#starting-a-worker) |
|
||||
| send input | `POST /api/v1/sessions/:id/input` |
|
||||
| **read a worker's answer** (claude/codex) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}`, clean transcript text, no TUI noise. ⚠️ **Poll it**, see [symptom 7](#7-last-response-returns-an-empty-string-right-after-stop) |
|
||||
| **read a worker's answer** (claude/codex/deepseek) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}`, clean transcript text, no TUI noise. ⚠️ **Poll it**, see [symptom 7](#7-last-response-returns-an-empty-string-right-after-stop) |
|
||||
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer`, for *diagnosis* (unsubmitted prompt?), not for reading answers |
|
||||
| full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` |
|
||||
| background agents, one session | `GET /api/v1/sessions/:id/subagents` |
|
||||
@@ -321,7 +324,7 @@ on signals and markers for exactly this reason.
|
||||
⚠️ `GET /api/v1/sessions/:id/output` → `.data.textOutput` looks like the obvious read
|
||||
but stays **empty for interactive tmux-backed sessions** (it is fed only by the legacy
|
||||
JSON-stream path). Verified empty on live claude and shell sessions. Use
|
||||
`last-response` for claude/codex answers; only fall back to `terminal?tail=` for
|
||||
`last-response` for claude/codex/deepseek answers; only fall back to `terminal?tail=` for
|
||||
hook-less modes, or to diagnose a prompt that was never submitted, and strip ANSI:
|
||||
|
||||
```bash
|
||||
@@ -640,10 +643,14 @@ block, so a linked case or a raw `workingDir` had no hooks at all. `POST
|
||||
session-create path installs hooks regardless of how the directory got there. See
|
||||
[symptom 8](#8-send-and-wait-resolves-instantly-with-signalidle-and-the-answer-is-last-turns).
|
||||
|
||||
Default `until` set: `stop,idle,exit`. On non-claude modes the server silently drops
|
||||
`stop`/`blocked` from the *default* set (echoed back as `wait.until`, e.g.
|
||||
Default `until` set: `stop,idle,exit`. On modes with no hook signals the server silently
|
||||
drops `stop`/`blocked` from the *default* set (echoed back as `wait.until`, e.g.
|
||||
`["idle","exit"]` on shell); requesting them *explicitly* there is a 400 naming the
|
||||
mode. ⚠️ That 400 is about **mode**, so a hooks-less *claude* session accepts
|
||||
mode. ⚠️ `deepseek` is not one of those: its harness reports its own lifecycle, so it
|
||||
keeps the full default set and accepts an explicit `until=stop`. ⚠️ For dsh the answer is
|
||||
per-SESSION rather than per-mode — a session created with `statusReporting: false` has no
|
||||
bridge, and an explicit `until=stop` there is a 400 naming that setting. ⚠️ That 400 is
|
||||
otherwise about **mode**, so a hooks-less *claude* session accepts
|
||||
`until=stop` happily and then never resolves it. ⚠️ On hook-less modes the lifecycle
|
||||
signals are also **coarse in practice**: a
|
||||
short shell command produced **no** `idle` transition within 60 s (verified live), so
|
||||
@@ -791,7 +798,8 @@ for environment and setup problems.
|
||||
| `CODEMAN_MUX` unset but you seem to be in a session | remote-SSH case: the env vars are not exported there. Fail closed, refuse to act |
|
||||
| connection refused from inside a container | a loopback-bound server is unreachable from a container, and `CODEMAN_DOCKER_BRIDGE_HOOKS=1` does **not** fix that: it opens a hooks-only listener, so hook events start flowing but `/api/v1/*` stays refused. Driving the API from inside a Docker case needs a reachable bind (an operator decision); report it, don't retry |
|
||||
| wait routes 404 on a valid session id | read the `.error` text: a `Route ...` prefix means the server predates the wait endpoints (< 1.13.0; a dev build can serve them while reporting an older version, so probe, never version-compare), poll `terminal?tail=` and say so. `Session ... not found` means your id is wrong, not the server |
|
||||
| wait on `stop` never resolves | non-claude mode, or hooks not reaching the server (Docker/remote), or a case created by Codeman < 1.13.0 against an `--https` install (its hook curls lacked `-k` and TLS-failed silently; a 1.13.0+ server rewrites them the next time a session starts in that case). Use markers or `idle,exit` |
|
||||
| wait on `stop` never resolves | a mode with no hook signals, or hooks not reaching the server (Docker/remote), or a case created by Codeman < 1.13.0 against an `--https` install (its hook curls lacked `-k` and TLS-failed silently; a 1.13.0+ server rewrites them the next time a session starts in that case). Use markers or `idle,exit` |
|
||||
| wait on `stop` never resolves, on a **dsh** worker whose pane clearly finished | that profile does not implement the harness's supervisor contract, which Codeman cannot detect at request time (an unrecognized profile is treated as launchable on purpose). The wait is accepted and then times out. Drive that worker with markers, or switch to a profile that reports — `@deepseek-harness-tui/dsh-tui` does |
|
||||
| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept did not fire (it is bounded by a 90 s window and an attempt cap); use the readiness recipe in SKILL.md, wait for `shift+tab` first, accept the dialog only as the bounded fallback |
|
||||
| readiness burns its whole budget, then the worker answers fine anyway | you matched `bypass`, which is the statusline of ONE permission mode. Codeman spawns `--dangerously-skip-permissions` by default, but the server's `claudeMode` setting also has `auto` (`auto mode on`), `allowedTools` and `normal` (both `don't ask on`), and the effective per-session value is not exposed on `GET /api/v1/sessions/:id`. Match **`shift+tab`** instead: every mode's status bar ends `(shift+tab to cycle)` (measured per mode against claude-cli 2.1.226). Expect `blocked` signals mid-turn on the non-default modes |
|
||||
| ANSI escapes survive the strip pipeline | `sed -e 's/\x1b…'` on macOS: `\x1b` is GNU-only, BSD sed matches nothing and strips nothing. Use the `ESC=$(printf '\033')` form above |
|
||||
|
||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||
# always "" for shell/opencode/gemini/antigravity/pi/grok/deepseek, which have no transcript, use
|
||||
# always "" for shell/opencode/gemini/antigravity/pi/grok, which have no transcript, use
|
||||
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
||||
|
||||
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
||||
@@ -198,6 +198,59 @@ delete_session "$SID"
|
||||
Increment `SEQ` for every *new* input to the same worker. Reuse the same `SEQ` only to
|
||||
re-ask about the same delivery (the duplicate-wait loop above).
|
||||
|
||||
## Flow 1b: DeepSeek Harness worker, end to end
|
||||
|
||||
A `deepseek` worker is driven with the same four verbs as a claude one, because the
|
||||
harness reports its own lifecycle: its `stop` is a real end-of-turn signal, and its
|
||||
answer comes from a real transcript. The differences are all at the edges.
|
||||
|
||||
```bash
|
||||
# 0. Is there anything to spawn? `available` is the binary, `runnable` is a profile
|
||||
# that can drive a pane -- dsh ships only web/headless, so the two differ.
|
||||
"${CURL[@]}" "$API/api/v1/deepseek/status" | jq -c '{available:.data.available,runnable:.data.runnable,profile:.data.defaultProfile}'
|
||||
|
||||
# 1. Spawn. `deepSeekConfig` is optional: an absent profile picks the first
|
||||
# pane-capable one, and an absent permissionMode leaves the harness on its own
|
||||
# workspace-write default, which still ASKS before it acts.
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"dsh-worker","mode":"deepseek","deepSeekConfig":{"permissionMode":"danger-full-access"}}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; exit 1; } # OPERATION_FAILED = no runnable profile
|
||||
CREATED+=("$SID")
|
||||
|
||||
# 2. Readiness, and ONLY readiness. ⚠️ Do not use the stop signal for this: the
|
||||
# harness reports idle at BOOT, ~300 ms before the composer paints.
|
||||
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||
--data-urlencode 'match=❯' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000' \
|
||||
| jq -e '.data.wait.matched' >/dev/null || { echo "no composer"; delete_session "$SID"; exit 1; }
|
||||
|
||||
# 3. Task it. Identical to a claude worker, including the \r and the (clientId, seq).
|
||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||
-d '{"input":"Read calc.py and tell me in one sentence whether add() is correct.\r","useMux":true,"clientId":"codeman-dsh-1","seq":1,"wait":"stop,exit","waitTimeout":300000}' \
|
||||
| jq -c '{delivered:.data.delivered,signal:.data.wait.signal,timedOut:.data.wait.timedOut}'
|
||||
|
||||
# 4. Read it. From $DSH_HOME/sessions/**, not the pane -- scraping a dsh pane returns
|
||||
# its ASCII-art splash. Poll: the harness finalizes the message just after it
|
||||
# reports idle. Two answers are not the model's words and say so:
|
||||
# "Turn error: …" (the provider or harness failed) and "Turn ended: …" (early stop).
|
||||
for _ in $(seq 1 15); do
|
||||
TXT=$("${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text')
|
||||
[ -n "$TXT" ] && break; sleep 1
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
|
||||
# 5. Full conversation, if you need the tool calls too:
|
||||
# "${CURL[@]}" "$API/api/v1/sessions/$SID/last-response?context=full" | jq -r '.data.messages[]|"[\(.label)] \(.text)"'
|
||||
|
||||
delete_session "$SID"
|
||||
```
|
||||
|
||||
⚠️ **`wait:"stop,exit"`, not `wait:true`.** The default set also carries `idle`, which
|
||||
for an external CLI is inferred from output stabilization: a dsh TUI that repaints
|
||||
rarely reads as idle mid-turn, and a wait carrying `idle` then resolves in 0 ms on a
|
||||
turn with minutes left to run (measured). The same reason the preamble's `sendwait`
|
||||
asks for `stop,exit` on every mode.
|
||||
|
||||
## Flow 2: shell worker, marker-synchronized
|
||||
|
||||
`shell` sessions have no hooks (`stop`/`blocked` are a 400 there), and their lifecycle
|
||||
|
||||
@@ -152,7 +152,19 @@ It is **decoration, and resolved rather than trusted**, so treat it accordingly:
|
||||
|
||||
### 5.2 Readiness
|
||||
|
||||
A new session reports `idle` before its CLI has spawned, and a brand-new case shows a
|
||||
**dsh workers first**, because their trap is the opposite of claude's: they have no
|
||||
trust dialog and boot straight into a composer (`❯`, matched `from=buffer`), but the
|
||||
harness reports `idle` — which reaches you as a `stop` signal — about 300 ms BEFORE that
|
||||
composer paints (measured 2.26 s vs 2.56 s after spawn, twice). So the signal that means
|
||||
"this worker finished its turn" is also the first thing it emits at boot, and a
|
||||
send-and-wait fired straight after `quick-start` resolves on it, reports a turn that
|
||||
never ran, and leaves the prompt in a pane that was not yet taking input. Wait for the
|
||||
composer, not for the signal; `spawn_worker` does exactly that, and by the time it
|
||||
returns the boot edge is spent (signals are edge-triggered, so nothing can catch it
|
||||
later). A profile whose composer is not `❯` needs `DSH_READY_MARK` set to whatever it
|
||||
does draw.
|
||||
|
||||
For claude: a new session reports `idle` before its CLI has spawned, and a brand-new case shows a
|
||||
**trust dialog** first, so neither "wait for idle" nor "wait for ❯" means ready (the
|
||||
trust dialog contains `❯` too, observed live). Codeman auto-accepts that dialog
|
||||
itself, reliably enough that stage 1 usually just works: `_maybeAcceptTrustDialog()`
|
||||
@@ -341,17 +353,35 @@ If the loop exhausts its cap, do not keep looping: read the terminal, report wha
|
||||
see, and remember that a still-typed-but-unsubmitted prompt (missing `\r`) can only be
|
||||
recovered by submitting it with `{"input":"\r"}`.
|
||||
|
||||
⚠️ `stop` and `blocked` fire for `claude` sessions only (they are Claude Code hooks,
|
||||
and only when the workspace actually has them, see [§5.1](#51-where-to-spawn)). On
|
||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`, requesting them explicitly is a
|
||||
⚠️ `stop` and `blocked` fire for `claude` sessions (they are Claude Code hooks, and
|
||||
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
|
||||
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
||||
real end-of-turn signal rather than a guess. On
|
||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`, requesting them explicitly is a
|
||||
400, and lifecycle transitions there are coarse (a short shell command may emit **no**
|
||||
`idle` transition at all, verified live), so synchronize those with markers.
|
||||
|
||||
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
|
||||
false` at create time disarms the bridge, and an explicit `until=stop` is then a 400
|
||||
naming that setting. And a `stop` that is *accepted* is not proof it will ever fire —
|
||||
whether the installed profile implements the supervisor contract cannot be known at
|
||||
request time, so a non-conforming one accepts the wait and times out on it. One timeout
|
||||
on a dsh worker whose pane clearly finished identifies that profile; switch it to
|
||||
markers.
|
||||
|
||||
### 5.4 Read the answer
|
||||
|
||||
For `claude` and `codex` workers this is the read path: `last-response` returns the
|
||||
agent's final message as clean text, taken from the transcript rather than the screen,
|
||||
so it carries none of the TUI's box-drawing or repaint noise.
|
||||
For `claude`, `codex` and `deepseek` workers this is the read path: `last-response`
|
||||
returns the agent's final message as clean text, taken from the transcript rather than
|
||||
the screen, so it carries none of the TUI's box-drawing or repaint noise.
|
||||
|
||||
⚠️ For `deepseek` it reads `$DSH_HOME/sessions/**`, and reading it is the ONLY way to
|
||||
get that answer: dsh-TUI paints a full-screen splash, so scraping its pane returns the
|
||||
ASCII-art logo (that is what `last-response` itself used to return for dsh). Two dsh
|
||||
answers are not the model's words and say so: `Turn error: …` (the provider or the
|
||||
harness failed the turn) and `Turn ended: …` (an early stop such as `max-tokens`). A
|
||||
turn still streaming reads back as the partial answer so far, so a non-empty read is
|
||||
not by itself proof the turn ended — that is what the `stop` signal is for.
|
||||
|
||||
```bash
|
||||
for _ in $(seq 1 10); do # the transcript write LAGS the stop signal
|
||||
@@ -369,9 +399,10 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
|
||||
single read taken the instant send-and-wait returns comes back `""` even though the
|
||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`; the first four
|
||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`; the first four
|
||||
verified live, pi from the same source path), which is
|
||||
why the loop above is bounded rather than open-ended. Fall back to the terminal buffer
|
||||
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
|
||||
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
|
||||
there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive
|
||||
sessions; don't use it):
|
||||
|
||||
|
||||
@@ -26,11 +26,20 @@
|
||||
* external CLIs: those lists exist to describe what `isExternalCliMode()` gates
|
||||
* (no Claude transcript, no hooks, no Claude-format parsers), so naming some but
|
||||
* not all of them is the drift itself. Runs of one or two modes are exempt, since
|
||||
* a legitimate pair ("claude or shell") is not a class claim. ONE exception is
|
||||
* allowed and it is a real one: the "writes no transcript" lists drop `codex`,
|
||||
* which does write a rollout Codeman reads back (the pane carries a unique
|
||||
* originator precisely so `last-response` can find it), so external-minus-codex
|
||||
* is a meaningful class rather than an oversight.
|
||||
* a legitimate pair ("claude or shell") is not a class claim. The exceptions are
|
||||
* the REAL classes inside the external family, each one a capability some of those
|
||||
* CLIs have and the rest do not:
|
||||
*
|
||||
* - "writes no transcript" drops `codex` (a rollout Codeman reads back) and
|
||||
* `deepseek` (a JSONL session file Codeman reads back);
|
||||
* - "delivers no hook signals" drops `deepseek`, whose harness reports its own
|
||||
* lifecycle -- that one is derived from `hooksAvailableForMode()` rather than
|
||||
* restated, so the predicate and the prose cannot drift apart;
|
||||
* - the positive twin of the first: the modes whose answers CAN be read.
|
||||
*
|
||||
* Anything else partial is still the drift. A NEW backend belongs to none of these
|
||||
* classes until someone says so, so every one of them grows by a mode and every
|
||||
* stale list fails here -- which is the whole point.
|
||||
*
|
||||
* Port: N/A (pure static analysis).
|
||||
*/
|
||||
@@ -41,6 +50,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
|
||||
import { isExternalCliMode } from '../src/session.js';
|
||||
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
|
||||
import type { SessionMode } from '../src/types/session.js';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
@@ -63,6 +73,15 @@ function schemaModes(schema: typeof CreateSessionSchema | typeof QuickStartSchem
|
||||
const MODES = schemaModes(CreateSessionSchema);
|
||||
const EXTERNAL_MODES = MODES.filter(isExternalCliMode);
|
||||
|
||||
/**
|
||||
* External modes whose ANSWERS Codeman can read: codex from its rollout,
|
||||
* deepseek from `$DSH_HOME/sessions/**`. Stated here rather than derived because
|
||||
* `last-response` branches per mode into a per-CLI reader and there is no single
|
||||
* predicate to import; the runtime facts are `readCodexLastResponse` and
|
||||
* `readDeepSeekLastResponse` in session-routes.ts.
|
||||
*/
|
||||
const TRANSCRIPT_EXTERNAL_MODES = new Set<string>(['codex', 'deepseek']);
|
||||
|
||||
/**
|
||||
* Mode tokens appearing back to back, separated only by list punctuation — `a|b|c`,
|
||||
* `a`/`b`/`c`, "`a`, `b` and `c`". Newlines collapse to spaces first so a wrapped list
|
||||
@@ -109,9 +128,12 @@ describe('agent skill run-mode lists', () => {
|
||||
|
||||
it('never enumerates a partial set of external CLI modes', () => {
|
||||
const complete = new Set<string>(EXTERNAL_MODES);
|
||||
/** The documented exception: codex writes a rollout, so it is absent from the
|
||||
* "no transcript" lists on purpose. Every OTHER external mode must still be there. */
|
||||
const withoutCodex = new Set<string>(EXTERNAL_MODES.filter((m) => m !== 'codex'));
|
||||
// The real classes inside the family (see the fileoverview). Each is derived, so
|
||||
// an eighth backend joins none of them and every list naming the other seven fails.
|
||||
const noTranscript = new Set<string>(EXTERNAL_MODES.filter((m) => !TRANSCRIPT_EXTERNAL_MODES.has(m)));
|
||||
const withTranscript = new Set<string>(EXTERNAL_MODES.filter((m) => TRANSCRIPT_EXTERNAL_MODES.has(m)));
|
||||
const noHookSignals = new Set<string>(EXTERNAL_MODES.filter((m) => !hooksAvailableForMode(m)));
|
||||
const allowed = [complete, noTranscript, withTranscript, noHookSignals];
|
||||
const sameSet = (a: Set<string>, b: Set<string>) => a.size === b.size && [...a].every((v) => b.has(v));
|
||||
|
||||
const offenders: string[] = [];
|
||||
@@ -121,7 +143,7 @@ describe('agent skill run-mode lists', () => {
|
||||
if (listed.length < 3) continue;
|
||||
const externals = new Set<string>(listed.filter(isExternalCliMode));
|
||||
// Empty is fine (a claude/shell-only list); partial is the drift.
|
||||
if (externals.size === 0 || sameSet(externals, complete) || sameSet(externals, withoutCodex)) continue;
|
||||
if (externals.size === 0 || allowed.some((set) => sameSet(externals, set))) continue;
|
||||
const missing = EXTERNAL_MODES.filter((m) => !externals.has(m));
|
||||
offenders.push(`${file}: "${run.trim()}" is missing ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user