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:
Codeman maintainer
2026-08-25 04:17:48 +02:00
parent d1bc0c517d
commit 6261b6f655
9 changed files with 405 additions and 103 deletions
+40 -9
View File
@@ -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):