diff --git a/CLAUDE.md b/CLAUDE.md index 32aa8449..e3d2cefb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,7 +187,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) -**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` are hook-driven and fire for **`claude` and `deepseek` ONLY** (`shell` installs none either); asking for one explicitly on any other mode is a 400, the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred — `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case ]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` +**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` are hook-driven and fire for **`claude` and `deepseek` ONLY** (`shell` installs none either); asking for one explicitly on any other mode is a 400, the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred — `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. ⚠️ **`deepseek` is therefore the one non-claude mode the skill drives like claude** — `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. Two traps are baked into the preamble rather than left to the agent: the harness's boot `idle` report lands ~300 ms BEFORE its composer paints (2.26 s vs 2.56 s, measured), so readiness must come from the composer and never from the signal, or a send-and-wait resolves on the boot edge and reports a turn that never ran; and `sendwait` asks for `wait:"stop,exit"` rather than the default set, because that set also carries `idle`, which for an 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 with minutes left to run. The primitives are packaged as the **`skills/codeman` agent skill**: installable via `codeman skill install [--case ]` / `skill uninstall`, or auto-injected into a case's `.claude/skills/` on Claude session create behind `agentSkillEnabled` (SYNCED, default OFF). Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgentSessionPreamble` → `${XDG_CACHE_HOME:-~/.cache}/codeman-agent-.sh`, local claude sessions only), single-sourced from `skills/codeman/preamble.sh` and pinned byte-identical to SKILL.md's §0 heredoc by `test/agent-skill.test.ts`, so the skill's bootstrap is a two-line loader instead of a ~150-line paste the model types out (~47 s of generation, measured live). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. @@ -205,7 +205,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the CLI run modes running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a `SessionMode` of its own**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All seven **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE` and `DSH_HOME` for a non-granted owner (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`; user guide `docs/deepseek-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek) +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All seven **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. ⚠️ **Grok is codex-shaped on permissions but opencode-shaped on rendering**: its bypass switch is `alwaysApprove` (`--always-approve`, grok's `bypassPermissions` mode — the Run button sends it `true` like antigravity's, and the clamp's only-if-sent branch strips it for non-granted owners), while its fullscreen alt-screen TUI keeps it OUT of `isAltScreenStripMode()`; the resolver version-probes `grok --version` like pi's (npm squatters exist for the name — `GET /api/grok/status` surfaces path + version), and grok lands on the `'buffer'` echo policy via the fallthrough (UNMEASURED against a live authenticated session; if its composer turns out per-keystroke-reactive like codex, flip it to the `'off'` branch). Grok's own tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`; user guide `docs/grok-integration.md`. ⚠️ **DeepSeek breaks three of this family's assumptions, so do not pattern-match it onto its siblings.** (1) The agent is a **PROFILE, not the binary**: `dsh` is a launcher over `$DSH_HOME/profiles/` and DeepSeek ships only `web`/`headless`/`base`, so the terminal front door is ALWAYS third-party and "installed" ≠ "runnable" — the Run button gates on `isDeepSeekRunnable()` (binary AND a pane-capable profile) while `isDeepSeekAvailable()` gates the "add a profile" affordance; a `web`/`headless` profile is refused at spawn because it cannot drive a pane. (2) The permission switch is the **`DSH_PERMISSION_MODE` env export, not a flag** (`read-only`/`workspace-write`/`danger-full-access`) — the harness has none, and this is the one legitimate exception to the effort-style env-var ban because it is read with `??` as a boot-time default, so it stays soft; absent = `workspace-write`, which asks, hence the only-if-sent clamp branch, clamping to `workspace-write` (never `read-only`, which would break the workspace). ⚠️ **That clamp needs a second half no other CLI needs**, because the switch is an env var and `DSH_*` is an allowlisted `envOverrides` prefix: `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so a non-granted owner sending `DSH_PERMISSION_MODE` on the SAME request would land last and hand back exactly the privilege the config clamp removed. `clampEnvOverridesForOwner()` (session-routes.ts) DROPS `DSH_PERMISSION_MODE` and `DSH_HOME` for a non-granted owner (dropping falls through to what `_configureDeepSeek()` exports, which is the clamped value); `DSH_HOME` is there because it points the launcher at a profile tree whose plugin code runs at BOOT, before any approval row applies. Every OTHER CLI's bypass is a command-line flag reachable only through its config, which is why the config clamp alone is the whole gate for them. (3) It is the **only non-claude mode that passes `hooksAvailableForMode()`**, and for it alone that predicate is a per-SESSION question rather than a per-mode one (`deepSeekConfig.statusReporting: false` disarms the bridge, so every call site passes `sessionHookOptions(session)`; answering from the mode there re-creates the infinite-wait-dressed-as-a-timeout the guard exists to prevent). It passes because the terminal front door reports idle/working/blocked to a supervisor over a generic env-gated contract and `deepseek-status-shim.ts` makes Codeman that supervisor — real `stop`/`blocked` signals, real Approvals Inbox items, plus the `agent_working` event that clears an alert answered in the terminal. ⚠️ The resolver needs the strictest identity probe of the family (`dsh --help` must say `DeepSeek Harness`) because Debian ships an unrelated `dsh` (dancer's shell) that would pass a version probe. Model is NOT a session field (it is a profile composition entry). ⚠️ `hooksAvailableForMode()` is about hook SIGNALS and is not a stand-in for "is this a claude session": Read My Mind and intent capture read Claude's own transcript and compare `mode === 'claude'` directly, because when `deepseek` earned a yes the shared predicate silently widened both to a mode with no transcript to read (pinned by a static check in `test/deepseek-mode.test.ts`). ⚠️ **It is also the only external CLI whose answers are READ FROM DISK rather than scraped off the pane**: `deepseek-transcript.ts` reads `$DSH_HOME/sessions///session.jsonl.zstd` and backs the `last-response` route for dsh, because the pane segmenter served dsh-TUI's ASCII-art SPLASH as the worker's answer (measured), which anything polling for a first answer reads as an answer. Three traps live in that file: dsh appends **one zstd FRAME per write** and Node's `zlib` zstd decoder stops at the first (a real 56-line transcript decoded as 1 line, so the module walks frame headers itself; a Node older than 22.15 has no zstd and falls back to the pane); every turn also records a **plugin-sourced `user/message`** (the runtime-context snapshot) that must not render as the user's words; and a failed `turn/end` is surfaced as `Turn error: …` rather than as an empty string that reads as "still thinking". ⚠️ Session→transcript pairing is by the header's own `cwd` plus a ±60 s boot window, never by reproducing dsh's directory mangling (which has already changed form once) — and NEVER by newest-mtime alone, which handed a fresh worker its predecessor's answer in the same case dir. DeepSeek's own tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`, `test/deepseek-transcript.test.ts`; user guide `docs/deepseek-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek) **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6fbb5dbe..462ead47 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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///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 `` 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, `` and `session-`) 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`. diff --git a/docs/deepseek-integration.md b/docs/deepseek-integration.md index 7b97ccc9..e0a44467 100644 --- a/docs/deepseek-integration.md +++ b/docs/deepseek-integration.md @@ -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///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 ` 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 --trusted-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 diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index bc371540..59f90473 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -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() { # -> "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() { # -> "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 [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 ... -> one " " 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 ... -> one " " 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 [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 [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 [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/` 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 | diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index ccb007ea..0cee70bd 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -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() { # -> "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() { # -> "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 [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 ... -> one " " 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 ... -> one " " 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 [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 [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 [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 diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index e941849b..50548efa 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -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 | diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index f338b08b..d5e7f4df 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -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 diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 9a8dff76..f3219cd8 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -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): diff --git a/test/agent-skill-mode-lists.test.ts b/test/agent-skill-mode-lists.test.ts index 821f5f0b..9b01020d 100644 --- a/test/agent-skill-mode-lists.test.ts +++ b/test/agent-skill-mode-lists.test.ts @@ -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(['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(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(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(EXTERNAL_MODES.filter((m) => !TRANSCRIPT_EXTERNAL_MODES.has(m))); + const withTranscript = new Set(EXTERNAL_MODES.filter((m) => TRANSCRIPT_EXTERNAL_MODES.has(m))); + const noHookSignals = new Set(EXTERNAL_MODES.filter((m) => !hooksAvailableForMode(m))); + const allowed = [complete, noTranscript, withTranscript, noHookSignals]; const sameSet = (a: Set, b: Set) => 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(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(', ')}`); }