Merge pull request #337 from Ark0N/feat/deepseek-harness

feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode
This commit is contained in:
Ark0N
2026-08-25 19:12:01 +02:00
committed by GitHub
57 changed files with 3893 additions and 93 deletions
+9 -8
View File
File diff suppressed because one or more lines are too long
+22
View File
@@ -68,6 +68,18 @@ RUN curl -fsSL https://x.ai/cli/install.sh | bash \
&& rm -rf /root/.grok /root/.local/bin/grok /root/.local/bin/agent \
&& grok --version
# DeepSeek Harness (`dsh`). A normal npm package, but the ONLY entry here whose
# binary runs nothing on its own: `dsh` is a profile launcher, and DeepSeek ships
# only `web` and `headless`, so without an interactive profile a
# `mode: 'deepseek'` container would start a pane that dies on arrival. The
# profile itself is installed further down, into the `agent` HOME, because
# Codeman deliberately does NOT seed `profiles/` from the host: it is a
# per-profile node_modules tree, host-arch-specific and far too large to copy on
# every container start.
RUN npm install -g @deepseek-ai/dsh \
&& npm cache clean --force \
&& dsh --version
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
@@ -88,9 +100,19 @@ ENV HOME=/home/agent
# `.pi/agent` and `.grok` ARE pre-created: both are seeded per-FILE (pi:
# auth/settings/trust/models; grok: auth.json/config.toml/pager.toml), and a
# per-file seed copy, unlike a whole-dir one, does not create its parent directory.
# `.dsh` is pre-created for the same per-file reason (.env/settings.yaml/
# cordis.patch.yml), and the interactive profile is built into it HERE rather than
# after `USER agent`: this layer's closing chgrp/chmod is what makes the whole tree
# writable by the arbitrary uid the container actually runs as, and a profile
# installed after it would miss that fixup. DSH_HOME points the launcher at the
# agent's dir while this still runs as root.
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
/home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \
/home/agent/.dsh \
&& DSH_HOME=/home/agent/.dsh HOME=/home/agent \
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui \
&& test -f /home/agent/.dsh/profiles/dsh-tui/package.json \
&& chgrp -R 0 /home/agent \
&& chmod -R g=u /home/agent
+16 -2
View File
@@ -18,9 +18,23 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
## Session launch modes
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok)
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All six modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
⚠️ **The agent is a PROFILE, not the binary.** `dsh` is a launcher over `$DSH_HOME/profiles/<name>` (an ordered stack of plugin-bundle patch layers), and DeepSeek ships only `web` (browser UI), `headless` (one-shot) and `base` (no app). The interactive terminal front door is ALWAYS third-party. So availability is TWO questions, not one, and `isDeepSeekRunnable()` (binary AND a pane-capable profile) is what the Run button gates on while `isDeepSeekAvailable()` (binary only) gates the "add a profile" affordance and the web-UI shortcut. Reporting only the binary would let Run spawn a pane that dies on arrival, which is this mode's single most confusing failure. `buildDeepSeekCommand()` emits `dsh --profile <name> [--resume [id]]`; an absent profile resolves through `resolveDefaultDeepSeekProfile()`, which prefers a recognized TUI, then an UNRECOGNIZED profile (third-party by construction — a classifier that has not heard of a bundle must not hide it), and refuses `web`/`headless`, which cannot drive a pane.
⚠️ **The permission switch is an ENV VAR, not a flag.** The harness has no `--dangerously-skip-permissions` equivalent; its sandbox/approval rows read `DSH_PERMISSION_MODE` with three presets (`read-only` / `workspace-write` / `danger-full-access`; measured from `dsh --dump-default-config`). It is exported via `tmux setenv` in `_configureDeepSeek()`, never on the command line, and `test/deepseek-mode.test.ts` pins that nothing permission-shaped ever reaches the spawn line. This is the ONE place a Codeman env export is the right mechanism rather than the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks in-session `/effort`), the harness reads it with `??` as a boot-time DEFAULT, so it stays soft. Absent = `workspace-write`, which still asks, so the multi-user clamp is the only-if-sent branch (codex/antigravity/grok shape, not pi's materialize) — and it clamps down to `workspace-write`, NOT `read-only`, because the clamp removes privilege without breaking a session's ability to edit its own workspace. ⚠️ **Clamping the config is only HALF the gate here, and this is the only CLI where that is true.** Every sibling's bypass is a command-line flag, reachable only through the per-CLI config `clampExternalCliBypassForOwner()` already owns. DeepSeek's is an env var, `DSH_*` is an allowlisted `envOverrides` prefix (it must be — that is also how the harness's ordinary knobs are set), and `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so `envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'}` sent on the SAME request as a clamped config lands last and wins. `clampEnvOverridesForOwner()` (session-routes.ts, exported as `_clampEnvOverridesForOwner` for tests) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureDeepSeek()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) rather than rewriting them, since dropping falls through to what `_configureDeepSeek()` exports, which is already the clamped value. `DSH_HOME` is on that list because it aims the launcher at a profile tree and a profile's plugin code executes at BOOT, before any approval row can apply — the wider of the two holes. No-op in single-user mode and for a granted owner, like every other clamp.
⚠️ **It is the only non-claude mode that passes `hooksAvailableForMode()`, and it earned that.** The community terminal front door reports its own lifecycle to a supervising process through a generic env-var-gated contract inherited from Herdr: with `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` set it shells out `<bin> pane report-agent <paneId> --state idle|working|blocked …` on every state change and treats exit 0 as delivered. `deepseek-status-shim.ts` GENERATES a small script into the data dir (like `self-update-runner.sh`, so npm installs and git clones behave alike) and points `HERDR_BIN_PATH` at it; it forwards to `POST /api/hook-event` as `idle→stop`, `blocked→permission_prompt`, `working→agent_working`. So a dsh session gets real respawn triggers, real `wait` stop/blocked signals and real Approvals Inbox items instead of output-stabilization guesswork. This is an interface implementation, not an impersonation — no real `herdr` binary is ever executed. A TUI that does not implement the contract simply never calls the shim and falls back to stabilization, so the feature is inert rather than harmful there. ⚠️ **For deepseek alone, `hooksAvailableForMode()` is a per-SESSION question**, which is why it takes a `HookCapabilityOptions` second argument and every call site passes `sessionHookOptions(session)`: `deepSeekConfig.statusReporting: false` skips the `HERDR_*` export, and that triple is the only reason a dsh session posts anything, so answering from the mode alone would accept `until=stop` on a session where nothing can ever send one — the infinite-wait-dressed-as-a-timeout the predicate exists to prevent. The option defaults permissive (`!== false`), so a call site that forgets it degrades to the old behaviour instead of 400ing a working session. ⚠️ Profile conformance is the LIMIT of what is knowable at request time: `resolveDefaultDeepSeekProfile()` deliberately treats an unrecognized profile as launchable, so a non-conforming TUI still answers true and still times out on an explicit `stop` — which is why the DEFAULT signal set keeps `idle`/`exit`. ⚠️ **The predicate is not a stand-in for "is this a claude session"**, though it read like one while `claude` was the only true answer: Read My Mind (`POST /api/sessions/:id/readmymind`) and intent capture (`captureIntentPrompt`) read Claude's own transcript and were silently widened to deepseek by this change, so both compare `mode === 'claude'` directly and a static check in `test/deepseek-mode.test.ts` keeps them there.
⚠️ **`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.
⚠️ **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`.
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
+178
View File
@@ -0,0 +1,178 @@
# DeepSeek Harness (`dsh`) integration plan
> **Status**: Executed. This document records the plan, the decision behind each
> wiring point, and what was and was not verified. The user-facing guide is
> [`deepseek-integration.md`](./deepseek-integration.md); the per-decision
> invariants live in
> [`architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek`](./architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek).
> Template: the grok integration ([`grok-integration-plan.md`](./grok-integration-plan.md)),
> itself calibrated against pi. Every fact below was measured against a live
> **dsh 0.1.1-rc.2** install and **@deepseek-harness-tui/dsh-tui 0.9.0**, not read
> from documentation.
## 1. What the DeepSeek Harness is
[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
(open-sourced 2026-08-13, MIT) is a plugin-native agent framework: tools, skills,
sessions, sandboxes and whole APPS are Cordis plugins composed into *profiles*.
`dsh` is the launcher — `dsh --profile <name>` boots
`$DSH_HOME/profiles/<name>`, an ordered stack of plugin-bundle patch layers under
the user's own overrides. State lives in `~/.dsh` (`.env` 0600, `settings.yaml`,
`cordis.patch.yml`, `profiles/`, `sessions/`, `storages/`).
## 2. Shape decisions (why DeepSeek is wired the way it is)
DeepSeek is a ninth run mode. Never a location overlay, never a web tab (the
browser UI is handled separately, §3). Three of its decisions have no precedent
in the six external CLIs before it.
| Question | Decision | Why |
| --- | --- | --- |
| What does a pane run? | `dsh --profile <name>`, profile discovered | **The decision that shapes everything else.** DeepSeek ships `web`, `headless` and `base` — no terminal agent. The interactive front door is always a third-party plugin, so Codeman resolves a binary AND a profile inventory, and "available" means both. `resolveDefaultDeepSeekProfile()` prefers a recognized TUI, then an UNRECOGNIZED profile (anyone can publish an app bundle; a classifier that has not heard of one must not hide it), and refuses `web`/`headless`, which cannot occupy a pane. |
| Which TUI? | none blessed; default for BOOTSTRAP only | `POST /api/deepseek/install-profile` defaults to `@deepseek-harness-tui/dsh-tui` (~27.5k weekly downloads, ~4x the next, MIT, and it speaks the status contract in §2.3), but accepts any npm name and the resolver never assumes that profile exists. Codeman offers a default; it does not pick a winner. |
| Permission bypass | `DSH_PERMISSION_MODE` env export, no flag | The harness has NO command-line permission option; its sandbox/approval rows read one env var with three presets (`read-only` / `workspace-write` / `danger-full-access`, read off `dsh --dump-default-config`). This is the one legitimate exception to the `CLAUDE_CODE_EFFORT_LEVEL` ban: that var hard-locks in-session switching, whereas the harness reads this with `??` as a boot-time DEFAULT, so it stays soft. Exported via `tmux setenv`, never on the command line. The Run button sends `danger-full-access`, matching every sibling Run button. |
| Multi-user clamp branch | only-if-sent, clamped to `workspace-write`, **plus an env-var half** | Omitting the export leaves the harness on `workspace-write`, which still ASKS, so an absent config is already safe (the codex/antigravity/grok shape, not pi's materialize). Clamping to `workspace-write` rather than `read-only` is deliberate: the clamp removes privilege, it must not break a session's ability to edit its own workspace. ⚠️ Unlike every sibling, clamping the CONFIG is only half the gate: the switch is an env var, `DSH_*` is an allowlisted `envOverrides` prefix, and `applyEnvOverrides()` runs AFTER `_configureDeepSeek()`, so `envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'}` on the same request would land last and win. `clampEnvOverridesForOwner()` drops `DSH_PERMISSION_MODE` and `DSH_HOME` for a non-granted owner (dropping falls through to the clamped export). `DSH_HOME` because it aims the launcher at a profile tree whose plugin code runs at BOOT, before any approval row. |
| `hooksAvailableForMode()` granularity | per SESSION for deepseek, per mode for everything else | `deepSeekConfig.statusReporting: false` disarms the `HERDR_*` export, and the triple is the only reason a dsh session posts anything, so a mode-only answer would accept `until=stop` where nothing can send one — the infinite-wait the predicate exists to prevent. Call sites pass `sessionHookOptions(session)`; the default stays permissive so a forgotten one degrades to the old behaviour. ⚠️ Profile conformance stays unknowable at request time (an unrecognized profile is deliberately launchable), so a non-conforming TUI still times out on an explicit `stop`; the default set keeps `idle`/`exit` for that. ⚠️ The predicate is NOT "is this claude": Read My Mind and intent capture read Claude's transcript and were silently widened by this change, so they compare `mode === 'claude'` directly now. |
| Profile install spawn | own process group, hand-rolled timeout | `dsh plugin add` fans out into package-manager children, and spawn's built-in `timeout` signals only the direct child: survivors keep the inherited stdio pipes open, `close` never fires, and the held-open request leaks with no route-level deadline. `detached: true` + negative-pid SIGTERM→SIGKILL, the same escalation `runGit()` uses for the same reason, plus a last-resort reap for a grandchild that escaped the group. |
| Idle detection | **real hook events via a status shim** | The standout decision. The TUI already reports its lifecycle to a supervising process through a generic env-gated contract inherited from Herdr: `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` make it run `<bin> pane report-agent <id> --state idle\|working\|blocked …` on every state change, exit 0 = delivered. `deepseek-status-shim.ts` generates a script into the data dir and points `HERDR_BIN_PATH` at it. So deepseek is the only non-claude mode that passes `hooksAvailableForMode()` — earned by emitting definitive signals, not granted. An interface implementation, not an impersonation: no real `herdr` binary is ever executed, and a TUI that ignores the contract simply falls back to output stabilization. |
| `agent_working` event | new, 157th SSE constant | The one hook event with no Claude Code hook behind it. A harness turn cannot run while its own modal approval is on screen, so "started working" proves a dialog was answered in the terminal. Without it a dsh red alert would survive until the next `stop` — the exact stuck-alert bug the claude path already fixed once, and its pane-capture staleness sweep is Claude-dialog-shaped and cannot help here. |
| Resolver | identity probe THEN version probe | Strictest of the family, and not by preference. `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) which would answer a version probe convincingly and then be handed a spawn line. `dsh --help` must match `DeepSeek Harness` first. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release. |
| Env allowlist | `DSH_*` + `DEEPSEEK_*` | `DSH_*` covers the launcher's documented inputs (`DSH_HOME`, `DSH_PERMISSION_MODE`, `DSH_TELEMETRY_MODE`, the `DSH_TUI_*` knobs); `DEEPSEEK_*` is the vendor namespace holding `DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`, same reasoning that admitted `XAI_*` for grok. ⚠️ Pi's lesson repeats exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), and the allowlist is one GLOBAL list, so admitting those would widen every mode at once. They stay out. |
| Model | NOT a session field | The model is a composition entry (`agent-default-model`) in the profile's config tree, set in `~/.dsh/settings.yaml` + `cordis.patch.yml`. Both create paths deliberately resolve no model for this mode rather than inventing a flag. |
| Alt-screen strip | OUT of `isAltScreenStripMode()` | Third-party fullscreen TUIs with their own scrollback and mouse handling — the opencode case, not the Ink case. |
| Local echo | `'buffer'` via the `_updateLocalEchoState` fallthrough | UNMEASURED against a live authenticated session (see §5), same honest gap grok shipped with. The leading TUI's composer supports `@` completion and history search, which *may* make it per-keystroke reactive like codex; if so the fallback is the `'off'` branch. |
| Docker | image installs dsh AND a profile | Profiles are deliberately NOT seeded from the host: each is a per-profile `node_modules` tree, host-arch-specific and far too large to copy per container start. Only `~/.dsh/.env`, `settings.yaml`, `cordis.patch.yml` are seeded (auth + model composition). The profile install rides the `useradd` layer so the closing `chgrp`/`chmod g=u` covers it, which is what keeps it usable under the arbitrary uid the container runs as. |
| Remote SSH | `exec "$SHELL" -i -l -c 'dsh'` | Boots the remote box's default profile; a remote with several needs the per-host `commands.deepseek` override, since `deepSeekConfig` does not cross ssh. |
## 3. The web profile
The browser UI is the only interactive surface DeepSeek ships itself, so it gets
a **shortcut, not a run mode**: `Run ▸ DeepSeek web UI…` starts
`dsh web --no-open --host 127.0.0.1 --port <free> --trusted-host <codeman-authority>`
as a background process and opens the URL as an ordinary web tab.
The server was a **shell session** first, on the reasoning that Codeman already
supervises those (visible, scrollable, killable, dies with its tab) so nothing
new had to own a long-lived HTTP server. That version worked and was still
wrong in use: clicking "open the DeepSeek web UI" put a terminal tab on screen
next to the web tab actually asked for, every single time, and after the first
launch the terminal was pure noise. Opening a dashboard should open one tab.
So `POST /api/deepseek/web` owns it instead (`src/deepseek-web-server.ts`), and
what the session gave away for free is now explicit: exactly one server, reused
rather than raced on a second click; restarted when the requested authority
changes; killed on Codeman shutdown (a detached child would otherwise hold its
port against the next start — the very EADDRINUSE this feature already got
wrong once); and boot output captured, since with no shell tab there is nowhere
else for a stack trace to land. It is fenced at the same bar as the profile
installer: booting a dsh profile executes the plugin code in it, so it requires
the privileged grant in multi-user mode.
`--trusted-host` 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. The authority comes
from the CLIENT (`location.host`) because only the browser knows which of a
multi-homed Codeman's origins is actually in play.
Three things about this shortcut are load-bearing and each came from it failing
in exactly that way against a real install:
- **The port is chosen, never hardcoded.** `GET /api/deepseek/web-port` walks
3080..3119 for a free loopback port. 3080 is dsh's own default, which makes it
precisely the port a DeepSeek user is most likely to already be serving on:
binding it unconditionally killed the launch with `EADDRINUSE` against the
user's own `dsh web`.
- **The tab is opened only after the server answers.** The launch polls
`POST /api/webviews/probe` until the URL responds, so a server that dies on
startup reports the failure and points at its shell tab, instead of silently
persisting a dashboard aimed at nothing.
- **The saved tab is `trusted: true`, and must be.** An untrusted webview is
sandboxed without `allow-same-origin`, which breaks this dashboard twice: the
dsh client-runtime reads `localStorage` while loading plugins and dies there,
and an opaque-origin frame sends `Origin: null`, so dsh's trust check 403s
every `/api` call regardless of what `--trusted-host` names. Passing
`location.host` only means anything once the frame actually carries that
origin. The trade is real — a trusted proxied frame is same-origin with
Codeman and can reach Codeman's API — and is defensible only because this
particular dashboard is an agent harness Codeman just started itself on
loopback, which can already run code as the user. It is not a precedent for
trusting third-party dashboards generally.
The record is marked `managed: 'deepseek-web'`, which keeps it out of the
saved-dashboard list: the shortcut that maintains it is already a menu entry, so
listing both showed the same dashboard twice. Being managed is also what lets a
relaunch repoint the existing row instead of stacking one dead dashboard per
restart, since the port is now chosen per launch.
The authority baked into `--trusted-host` is the one the launch was clicked
from, and reuse is conditional on it: a running server fenced for a *different*
origin is stopped and restarted rather than reused, because reusing it renders a
page whose every API call 403s — which reads as a broken dashboard rather than a
misconfigured one.
## 4. Touch points (the checklist)
Backend: `types/session.ts` (SessionMode + `DeepSeekConfig` + SessionState),
`utils/deepseek-cli-resolver.ts` (new) + barrel, `deepseek-status-shim.ts` (new),
`tmux-manager.ts` (`buildDeepSeekCommand`, dispatch, resume flag, PATH export,
truecolor, `_configureDeepSeek`, availability error, plumbing), `session.ts`
(external-mode gate, label, config plumbing, tmux-required error, attach env),
`mux-interface.ts`, `schemas.ts` (prefixes, `DeepSeekConfigSchema`,
`DeepSeekInstallProfileSchema`, both mode enums, remote overrides, cron agentType,
`agent_working`), `session-wait-registry.ts` (`hooksAvailableForMode`),
`hook-event-routes.ts` (`APPROVAL_RESOLVING_EVENTS`), `session-routes.ts` (clamp +
both create paths + `resolveDeepSeekLaunchError`), `system-routes.ts`
(`GET /api/deepseek/status`, `POST /api/deepseek/install-profile`), `server.ts`
(availability inject + mux restore), `sse-events.ts`, `docker-hosts.ts`,
`remote-hosts.ts`, `config/dependency-registry.ts`,
`response-viewer-transcript.ts`, `cron/cron-service.ts` (comment),
`tui/tui-client.ts` + `tui-app.ts`.
Frontend: `index.html` (welcome button, run-mode entry, install affordance, web-UI
shortcut, cron option, clone Brain option), `session-ui.js` (`runDeepSeek()`,
`runDeepSeekWeb()`, `installDeepSeekProfile()`, dispatch, availability, "Run DS"
label, external-CLI gates), `app.js` (label, `ds` tab badge, kill-menu, SSE map),
`settings-ui.js` (welcome gate + `_onHookAgentWorking`), `constants.js`,
`mobile-overview.js`, `home-sessions.js`, `panels-ui.js`, `i18n.js`,
`terminal-ui.js`, `styles.css` + `mobile.css` (brand-indigo identity; the non-og
skin block and the mobile `!important` pair are both load-bearing).
Meta: `docker/agent.Dockerfile`, `install.sh`, `package.json` keyword,
`skills/codeman/reference/*`, CLAUDE.md, `architecture-invariants.md`.
Tests: `test/deepseek-mode.test.ts` + `test/deepseek-cli-resolver.test.ts` (new);
`run-mode-ui`, `render-index-html`, `mobile-overview`, `agent-skill-mode-lists`
(extended).
## 5. Verification performed
See the summary at the end of the implementing session for the live run. In
short: the CI gate green; the resolver, profile inventory, spawn-line and clamp
behaviour covered by 31 new unit tests; and an isolated instance used to exercise
`GET /api/deepseek/status` and a real session against the live dsh install.
**Not verified (honest gaps):**
- The local-echo `'buffer'` policy against the TUI's real composer (§2). If it
turns out per-keystroke reactive like codex's, flip it to the `'off'` branch;
teaching `PredictiveEchoAddon` its composer row is the larger follow-up.
- Scrollback/repaint behaviour of a third-party fullscreen TUI under the narrow
strip during a long session.
- A Docker case with `mode: 'deepseek'` (needs a `--no-cache` agent-image
rebuild — see the `--no-cache` rule in CLAUDE.md).
- A remote-SSH deepseek case.
- The web-UI shortcut against a tunnel authority. Loopback and a tailnet name are
both verified end to end through the webview proxy (dashboard renders, its
`/api` calls succeed, no shell session created).
## 6. Follow-ups
- **Response viewer**: read `~/.dsh/sessions/**` (JSONL) the way codex rollouts
are read back. Highest-value follow-up, and very achievable.
- **`headless` as an execution backend** for Codeman's own AI checks
(`ai-idle-checker`, `ai-plan-checker`), today Claude-only.
- **Profile/model picker in Session Options**, reading `GET /api/deepseek/status`
`.profiles`.
- **`--patch` overlays per session**, which is the harness-native way to change
agent composition without touching the user's profile.
- Measure the local-echo policy and pin the result the way pi did.
+249
View File
@@ -0,0 +1,249 @@
# DeepSeek Harness (`dsh`) in Codeman
Codeman can run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
as a session backend, alongside Claude Code, OpenCode, Codex, Gemini,
Antigravity, Pi and Grok. It is the ninth run mode, and the one that is wired
least like the others, for two reasons worth understanding before you use it.
## 1. The agent is a profile, not the binary
`dsh` is a **launcher**, not an agent. It boots a *profile*: an ordered stack of
plugin-bundle patch layers under `$DSH_HOME/profiles/<name>` (`$DSH_HOME`
defaults to `~/.dsh`). DeepSeek ships three bundles and none of them is a
terminal agent:
| Profile | What it is | Can Codeman run it in a tab? |
| ------------ | --------------------------------- | ---------------------------- |
| `web` | the browser UI, served on :3080 | no — but see §5 |
| `headless` | answers one task and exits | no |
| (`base`) | the shared core, no app at all | no |
The interactive terminal front door is **always a third-party plugin**. So
"DeepSeek is installed" and "Codeman can start a DeepSeek session" are different
questions, and Codeman answers both separately:
```bash
curl -s localhost:3000/api/deepseek/status | jq
{
"available": true, # the `dsh` binary resolved and proved its identity
"runnable": false, # ...but nothing installed can drive a pane
"path": "/home/you/.local/bin",
"version": "0.1.1-rc.2",
"dshHome": "/home/you/.dsh",
"defaultProfile": null,
"profiles": [ { "name": "web", "kind": "web", "bundles": [...] } ]
}
```
### Installing a terminal profile
From the UI: open the **Run** dropdown. When `dsh` is installed but no
pane-capable profile is, the menu shows **DeepSeek — add a terminal profile…**.
One click installs one and the normal DeepSeek entry appears.
By hand, or to pick a different front door:
```bash
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui
```
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
margin the most used community TUI, it is MIT, and it implements the status
contract described in §3. It is a **default, not a requirement**: any profile
under `$DSH_HOME/profiles` that is not `web` or `headless` shows up in the
inventory and can be launched, including one you compose yourself. The endpoint
accepts any npm package name:
```bash
curl -sX POST localhost:3000/api/deepseek/install-profile \
-H 'Content-Type: application/json' \
-d '{"profile":"my-tui","package":"@someone/dsh-tui"}'
```
Installing a plugin is arbitrary code execution on the host, so in multi-user
mode this endpoint requires the can-bypass-permissions grant (the same bar as a
`shell` session). The request is held open while the package manager runs and is
bounded at five minutes; the install runs in its own process group, so hitting
that bound kills the whole tree rather than just the launcher.
> **`dsh` is also a Debian program.** `apt install dsh` gives you "dancer's
> shell", a distributed shell, which would answer `--version` convincingly.
> Codeman's resolver therefore demands the harness's own help banner before it
> will point a spawn line at a candidate, and `GET /api/deepseek/status` reports
> `path` and `version` so a misresolution is diagnosable rather than presenting
> as "the mode just doesn't work".
## 2. Permissions are an env var, not a flag
The harness has **no `--dangerously-skip-permissions` equivalent**. Its sandbox
and approval rows are configuration, driven by one documented input,
`DSH_PERMISSION_MODE`, with three presets (read off `dsh --dump-default-config`):
| `DSH_PERMISSION_MODE` | sandbox | approvals | notes |
| --------------------- | -------------------- | --------- | ------------------------- |
| `read-only` | `read-only` | ask | |
| `workspace-write` | `workspace-write` | ask | the harness's own default |
| `danger-full-access` | `danger-full-access` | **never** | what the Run button sends |
Codeman exports it via `tmux setenv`, never on the command line. Because the
harness reads it with `??`, it is a **soft default**: it sets the boot-time
preset and you can still change permission mode inside the session.
Omitting it entirely leaves the harness on `workspace-write`, which still asks —
which is why the multi-user clamp only needs to force a *sent* value down. A
non-granted owner's `danger-full-access` becomes `workspace-write`, not
`read-only`: the clamp removes privilege without breaking the session's ability
to edit its own workspace.
Because the switch is an env var rather than a flag, that clamp has a second half
no other CLI needs. `DSH_*` is an allowlisted `envOverrides` prefix (it has to be:
that is also how you set the harness's ordinary knobs), and env overrides are
applied *after* the permission export, so in multi-user mode a non-granted owner
sending
```json
{ "mode": "deepseek", "envOverrides": { "DSH_PERMISSION_MODE": "danger-full-access" } }
```
would otherwise hand back the privilege the config clamp just removed. For a
non-granted owner Codeman therefore **drops `DSH_PERMISSION_MODE` and `DSH_HOME`
from `envOverrides`**; dropping them falls through to the clamped config and the
server's own `DSH_HOME`. `DSH_HOME` is in that list because it points the
launcher at a profile tree, and a profile's plugin code runs at boot, before any
approval row can apply. Single-user installs and granted owners are unaffected.
## 3. Real idle detection (the interesting part)
Every other external CLI mode in Codeman is **readiness-guessed**: Codeman
watches the PTY go quiet and infers that a turn ended. Claude is the exception,
because Claude Code fires hooks.
DeepSeek is the second exception. The community terminal front door already
reports its own lifecycle to a supervising process through a generic,
env-var-gated contract (inherited from [Herdr](https://herdr.dev)): when
`HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set, it shells out on
every state change with
```
"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
--source custom:dsh-tui --agent dsh-tui \
--state idle|working|blocked [--message ...] --seq N
```
Codeman points `HERDR_BIN_PATH` at a small generated shim
(`~/.codeman/dsh-status-shim.mjs`, written at session create) which forwards each
report to `POST /api/hook-event`. The mapping:
| Harness state | Codeman hook event | What you get |
| ------------- | ------------------ | -------------------------------------------------------- |
| `blocked` | `permission_prompt`| red "needs you" tab alert + an Approvals Inbox item |
| `idle` | `stop` | definitive end-of-turn: respawn triggers, `wait` returns |
| `working` | `agent_working` | clears an alert answered in the terminal, at once |
So a DeepSeek session gets Claude-grade signals: `GET /api/sessions/:id/wait`
really can block on `stop` and `blocked` for it, and it is the only non-Claude
mode for which that is true (`hooksAvailableForMode`).
That is a per-*session* answer, not a per-mode one. Turning the bridge off with
`deepSeekConfig.statusReporting: false` means nothing will ever post a hook event
for that session, so an explicit `until=stop` is refused up front (with a message
naming the setting) rather than blocking for your whole timeout. Omitting `until`
never fails: the hook-only signals are dropped from the default set and you still
get `idle` and `exit`.
One limit worth knowing: whether the *profile* implements the contract cannot be
known at request time (Codeman deliberately treats an unrecognized profile as
launchable). A dsh session running a non-conforming TUI therefore still accepts
`until=stop` and will time out on it. `idle`/`exit` are the reliable pair there.
This is an interface implementation, not an impersonation — nothing on your
machine executes a real `herdr` binary. If you use a terminal profile that does
*not* implement the contract, the shim is simply never called and the mode falls
back to output-stabilization readiness like its siblings. Turn it off per session
with `deepSeekConfig.statusReporting: false`.
## 4. Starting a session
From the UI, pick **DeepSeek** in the Run dropdown (or the **Run DeepSeek**
welcome button) and press Run. Over the API:
```bash
curl -sX POST localhost:3000/api/quick-start \
-H 'Content-Type: application/json' \
-d '{
"caseName": "myproject",
"mode": "deepseek",
"deepSeekConfig": {
"profile": "dsh-tui",
"permissionMode": "danger-full-access"
}
}'
```
`deepSeekConfig` fields: `profile`, `permissionMode`, `resumeSession`,
`resumeSessionId`, `statusReporting`. Resume prefers an explicit id over the
most-recent form, and both are passed through to the profile's app, which is
where `--resume` is understood.
**Models are not a session field.** The model is a composition entry in the
profile's config tree (`agent-default-model`), not a CLI flag, so Codeman does
not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
profile. That is also how you point dsh at a local or third-party provider.
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
all flow through). Provider keys with *other* names are deliberately not: a dsh
`settings.yaml` can nominate any env var as a credential via `apiKeyEnv`, and
Codeman's allowlist is global, so admitting them would widen it for every mode at
once. Authenticate those the way dsh does, from the file or the server's own
environment.
## 5. The web UI as a tab
The browser UI is the one interactive surface DeepSeek ships itself, so it gets a
shortcut rather than a run mode: **Run ▸ DeepSeek web UI…** starts
`dsh web --no-open --host 127.0.0.1 --port 3080 --trusted-host <codeman-host>` in
an ordinary shell session and opens `http://127.0.0.1:3080` as a Codeman web tab.
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.
## 6. 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
host (each is a per-profile `node_modules` tree, host-arch-specific and far too
large to copy on every container start); only `~/.dsh/.env`, `settings.yaml` and
`cordis.patch.yml` are seeded, which is what carries auth and model composition
in. As with pi and grok, in-container sessions are invisible host-side:
`~/.dsh/sessions` inside a container is that container's own.
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
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
forever.
- `--patch` overlays per session (the profile's own layers apply as normal).
- `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
`dsh 0.1.1-rc.2` and `@deepseek-harness-tui/dsh-tui 0.9.0`. The permission
presets, the profile layout, and the supervisor contract above were all read off
the live install rather than from documentation.
+67 -3
View File
@@ -125,6 +125,14 @@ PI_SEARCH_PATHS=(
"$HOME/bin/pi"
)
# DeepSeek Harness search paths (from src/utils/deepseek-cli-resolver.ts)
DSH_SEARCH_PATHS=(
"$HOME/.local/bin/dsh"
"/usr/local/bin/dsh"
"$HOME/.npm-global/bin/dsh"
"$HOME/bin/dsh"
)
# Grok CLI search paths (from src/utils/grok-cli-resolver.ts)
GROK_SEARCH_PATHS=(
"$HOME/.grok/bin/grok"
@@ -594,6 +602,57 @@ check_grok() {
return 1
}
# `dsh` is the hardest name of the lot: Debian ships an unrelated `dsh`
# (dancer's shell). The server-side resolver settles it by demanding the
# harness's own help banner; detection here only feeds the "you have no AI CLI"
# hint, so the same banner grep is enough — but unlike every sibling probe it
# EXECUTES the candidate, so it must be bounded. </dev/null is load-bearing
# twice over: a foreign binary that blocks on stdin would hang the install, and
# under `curl | bash` a child that reads stdin EATS THE REST OF THIS SCRIPT.
# The timeout (where coreutils ships one; stock macOS has none) bounds a binary
# that ignores EOF, mirroring the server resolver's own EXEC_TIMEOUT_MS.
dsh_banner_probe() {
local runner=()
if command -v timeout &>/dev/null; then runner=(timeout 5); fi
"${runner[@]}" "$1" --help </dev/null 2>/dev/null | grep -qi "DeepSeek Harness"
}
# Resolved ONCE and memoized: the probe executes a possibly-foreign binary, and
# the check/get/reminder call sites together used to re-run the whole scan many
# times per install.
DSH_RESOLVE_DONE=""
DSH_RESOLVED_PATH=""
resolve_dsh() {
[[ -n "$DSH_RESOLVE_DONE" ]] && return 0
DSH_RESOLVE_DONE=1
local candidate path
if command -v dsh &>/dev/null; then
candidate="$(command -v dsh)"
if dsh_banner_probe "$candidate"; then
DSH_RESOLVED_PATH="$candidate"
return 0
fi
fi
for path in "${DSH_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]] && dsh_banner_probe "$path"; then
DSH_RESOLVED_PATH="$path"
return 0
fi
done
return 0
}
check_dsh() {
resolve_dsh
[[ -n "$DSH_RESOLVED_PATH" ]]
}
get_dsh_path() {
resolve_dsh
echo "$DSH_RESOLVED_PATH"
}
get_grok_path() {
if command -v grok &>/dev/null; then
command -v grok
@@ -2123,6 +2182,7 @@ main() {
local has_antigravity=false
local has_pi=false
local has_grok=false
local has_dsh=false
info "Checking AI CLI tools..."
if check_claude; then
@@ -2153,10 +2213,14 @@ main() {
has_grok=true
success "Grok CLI found at $(get_grok_path)"
fi
if check_dsh; then
has_dsh=true
success "DeepSeek Harness found at $(get_dsh_path)"
fi
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" ]]; then
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" ]]; then
echo ""
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok."
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or DeepSeek Harness."
headless_guard "install an AI CLI (curl | bash from its vendor)"
echo ""
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
@@ -2512,7 +2576,7 @@ main() {
echo -e " https://github.com/Ark0N/Codeman"
echo ""
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok; then
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh; then
echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:"
echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code"
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
+1
View File
@@ -63,6 +63,7 @@
"antigravity",
"pi",
"grok",
"deepseek",
"gemini-cli",
"ai-agents",
"agent",
+4 -4
View File
@@ -237,7 +237,7 @@ 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` and `grok`, which write no Claude transcript.
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `deepseek`, which write no Claude transcript.
**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.
@@ -336,14 +336,14 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok`; response is
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name.
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`
and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed).
Pi's and grok's also carry `.data.version`, because `pi` is a short generic name and
`grok` is a name with npm squatters, so an unrelated binary on `$PATH` can shadow either:
@@ -464,7 +464,7 @@ Quirks that will bite you:
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:2261`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`. ⚠️ **`shell` is NOT one of those**
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:174-183`, lists only those six), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
+2 -2
View File
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`) | HTTP only (no other CLI has messaging) |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume
@@ -347,7 +347,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`) cannot be peers
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one
+1 -1
View File
@@ -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, which have no transcript, use
# always "" for shell/opencode/gemini/antigravity/pi/grok/deepseek, 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
+3 -3
View File
@@ -343,7 +343,7 @@ 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`, requesting them explicitly is a
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`, 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.
@@ -369,7 +369,7 @@ 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`; the first four
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`; 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
there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive
@@ -454,7 +454,7 @@ turn), and both better than diffing terminal samples:
```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`** (those parsers are skipped wholesale) and
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
+27
View File
@@ -9,6 +9,7 @@
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js';
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
@@ -163,6 +164,32 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
},
],
},
{
id: 'dsh',
label: 'DeepSeek Harness CLI',
category: 'core',
required: false,
usedBy: ['DeepSeek sessions'],
// Version match required, and for a sharper reason than pi or grok: `dsh` is
// not merely a squattable npm name, it is an existing Debian program
// (dancer's shell, `apt install dsh`). The run mode's resolver additionally
// demands the harness's own help banner before it will point a spawn line at
// a candidate; the doctor is advisory and settles for the shared
// DEEPSEEK_VERSION_REGEX, so the two cannot disagree about the VERSION even
// though the resolver is the stricter of the pair about IDENTITY.
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: ['dsh'],
versionArg: '--version',
versionRegex: DEEPSEEK_VERSION_REGEX,
requireVersionMatch: true,
},
},
],
},
{
id: 'libreoffice',
label: 'LibreOffice',
+16 -3
View File
@@ -48,8 +48,9 @@ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
* answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript,
* so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve`
* is NOT a clamp.
* Codex, antigravity and grok need nothing here: their absent config already spawns safe
* (grok's bare spawn is its own ask-mode default; --always-approve is only ever sent).
* Codex, antigravity, grok and deepseek need nothing here: their absent config already spawns safe
* (grok's bare spawn is its own ask-mode default and deepseek's omits DSH_PERMISSION_MODE
* entirely, leaving the harness on workspace-write, which asks; both switches are only ever sent).
* Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched.
*/
export function clampCronExternalCliConfigs(
@@ -394,11 +395,23 @@ export class CronService {
let session: Session;
try {
const mode = job.agentType;
// Same two-part availability gate the HTTP create paths run: `dsh` is a
// profile LAUNCHER, so without this a job on a box with only the stock
// web/headless profiles spawns a bare `dsh` that boots a profile unable
// to drive a pane, and the prompt is typed into a logging server or a
// dead pane instead of failing the run with the actionable message.
if (mode === 'deepseek') {
const { resolveDeepSeekLaunchError } = await import('../utils/deepseek-cli-resolver.js');
const launchError = resolveDeepSeekLaunchError();
if (launchError) return this.failRun(job, run, launchError);
}
const globalNice = await this.deps.getGlobalNiceConfig();
const modelConfig = await this.deps.getModelConfig();
const claudeModeConfig = await this.deps.getClaudeModeConfig();
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
// DeepSeek's model is a composition entry in the profile's config tree,
// not a session flag — mirror the HTTP routes' exclusion.
const model = mode !== 'shell' && mode !== 'deepseek' ? modelConfig?.defaultModel || undefined : undefined;
// Section 6.3: materialize the safe default for a non-granted owner (see
// clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own
// spawn default is what would otherwise apply).
+271
View File
@@ -0,0 +1,271 @@
/**
* @fileoverview The DeepSeek Harness -> Codeman status bridge.
*
* ## Why this exists
*
* Every external CLI mode before this one (opencode, codex, gemini, antigravity,
* pi, grok) is READINESS-GUESSED: Codeman watches the PTY go quiet and infers a
* turn ended. Claude is the exception, because Claude Code fires real hooks. The
* DeepSeek Harness TUI gives us a third option, and a much better one than
* guessing: the community terminal front door already reports its own lifecycle
* to an owning supervisor, and it does so through a fully GENERIC, env-var-gated
* contract it inherited from Herdr (herdr.dev).
*
* When all three of `HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set,
* the TUI shells out on every state change:
*
* "$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
* --source custom:dsh-tui --agent dsh-tui \
* --state idle|working|blocked [--message <text>] --seq <n>
*
* and treats exit code 0 as "delivered" (retrying with backoff otherwise). So
* Codeman points `HERDR_BIN_PATH` at the script below and gets DEFINITIVE
* idle/working/blocked signals for dsh sessions: real respawn triggers, real
* `wait`/`wait-output` stop+blocked signals, and real Approvals Inbox items,
* on par with Claude's hooks rather than with output stabilization.
*
* This is an interface implementation, not an impersonation: we implement the
* one verb (`pane report-agent`) that the contract defines, and nothing on the
* machine ever executes a real `herdr` binary — `HERDR_BIN_PATH` is our own
* script, in our own data dir. `HERDR_ENV=1` is the flag the TUI checks to know
* a supervisor is present; a supervisor IS present, it is Codeman.
*
* ## Why it is generated rather than committed
*
* The shim must be an executable file at a stable absolute path in every
* install shape: a git clone (where `scripts/` exists), an `npm i -g aicodeman`
* (where `files` ships only `dist` plus two named scripts), and any
* `CODEMAN_INSTANCE`. Writing it into the data dir at session-create time makes
* one code path cover all of them, single-sources the content here in TS, and
* follows the precedent of `self-update-runner.sh`. It is rewritten whenever the
* embedded version marker changes, so an upgraded Codeman refreshes a stale shim
* without the user knowing it exists.
*
* @module deepseek-status-shim
*/
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from './config/instance.js';
/**
* Bumped whenever SHIM_SOURCE changes. The marker is embedded in the generated
* file, so `ensureDeepSeekStatusShim()` can tell a current shim from one written
* by an older Codeman and rewrite only when needed (rather than rewriting on
* every session create, or — worse — leaving a stale one in place forever).
*/
const SHIM_VERSION = 3;
const SHIM_MARKER = `codeman-dsh-status-shim v${SHIM_VERSION}`;
/**
* Mapping from the harness's three lifecycle states to Codeman hook events.
*
* - `blocked` -> `permission_prompt`: the TUI reports blocked when a tool
* approval or an `ask_user_question` questionnaire is on screen, which is
* exactly the red "needs you" alert and an answerable Approvals Inbox item.
* - `idle` -> `stop`: the definitive end-of-turn signal, the one respawn and the
* wait endpoints care about.
* - `working` -> `agent_working`: a turn STARTED. Codeman infers "working" from
* PTY output well enough on its own, but the event is what RESOLVES a pending
* approval when the user answers a dialog in the terminal instead of in the
* inbox. Without it a dsh session's red alert would survive until the next
* `stop`, which is 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, so it cannot help here).
*/
export const DEEPSEEK_STATE_TO_HOOK_EVENT: Readonly<Record<string, string>> = Object.freeze({
idle: 'stop',
blocked: 'permission_prompt',
working: 'agent_working',
});
/**
* The generated script.
*
* Constraints it must satisfy, each learned from an existing Codeman hook bug:
* - **TLS**: `CODEMAN_API_URL` is loopback HTTPS with a self-signed cert on
* `--https`/tailscale installs, so certificate verification is disabled for
* the request. Without this the whole bridge dies silently, exactly as the
* claude hook curls did before they grew `-k`.
* - **Secret**: the hook-secret file is read AT EXECUTION TIME, never baked in,
* so rotation needs no respawn and the value never lands on a command line.
* - **Exit codes**: 0 means delivered. Anything else makes the TUI retry with
* backoff, so transport failures self-heal, but an unknown verb or an
* unmapped state exits 0 to avoid a pointless retry storm over something that
* will never succeed.
* - **Timeout**: bounded below the caller's own 2s budget, so we lose the race
* deliberately rather than being killed mid-flight.
*/
const SHIM_SOURCE = `#!/usr/bin/env node
// ${SHIM_MARKER}
// GENERATED BY CODEMAN — do not edit. Rewritten from src/deepseek-status-shim.ts
// whenever its version marker changes.
//
// Implements the one verb the DeepSeek Harness TUI's supervisor contract uses:
// pane report-agent <paneId> --state <idle|working|blocked> [--message <t>] ...
// and forwards it to this Codeman instance as a hook event.
import { readFileSync } from 'node:fs'
import http from 'node:http'
import https from 'node:https'
const STATE_TO_EVENT = ${JSON.stringify(DEEPSEEK_STATE_TO_HOOK_EVENT)}
const TIMEOUT_MS = 1500
const argv = process.argv.slice(2)
const flag = (name) => {
const i = argv.indexOf(name)
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined
}
// Unknown verb: succeed silently. Retrying could never make it succeed, and a
// non-zero exit here would make the caller retry four times per state change.
if (argv[0] !== 'pane' || argv[1] !== 'report-agent') process.exit(0)
const event = STATE_TO_EVENT[String(flag('--state') ?? '')]
if (!event) process.exit(0)
// The pane id we hand the TUI IS the Codeman session id, but prefer the ambient
// env: it is set by the same code that set HERDR_PANE_ID, so a TUI that mangles,
// truncates or re-uses the pane argument still reports against the right session.
// NOT a security boundary, and do not read it as one: the agent runs IN this pane
// and can invoke the shim with CODEMAN_SESSION_ID unset and any argv it likes.
// That buys it nothing it did not already have, since the hook-secret file is
// readable from the same pane and any process there can POST /api/hook-event
// directly. Attribution here is about accidents, not adversaries.
const sessionId = process.env.CODEMAN_SESSION_ID || argv[2]
const apiUrl = process.env.CODEMAN_API_URL
if (!sessionId || !apiUrl) process.exit(1)
let secret = ''
try {
secret = readFileSync(process.env.CODEMAN_HOOK_SECRET_FILE || '', 'utf-8').trim()
} catch {
// Missing file: the loopback bypass still applies when no tunnel is running.
}
// The contract's ordering token: the TUI retries failed deliveries with
// backoff, so a stale report can land AFTER a newer one. Forwarded so the
// server can drop out-of-order arrivals instead of, say, resolving an
// approval with a retried 'working' while the harness sits blocked.
const seq = Number(flag('--seq'))
const body = JSON.stringify({
event,
sessionId,
data: {
source: 'dsh-status-shim',
agent: flag('--agent') || 'dsh',
...(Number.isFinite(seq) ? { seq } : {}),
...(flag('--message') ? { message: flag('--message') } : {}),
},
})
let url
try {
url = new URL('/api/hook-event', apiUrl)
} catch {
process.exit(1)
}
const transport = url.protocol === 'https:' ? https : http
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
timeout: TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-Codeman-Hook-Secret': secret,
},
// Loopback HTTPS with a self-signed cert (--https / tailscale installs).
rejectUnauthorized: false,
},
(res) => {
res.resume()
const status = res.statusCode ?? 0
// 2xx: delivered. 4xx: PERMANENT — a 401 (missing/rotated secret) or 429
// can never be fixed by retrying, and each retry feeds the auth-failure
// rate-limit bucket, so a single misconfigured dsh session could 429 the
// hook endpoint for the whole instance (killing every claude session's
// real hooks). Exit 0 so the TUI does not retry; only transport errors
// and 5xx stay retryable.
process.exit(status >= 200 && status < 500 ? 0 : 1)
}
)
req.on('timeout', () => {
req.destroy()
process.exit(1)
})
req.on('error', () => process.exit(1))
req.end(body)
`;
/** Absolute path of the generated shim for this instance. */
export function deepSeekStatusShimPath(): string {
return dataPath('dsh-status-shim.mjs');
}
let ensuredThisProcess = false;
/**
* Write the shim if it is missing or stale, and return its path.
*
* Idempotent and cheap: after the first call in a process it does nothing, and
* even the first call only rewrites when the on-disk marker differs. Never
* throws — a data dir that cannot be written is a degraded status bridge, not a
* failed session start, so callers fall back to output-stabilization readiness
* by receiving null.
*/
export function ensureDeepSeekStatusShim(): string | null {
const path = deepSeekStatusShimPath();
if (ensuredThisProcess) return path;
try {
let current = '';
try {
current = readFileSync(path, 'utf-8');
} catch {
// Missing — fall through to the write.
}
if (!current.includes(SHIM_MARKER)) {
mkdirSync(dirname(path), { recursive: true });
// Temp + rename, not a plain write: the TUI can be executing this exact
// path at the moment an upgraded Codeman refreshes it (every state change
// runs it, and session create is when the rewrite happens), and a reader
// that catches a half-written file gets a syntax error, exits non-zero,
// and is retried four times per state change for a file that will never
// parse. rename(2) is atomic within the directory, so a concurrent exec
// sees either the old shim or the new one, never a truncated one.
// Same reasoning as the state-store writes; pid-suffixed so two instances
// sharing a data dir cannot collide on the temp name.
const tempPath = `${path}.${process.pid}.tmp`;
try {
writeFileSync(tempPath, SHIM_SOURCE, { mode: 0o700 });
// The mode argument only applies when writeFileSync CREATES the file, so
// a leftover temp from a crashed run would keep its old permissions.
chmodSync(tempPath, 0o700);
renameSync(tempPath, path);
} catch (err) {
rmSync(tempPath, { force: true });
throw err;
}
}
// Re-assert the mode even when the content matched: a shim that lost its
// executable bit (a restored backup, a copied data dir) would make every
// report fail, and the TUI would retry four times per state change forever.
chmodSync(path, 0o700);
ensuredThisProcess = true;
return path;
} catch (err) {
console.warn(`[DeepSeek] Could not install the status shim at ${path}: ${(err as Error).message}`);
return null;
}
}
/** Test seam: forget the per-process memo so a fresh temp HOME is re-provisioned. */
export function resetDeepSeekStatusShimForTest(): void {
ensuredThisProcess = false;
}
+283
View File
@@ -0,0 +1,283 @@
/**
* @fileoverview Supervises the one background `dsh web` process behind the Run
* menu's "DeepSeek web UI..." entry.
*
* The shortcut originally started the server inside an ordinary SHELL SESSION,
* on the reasoning that Codeman already knows how to supervise those: it was
* visible, scrollable, killable, and died with its tab, and nothing new had to
* own a long-lived HTTP server. That reasoning was sound and the result was
* still wrong in use — clicking "open the DeepSeek web UI" spawned a terminal
* tab the user never asked for, next to the web tab they did, and the terminal
* was noise every time after the first.
*
* So the server moves here instead: one child process, no session, no tab.
* What that buys back has to be paid for explicitly, which is what this module
* is:
*
* - **Exactly one.** A second click reuses the running server rather than
* racing it for a port. The old shell-session flow could not do this at all,
* because two clicks were simply two sessions.
* - **Restarted when the authority changes.** `--trusted-host` fences dsh's
* `/api` against the browser authority, and a Codeman reachable at both
* loopback and a tailnet name has two. Whoever asks last wins, because the
* asker is by definition the origin about to load the page.
* - **Killed on shutdown.** A detached child that outlived Codeman would hold
* its port against the next start, which is exactly the EADDRINUSE this
* feature already got wrong once.
* - **Failures reported, not swallowed.** The shell tab used to be where the
* stack trace landed. With no tab, the spawn's own output is captured and
* handed back to the caller instead.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { createServer } from 'node:net';
import { join } from 'node:path';
import { getErrorMessage } from './types.js';
/**
* Where the port search starts, and how far it walks.
*
* 3080 is `dsh web`'s own default, so it is the friendly first choice — and
* emphatically not a fixed port. DeepSeek's web UI is a thing users run
* themselves, which makes the default precisely the port most likely to be
* taken already; hardcoding it made this feature die with EADDRINUSE against
* the user's own server.
*/
const PORT_BASE = 3080;
const PORT_SPAN = 40;
/** How long a freshly spawned server gets to answer before we call it failed. */
const READY_TIMEOUT_MS = 30_000;
const READY_POLL_MS = 400;
/** Grace between SIGTERM and SIGKILL when stopping the tree. */
const KILL_GRACE_MS = 3_000;
/** Bound on captured child output, so a chatty boot cannot grow without limit. */
const OUTPUT_CAP = 16_384;
export interface DeepSeekWebStatus {
running: boolean;
port: number | null;
url: string | null;
/** Browser authority this server was started to trust (`--trusted-host`). */
authority: string | null;
}
interface RunningServer {
child: ChildProcess;
port: number;
authority: string;
output: () => string;
}
let current: RunningServer | null = null;
/**
* True when nothing holds `port` on loopback.
*
* Binding is the only honest test: a connect probe cannot tell "free" from
* "listening but not answering yet", and this runs moments before `dsh web`
* binds the same port. It is inherently racy, which is why the caller still
* waits for the server to actually answer before reporting success.
*/
async function isLoopbackPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const probe = createServer();
probe.once('error', () => resolve(false));
probe.once('listening', () => probe.close(() => resolve(true)));
probe.listen(port, '127.0.0.1');
});
}
async function findFreePort(): Promise<number | null> {
for (let port = PORT_BASE; port < PORT_BASE + PORT_SPAN; port++) {
if (await isLoopbackPortFree(port)) return port;
}
return null;
}
/** Does the server answer HTTP yet? Any status counts: dsh may 4xx a bare GET. */
async function answersHttp(port: number): Promise<boolean> {
try {
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2_000) });
return true;
} catch {
return false;
}
}
/**
* Signal the whole process group.
*
* `dsh web` boots a plugin tree and fans out, so signalling only the direct
* child leaves survivors holding the port. Same negative-pid escalation as
* `runGit()` in git-clone.ts and the profile installer.
*/
function killTree(child: ChildProcess, signal: NodeJS.Signals): void {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
}
export function getDeepSeekWebStatus(): DeepSeekWebStatus {
if (!current) return { running: false, port: null, url: null, authority: null };
return {
running: true,
port: current.port,
url: `http://127.0.0.1:${current.port}`,
authority: current.authority,
};
}
/** Stop the background server, if one is running. Safe to call when none is. */
export async function stopDeepSeekWeb(): Promise<void> {
const running = current;
current = null;
if (!running) return;
await new Promise<void>((resolve) => {
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(hard);
resolve();
};
running.child.once('exit', finish);
killTree(running.child, 'SIGTERM');
const hard = setTimeout(() => {
killTree(running.child, 'SIGKILL');
finish();
}, KILL_GRACE_MS);
});
}
type StartResult = { ok: true; port: number; url: string; reused: boolean } | { ok: false; error: string };
/**
* Serializes concurrent starts. Two POSTs racing (two devices, or a double
* click while the first boots) used to both see `current === null`, pick the
* SAME free port, and spawn twice: the loser died on EADDRINUSE while its exit
* handler nulled the singleton out from under the winner, leaving a live
* `dsh web` nothing tracked or killed — the exact orphan this module exists to
* prevent. The second caller now simply waits and reuses the first's server.
*/
let startLock: Promise<unknown> = Promise.resolve();
/**
* Start (or reuse) the background `dsh web` for `authority`.
*
* @param dshDir directory holding the resolved `dsh` binary.
* @param authority browser authority to pass as `--trusted-host`.
*/
export function startDeepSeekWeb(dshDir: string, authority: string): Promise<StartResult> {
const run = startLock.then(
() => startDeepSeekWebLocked(dshDir, authority),
() => startDeepSeekWebLocked(dshDir, authority)
);
startLock = run.then(
() => undefined,
() => undefined
);
return run;
}
async function startDeepSeekWebLocked(dshDir: string, authority: string): Promise<StartResult> {
// Reuse only when the running server is BOTH healthy and fenced for the
// authority now asking. A server trusting the other origin renders a page
// whose every API call 403s, which looks like a broken dashboard rather than
// a misconfigured one.
if (current) {
if (current.authority === authority && (await answersHttp(current.port))) {
return { ok: true, port: current.port, url: `http://127.0.0.1:${current.port}`, reused: true };
}
await stopDeepSeekWeb();
}
const port = await findFreePort();
if (port === null) {
return { ok: false, error: `No free port for the DeepSeek web UI in ${PORT_BASE}-${PORT_BASE + PORT_SPAN - 1}` };
}
let child: ChildProcess;
try {
child = spawn(
join(dshDir, 'dsh'),
['web', '--no-open', '--host', '127.0.0.1', '--port', String(port), '--trusted-host', authority],
{
stdio: ['ignore', 'pipe', 'pipe'],
// Own process group so the whole plugin tree can be signalled at once.
detached: true,
env: process.env,
}
);
} catch (err) {
return { ok: false, error: `Failed to start dsh web: ${getErrorMessage(err)}` };
}
// The pipes must be drained whether or not anyone reads them: a full pipe
// blocks the child. Storage is capped; draining is not.
let output = '';
const capture = (chunk: Buffer) => {
if (output.length < OUTPUT_CAP) output += chunk.toString('utf-8');
};
child.stdout?.on('data', capture);
child.stderr?.on('data', capture);
let exited = false;
child.once('exit', () => {
exited = true;
// Only clear if this is still the current server: a restart may have
// already replaced it, and clearing then would drop the live one.
if (current?.child === child) current = null;
});
child.once('error', () => {
exited = true;
if (current?.child === child) current = null;
});
const running: RunningServer = { child, port, authority, output: () => output };
current = running;
const deadline = Date.now() + READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (exited) {
// Guarded like the exit/error handlers: a concurrent stop (DELETE route,
// shutdown) may already have cleared or replaced the singleton, and an
// unconditional null here would drop a server this call does not own.
if (current === running) current = null;
const tail = output.trim().slice(-800);
return { ok: false, error: tail ? `dsh web exited during startup: ${tail}` : 'dsh web exited during startup' };
}
if (await answersHttp(port)) {
return { ok: true, port, url: `http://127.0.0.1:${port}`, reused: false };
}
await new Promise((r) => setTimeout(r, READY_POLL_MS));
}
// Timeout: kill OUR child. Only route through stopDeepSeekWeb() while the
// singleton is still ours — signalling `current` unconditionally here could
// SIGTERM a healthy server a concurrent actor now owns.
if (current === running) {
await stopDeepSeekWeb();
} else {
killTree(running.child, 'SIGKILL');
}
const tail = output.trim().slice(-800);
return {
ok: false,
error: tail
? `dsh web did not answer on port ${port} within ${READY_TIMEOUT_MS / 1000}s: ${tail}`
: `dsh web did not answer on port ${port} within ${READY_TIMEOUT_MS / 1000}s`,
};
}
/** Test seam: forget any tracked child without signalling it. */
export function resetDeepSeekWebForTest(): void {
current = null;
}
+12
View File
@@ -146,6 +146,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
antigravity: 'exec agy',
pi: 'exec pi',
grok: 'exec grok',
deepseek: 'exec dsh',
};
return commands[mode as DockerCommandMode] || commands.shell;
}
@@ -625,6 +626,17 @@ const CRED_STORES: CredStorePolicy[] = [
rel: '.grok',
seedFiles: ['auth.json', 'config.toml', 'pager.toml'],
},
// DeepSeek Harness keeps credentials in `~/.dsh/.env` (0600) and composition in
// `settings.yaml` / `cordis.patch.yml`. `profiles/` is deliberately NOT seeded:
// it is a pnpm workspace holding a full node_modules tree per profile, which is
// both enormous and host-arch-specific. An in-container dsh therefore needs its
// profile installed IN the image (see docker/agent.Dockerfile), and the seeded
// files only supply auth and model composition. Same host-invisibility trade-off
// as pi and grok: `~/.dsh/sessions` inside a container is that container's own.
{
rel: '.dsh',
seedFiles: ['.env', 'settings.yaml', 'cordis.patch.yml'],
},
{ rel: '.config/gcloud', seedWhole: true },
{ rel: '.config/opencode', seedWhole: true },
];
+3
View File
@@ -20,6 +20,7 @@ import type {
AntigravityConfig,
PiConfig,
GrokConfig,
DeepSeekConfig,
SessionRemote,
SessionDocker,
} from './types.js';
@@ -80,6 +81,7 @@ export interface CreateSessionOptions {
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
@@ -113,6 +115,7 @@ export interface RespawnPaneOptions {
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
+4
View File
@@ -115,6 +115,10 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
antigravity: remoteLoginShellCommand('agy'),
pi: remoteLoginShellCommand('pi'),
grok: remoteLoginShellCommand('grok'),
// `dsh` alone boots nothing: the launcher needs a profile, and the remote box's
// profile inventory is unknown here. The per-host `commands.deepseek` override
// is the escape hatch for naming one.
deepseek: remoteLoginShellCommand('dsh'),
};
return commands[mode as RemoteCommandMode] || commands.shell;
}
+40 -2
View File
@@ -52,6 +52,7 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
type SessionRemote,
type SessionDocker,
} from './types.js';
@@ -178,7 +179,8 @@ export function isExternalCliMode(mode: SessionMode): boolean {
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok'
mode === 'grok' ||
mode === 'deepseek'
);
}
@@ -196,6 +198,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Pi';
case 'grok':
return 'Grok';
case 'deepseek':
return 'DeepSeek';
case 'shell':
return 'Shell';
case 'claude':
@@ -521,6 +525,9 @@ export class Session extends EventEmitter {
private _piConfig: PiConfig | undefined;
// Grok configuration (only for mode === 'grok')
private _grokConfig: GrokConfig | undefined;
// DeepSeek Harness configuration (only for mode === 'deepseek')
private _deepSeekConfig: DeepSeekConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -618,6 +625,8 @@ export class Session extends EventEmitter {
piConfig?: PiConfig;
/** Grok configuration (only for mode === 'grok') */
grokConfig?: GrokConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
@@ -727,6 +736,11 @@ export class Session extends EventEmitter {
this._piConfig = config.piConfig;
}
// Apply DeepSeek Harness configuration
if (config.deepSeekConfig) {
this._deepSeekConfig = config.deepSeekConfig;
}
// Apply Grok configuration
if (config.grokConfig) {
this._grokConfig = config.grokConfig;
@@ -879,6 +893,20 @@ export class Session extends EventEmitter {
return this._remote;
}
/**
* `deepSeekConfig.statusReporting` verbatim: `undefined` when the caller sent
* none (i.e. ON), `false` when the user disarmed the status bridge for this
* session.
*
* Exposed because whether a dsh session can deliver `stop`/`blocked` is a
* per-SESSION fact, not a per-mode one, and `hooksAvailableForMode()` is pure
* and holds no `Session` reference by design. Undefined for every other mode,
* where the flag is meaningless.
*/
get deepSeekStatusReporting(): boolean | undefined {
return this._deepSeekConfig?.statusReporting;
}
/** Owning username in multi-user mode, else undefined. */
get owner(): string | undefined {
return this._owner;
@@ -1325,6 +1353,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
@@ -1501,7 +1530,8 @@ export class Session extends EventEmitter {
this.mode === 'gemini' ||
this.mode === 'antigravity' ||
this.mode === 'pi' ||
this.mode === 'grok'
this.mode === 'grok' ||
this.mode === 'deepseek'
),
})
);
@@ -1572,6 +1602,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1831,6 +1862,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1924,6 +1956,12 @@ export class Session extends EventEmitter {
if (this.mode === 'grok') {
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.');
}
// DeepSeek sessions require tmux for DEEPSEEK_API_KEY / DSH_PERMISSION_MODE
// injection via setenv — and for the HERDR_* status-bridge triple, without
// which the mode silently loses its definitive idle/blocked signals.
if (this.mode === 'deepseek') {
throw new Error('DeepSeek Harness sessions require tmux. Direct PTY fallback is not supported.');
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
+146 -2
View File
@@ -53,6 +53,7 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
@@ -95,6 +96,9 @@ import {
getPiNotFoundMessage,
resolveGrokDir,
getGrokNotFoundMessage,
resolveDeepSeekDir,
getDeepSeekNotFoundMessage,
resolveDefaultDeepSeekProfile,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
@@ -119,6 +123,7 @@ import {
// ============================================================================
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
/** How long a cached process snapshot stays usable. */
const PROC_SNAPSHOT_TTL_MS = 2000;
@@ -846,6 +851,51 @@ function buildGrokCommand(config?: GrokConfig): string {
return parts.join(' ');
}
/**
* Build the DeepSeek Harness (`dsh`) command with appropriate flags.
*
* Unlike every sibling builder, the interesting decision here is not a flag but
* WHICH PROFILE to boot: `dsh` is a launcher over `$DSH_HOME/profiles/<name>`,
* and DeepSeek ships no interactive terminal profile of its own, so the agent a
* pane runs is always one the user installed. An absent `profile` resolves to
* the first pane-capable profile on the box; when there is none we still emit a
* bare `dsh --profile <default>` rather than inventing a name, because the
* availability gate in createSession() has already refused the spawn by then and
* this path only runs for a session that passed it.
*
* There is deliberately NO permission flag: the harness has none. The sandbox
* and approval rows read `DSH_PERMISSION_MODE`, exported through `tmux setenv`
* in buildEnvExports() so it never lands on this command line.
*
* Like the sibling builders, every user value is regex-allowlisted and silently
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
*/
function buildDeepSeekCommand(config?: DeepSeekConfig): string {
const parts = ['dsh'];
// A profile name is a single path segment: it is both interpolated into the
// shell line and joined into a filesystem path.
const requested = config?.profile;
const safeProfile =
requested && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(requested)
? requested
: (resolveDefaultDeepSeekProfile() ?? undefined);
if (safeProfile) parts.push('--profile', safeProfile);
// The launcher forwards everything after its own flags to the profile's app,
// which is where `--resume` is understood. An explicit id wins over the
// most-recent-session form, mirroring the sibling builders.
const safeSessionId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeSessionId) {
parts.push('--resume', safeSessionId);
} else if (config?.resumeSession) {
parts.push('--resume');
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -890,6 +940,7 @@ export function buildSpawnCommand(options: {
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
@@ -942,6 +993,9 @@ export function buildSpawnCommand(options: {
if (options.mode === 'grok') {
return buildGrokCommand(options.grokConfig);
}
if (options.mode === 'deepseek') {
return buildDeepSeekCommand(options.deepSeekConfig);
}
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
// so a `$SHELL` here is expanded by the SERVER process's shell against the
@@ -1159,6 +1213,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
return `${modeCommand} --session ${resumeId}`;
case 'grok':
return `${modeCommand} --resume ${resumeId}`;
case 'deepseek':
return `${modeCommand} --resume ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
@@ -1749,10 +1805,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
...(mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek'
? ['unset NO_COLOR']
: []),
// Stamp each Codex pane with a unique originator so the response-viewer
@@ -1853,6 +1919,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolveGrokDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'deepseek') {
const dir = resolveDeepSeekDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
@@ -1883,6 +1953,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
setGeminiEnvVars(this.tmux(), muxName);
}
/**
* Configure DeepSeek Harness environment on a tmux session.
*
* Two independent things, both via `tmux setenv` so they are inherited by the
* pane without appearing in `ps`:
*
* 1. `DSH_PERMISSION_MODE` — the harness's only permission input. Exported
* ONLY when the caller sent one, so an absent config lands on the harness's
* own `workspace-write` default (which asks) rather than on ours. That
* "only if sent" shape is what the multi-user clamp relies on.
* 2. The `HERDR_*` triple — the supervisor contract the terminal front door
* uses to report idle/working/blocked. Pointing `HERDR_BIN_PATH` at our own
* generated shim is what upgrades this mode from output-stabilization
* guessing to definitive hook events (see deepseek-status-shim.ts). The
* pane id IS the Codeman session id, which is how the shim attributes a
* report without trusting anything the agent could influence.
*
* Also forwards DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from the server env when
* present, matching the codex/gemini precedent for headless auth.
*/
private _configureDeepSeek(muxName: string, sessionId: string, config?: DeepSeekConfig): void {
const tmuxCmd = this.tmux();
const setenv = (key: string, value: string): void => {
const escaped = value.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical */
}
};
for (const key of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DSH_HOME']) {
const val = process.env[key];
if (val) setenv(key, val);
}
// Enum-validated at the schema boundary; re-checked here because this value
// reaches a shell line, and a builder must never trust its caller.
if (
config?.permissionMode &&
['read-only', 'workspace-write', 'danger-full-access'].includes(config.permissionMode)
) {
setenv('DSH_PERMISSION_MODE', config.permissionMode);
}
if (config?.statusReporting !== false) {
const shim = ensureDeepSeekStatusShim();
if (shim) {
setenv('HERDR_ENV', '1');
setenv('HERDR_BIN_PATH', shim);
setenv('HERDR_PANE_ID', sessionId);
}
}
}
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session).
@@ -1903,6 +2032,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1963,6 +2093,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'pi' && !cliDir) {
throw new Error(getPiNotFoundMessage());
}
if (mode === 'deepseek' && !cliDir) {
throw new Error(getDeepSeekNotFoundMessage());
}
if (mode === 'grok' && !cliDir) {
throw new Error(getGrokNotFoundMessage());
}
@@ -1981,6 +2114,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2049,6 +2183,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'gemini') {
this._configureGemini(muxName);
}
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
if (mode === 'deepseek') {
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
}
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
// so secret values stay off the bash command line. Must run before respawn-pane.
@@ -2206,6 +2344,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2236,6 +2375,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2260,6 +2400,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'gemini') {
this._configureGemini(muxName);
}
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
if (mode === 'deepseek') {
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
}
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
+1
View File
@@ -1014,6 +1014,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string
{ id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' },
{ id: 'pi', label: 'pi', detail: 'pi.dev' },
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' },
{ id: 'deepseek', label: 'deepseek', detail: 'DeepSeek Harness (dsh)' },
];
// ─────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -149,7 +149,7 @@ export type TuiAnswerResult =
export interface TuiQuickStartOptions {
caseName: string;
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek';
sessionName?: string;
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
parentSessionId?: string;
+5 -1
View File
@@ -109,7 +109,11 @@ export type HookEventType =
| 'elicitation_response'
| 'stop'
| 'teammate_idle'
| 'task_completed';
| 'task_completed'
// No Claude Code hook behind this one: it is the DeepSeek status bridge's
// "a turn STARTED" report (see deepseek-status-shim.ts). Keep in step with
// HookEventSchema in web/schemas.ts.
| 'agent_working';
// ========== API Response Types ==========
+71 -4
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' (which CLI backend)
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -17,6 +17,7 @@
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
*
* Cross-domain relationships:
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
@@ -45,11 +46,20 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
/** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
export type SessionMode =
| 'claude'
| 'shell'
| 'opencode'
| 'codex'
| 'gemini'
| 'antigravity'
| 'pi'
| 'grok'
| 'deepseek';
export type RemoteCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'
>;
/**
@@ -158,7 +168,7 @@ export interface RemoteSessionInfo {
/** Which CLI backends a Docker case can run (same set as remote). */
export type DockerCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'
>;
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
@@ -388,6 +398,61 @@ export interface GrokConfig {
resumeSessionId?: string;
}
/**
* DeepSeek Harness (`dsh`) session configuration.
*
* Two things make this config shaped unlike every sibling above it.
*
* **1. The agent is a PROFILE, not the binary.** `dsh` is a launcher: it boots
* `$DSH_HOME/profiles/<name>`, an ordered stack of plugin-bundle patch layers.
* DeepSeek ships only `web`, `headless` and `base`, so the interactive terminal
* agent is always a third-party profile the user installed. `profile` is
* therefore the primary knob, and an absent one resolves to the first
* pane-capable profile found (see resolveDefaultDeepSeekProfile).
*
* **2. Permissions are an ENV VAR, not a flag.** The harness has no
* `--dangerously-skip-permissions` equivalent; its sandbox and approval rows are
* config, driven by one documented input, `DSH_PERMISSION_MODE`, with three
* presets (measured from `dsh --dump-default-config`):
*
* read-only sandbox read-only, approval ask
* workspace-write sandbox workspace-write, approval ask <- default
* danger-full-access sandbox danger-full-access, approval never
*
* This is the one place a Codeman env export is the RIGHT mechanism rather than
* the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks
* in-session `/effort`), `DSH_PERMISSION_MODE` is read with `??` as a boot-time
* DEFAULT, so it stays a soft default the user can still change in-session. It
* is exported via `tmux setenv`, never on the spawn command line.
*/
export interface DeepSeekConfig {
/**
* Profile under `$DSH_HOME/profiles` to boot (`dsh --profile <name>`). Absent
* = the first pane-capable profile installed. A `web`/`headless` profile is
* refused at spawn time: neither can drive an interactive pane.
*/
profile?: string;
/**
* Sandbox + approval preset, exported as `DSH_PERMISSION_MODE`. Absent = the
* harness's own `workspace-write` default, which still ASKS — which is why the
* multi-user clamp only needs the only-if-sent branch here, like
* codex/antigravity/grok rather than pi.
*/
permissionMode?: 'read-only' | 'workspace-write' | 'danger-full-access';
/** Resume the most recent session for this workspace (`--resume`). */
resumeSession?: boolean;
/** Resume a specific session by ID (`--resume <id>`). Wins over resumeSession. */
resumeSessionId?: string;
/**
* Report idle/working/blocked back to Codeman through the Herdr-compatible
* status shim (see `deepseek-status-shim.ts`). Default ON: it upgrades this
* mode from output-stabilization guessing to definitive hook events. Only
* TUIs that implement the contract report; for one that does not, this is
* inert rather than harmful.
*/
statusReporting?: boolean;
}
/**
* Configuration for creating a new session
*/
@@ -553,6 +618,8 @@ export interface SessionState {
piConfig?: PiConfig;
/** Grok-specific configuration (only for mode === 'grok') */
grokConfig?: GrokConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
+16
View File
@@ -32,6 +32,16 @@
export type WebviewEmbedMode = 'proxy' | 'direct';
/** A saved dashboard, persisted to `~/.codeman/webviews.json`. */
/**
* Dashboards Codeman creates and maintains on the user's behalf.
*
* A managed record is hidden from the saved-dashboard list, because the shortcut
* that maintains it is already a menu entry of its own: listing both showed the
* same dashboard twice, once as "DeepSeek web UI..." and once as the row it had
* just written.
*/
export type WebviewManagedKind = 'deepseek-web';
export interface Webview {
id: string;
/** Display name shown on the tab. */
@@ -49,6 +59,12 @@ export interface Webview {
* cookies/localStorage, only for dashboards the user fully trusts.
*/
trusted: boolean;
/**
* Set when Codeman owns this record rather than the user (see
* `WebviewManagedKind`). Managed rows are maintained by the shortcut that
* created them, including repointing the URL when the port changes.
*/
managed?: WebviewManagedKind;
/** Multi-user owner (username). Undefined in single-user mode. */
owner?: string;
createdAt: number;
+401
View File
@@ -0,0 +1,401 @@
/**
* @fileoverview Resolve the DeepSeek Harness CLI (`dsh`) binary and its bootable profiles.
*
* Mirrors pi-cli-resolver.ts / grok-cli-resolver.ts, but the identity probe here
* is STRICTER than either, and deliberately so: `dsh` is not merely a short name
* with npm squatters, it is an EXISTING, widely packaged Unix program. Debian and
* Ubuntu ship `dsh` = "dancer's shell" / distributed shell (`apt install dsh`),
* which like nearly every Unix tool prints a version-shaped string of its own.
* A version-token probe alone (which is all pi and grok need) would
* therefore ACCEPT dancer's shell as the DeepSeek Harness and hand it to a spawn
* line, so every candidate must additionally prove its identity by printing the
* harness's own help banner.
*
* Two probes per candidate, both bounded and both cached behind the shared
* resolver's positive/negative caching:
* 1. `dsh --help` must match DEEPSEEK_IDENTITY_REGEX (`DeepSeek Harness`)
* 2. `dsh --version` must yield a version token (real output: `0.1.1-rc.2`)
* Order matters: identity is checked FIRST, so a foreign `dsh` is rejected on the
* cheaper, more discriminating signal and never contributes a version number.
*
* `dsh` is a profile LAUNCHER, not an agent: `dsh --profile <name>` boots an
* ordered stack of plugin-bundle patch layers, and DeepSeek ships only `web`
* (browser UI), `headless` (one-shot) and `base` (no app). The interactive
* terminal agent Codeman actually drives is a THIRD-PARTY profile the user
* installs. That is why this module resolves two independent things — a binary
* AND a profile inventory — and why "available" for the deepseek run mode means
* both (`isDeepSeekRunnable`, and `resolveDeepSeekLaunchError` in session-routes.ts
* for the actionable per-half message).
*
* @module utils/deepseek-cli-resolver
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
type CliResolverHost,
} from './cli-executable-resolver.js';
/**
* Common directories where the `dsh` binary may be installed.
*
* `dsh` is an npm package (`@deepseek-ai/dsh`), so unlike grok there is no
* vendor-owned install dir to lead with: the global npm bin is wherever the
* user's prefix points. `~/.local/bin` heads the list because it is the default
* for a prefix-relocated npm (and is where this box's install landed).
*/
const DEEPSEEK_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* A real `dsh --version` prints a bare `0.1.1-rc.2` (measured, 0.1.1-rc.2), so
* the prerelease suffix is part of the token — truncating it to `0.1.1` would
* misreport a release-candidate as a release in `codeman doctor`.
*
* Exported and SHARED with the `dsh` entry in `config/dependency-registry.ts`,
* so the doctor and the run mode cannot disagree about what counts as an
* installed dsh (the same single-source rule as PI_VERSION_REGEX /
* GROK_VERSION_REGEX). Shape is dictated by the doctor's `extractVersion()`
* (first capture group, whole-output scan): hence a capturing group and a
* leading boundary instead of `^`. No `g` flag, so there is no shared
* `lastIndex` to reset.
*/
export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)/;
/**
* The identity marker that separates DeepSeek's `dsh` from Debian's dancer's
* shell. The real launcher's `--help` banner reads:
*
* dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle …
*
* Matched case-insensitively against the help output. This is the check that
* makes the resolver safe to point a spawn line at; see the module header.
*/
export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i;
const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh';
/** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */
export function resolveDshHome(): string {
const fromEnv = process.env.DSH_HOME?.trim();
return fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh');
}
/**
* What a profile is FOR, inferred from the bundles it composes.
*
* `interactive` is the only kind a tmux pane can drive: `web` serves a browser
* UI and would occupy the pane with a logging server, `headless` answers one
* task and exits (which reads as an instantly-dead pane). `unknown` is treated
* as interactive-capable on purpose — the whole point of the harness is that
* anyone can publish an app bundle, so an unrecognized third-party profile must
* not be hidden from the picker just because this list has not heard of it.
*/
export type DeepSeekProfileKind = 'interactive' | 'web' | 'headless' | 'unknown';
export interface DeepSeekProfile {
/** Directory name under `$DSH_HOME/profiles`, i.e. the `--profile` argument. */
name: string;
/** Bundle package names composed by the profile, in order. */
bundles: string[];
kind: DeepSeekProfileKind;
}
/** Bundles that positively identify a non-interactive profile. */
const WEB_BUNDLE_PATTERN = /dsh-web-app|dsh-web-frontend/i;
const HEADLESS_BUNDLE_PATTERN = /dsh-headless/i;
/**
* Bundles that positively identify a terminal app. Intentionally a loose
* community-wide pattern rather than one blessed package: the terminal front
* door is third-party by construction (DeepSeek ships none), and a dozen
* scoped `dsh-tui` packages from a dozen different authors compete. Anything
* matching is a TUI; anything unmatched is `unknown`, which still counts as
* launchable.
*
* `tui` carries word boundaries so the loose arm stays a TOKEN match: `-` and
* `/` are non-word characters, so `@someone/tui-app` and `dsh-tui` both match
* while `intuition` and `gratuitous` do not. Being wrong here is cheap (an
* unmatched profile is `unknown`, which is launchable too) but it decides which
* profile a session boots by DEFAULT, and "the one whose name happens to contain
* t-u-i" is not a rule anyone could predict.
*/
const TUI_BUNDLE_PATTERN = /dsh-tui|dsh-terminal-app|\btui\b/i;
/**
* The profile names DeepSeek itself ships for its non-interactive surfaces.
*
* Consulted only AFTER the bundle patterns have found nothing, and only against
* the directory name. `readProfile()` yields an empty bundle list for any
* `package.json` without a `dsh.profile.bundles` array — a hand-edited file, an
* older layout, a profile mid-install — and with no bundles to read, the stock
* `web` and `headless` profiles look exactly like an unrecognized third-party
* one and inherit its launchable-by-default treatment. That is the single
* "unknown" that is knowably wrong, and it produces precisely the
* pane-dies-on-arrival failure the two-part availability gate exists to prevent.
*
* Deliberately a fallback rather than a first check: a third-party profile that
* legitimately composes a terminal app is identified by its BUNDLES, and its
* directory name (which the user chose) must never override that evidence.
*/
const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
['web', 'web'],
['headless', 'headless'],
]);
/** Profile directory names that are not profiles. */
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
const haystack = [name, ...bundles].join(' ');
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
// web profile as far as a tmux pane is concerned, because the web app owns the
// process and blocks.
if (WEB_BUNDLE_PATTERN.test(haystack)) return 'web';
if (HEADLESS_BUNDLE_PATTERN.test(haystack)) return 'headless';
if (TUI_BUNDLE_PATTERN.test(haystack)) return 'interactive';
return STOCK_NON_INTERACTIVE_PROFILES.get(name.toLowerCase()) ?? 'unknown';
}
/**
* Read a single profile directory's `package.json` and return its bundle list.
* Returns null for anything that is not a readable dsh profile, so a stray
* directory under `profiles/` cannot break the inventory.
*/
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
try {
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
const rawBundles = parsed?.dsh?.profile?.bundles;
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
return { name, bundles, kind: classifyProfile(name, bundles) };
} catch {
return null;
}
}
/**
* Inventory the profiles installed under `$DSH_HOME/profiles`.
*
* Never throws: a missing DSH_HOME (dsh installed but never run) is an empty
* list, which the callers render as "no profile yet" rather than an error.
* Deliberately un-cached — a user can create a profile at any moment (including
* through Codeman's own bootstrap), and the directory scan is cheap next to the
* two process spawns the binary probe already costs.
*/
export function listDeepSeekProfiles(): DeepSeekProfile[] {
const profilesDir = join(resolveDshHome(), 'profiles');
let entries: string[];
try {
entries = readdirSync(profilesDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
.map((e) => e.name);
} catch {
return [];
}
return entries
.map((name) => readProfile(profilesDir, name))
.filter((p): p is DeepSeekProfile => p !== null)
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* The profile a session should boot when the user picked none.
*
* Prefers a positively-identified terminal profile, then an unrecognized one
* (third-party by construction — see TUI_BUNDLE_PATTERN), and refuses to fall
* back to `web`/`headless`, which cannot drive a pane. Returns null when nothing
* launchable is installed, which is what makes the mode report unavailable
* instead of spawning a pane that dies on arrival.
*/
export function resolveDefaultDeepSeekProfile(profiles: DeepSeekProfile[] = listDeepSeekProfiles()): string | null {
return (
profiles.find((p) => p.kind === 'interactive')?.name ?? profiles.find((p) => p.kind === 'unknown')?.name ?? null
);
}
/** True when the profile can occupy a tmux pane as an interactive agent. */
export function isLaunchableProfile(profile: DeepSeekProfile): boolean {
return profile.kind === 'interactive' || profile.kind === 'unknown';
}
/**
* Run the two-stage identity+version probe on a candidate path.
*
* Returns the version token only when the binary proves it is the DeepSeek
* Harness launcher. Returns null for anything else: a missing binary, a
* non-zero exit, a hang (timeout), a help banner without the harness marker
* (this is the dancer's-shell rejection), or output with no version-shaped
* token.
*
* Never runs under vitest: the suites must stay hermetic and must not depend on
* whether the dev box happens to have dsh installed — and since `dsh` names a
* real Debian program, this probe would EXECUTE whatever binary of that name the
* machine carries. The shared resolver host is already inert under vitest, so
* this gate is defense in depth for any opted-in host that still carries the
* default probe; tests drive resolution via `createDeepSeekResolverForTest`,
* whose injected probe bypasses it. Pinned by test/deepseek-cli-resolver.test.ts.
*/
function probeDeepSeekVersion(binPath: string): string | null {
if (process.env.VITEST) return null;
const run = (args: string[]): string | null => {
try {
return execFileSync(binPath, args, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// A stuck or hostile `dsh` that ignores SIGTERM would survive the timeout
// and block the server (execFileSync keeps waiting after the signal).
killSignal: 'SIGKILL',
}).trim();
} catch (err) {
console.warn(
`[DeepSeekResolver] Ignoring ${binPath}: "dsh ${args.join(' ')}" failed (${(err as Error).message})`
);
return null;
}
};
// Identity first — the discriminating signal, and the one that keeps Debian's
// dancer's shell out of a spawn line.
const help = run(['--help']);
if (help === null) return null;
if (!DEEPSEEK_IDENTITY_REGEX.test(help)) {
console.warn(
`[DeepSeekResolver] Ignoring ${binPath}: "dsh --help" is not the DeepSeek Harness launcher ` +
`(printed ${JSON.stringify(help.slice(0, 80))}). A different program named "dsh" (e.g. Debian's ` +
`dancer's shell) is earlier on PATH.`
);
return null;
}
const out = run(['--version']);
if (out === null) return null;
const candidate = DEEPSEEK_VERSION_REGEX.exec(out)?.[1];
if (candidate) return candidate;
console.warn(`[DeepSeekResolver] Ignoring ${binPath}: "dsh --version" printed ${JSON.stringify(out.slice(0, 80))}`);
return null;
}
type DeepSeekVersionProbe = (binPath: string) => string | null;
function createDeepSeekResolver(
host?: CliResolverHost,
versionProbe: DeepSeekVersionProbe = probeDeepSeekVersion,
now?: () => number
) {
return createCliExecutableResolver<string>(
{
binary: 'dsh',
searchDirs: DEEPSEEK_SEARCH_DIRS,
validateCandidate: (binPath) => {
const version = versionProbe(binPath);
return version ? { accepted: true, metadata: version } : { accepted: false };
},
now,
},
host
);
}
/**
* Creates an isolated DeepSeek wrapper around an injected host, version probe
* and clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe,
* which is exactly what the hermeticity test exercises.
*/
export function createDeepSeekResolverForTest(
host: CliResolverHost,
versionProbe?: DeepSeekVersionProbe,
now?: () => number
) {
return createDeepSeekResolver(host, versionProbe ?? probeDeepSeekVersion, now);
}
const deepSeekResolver = createDeepSeekResolver();
/**
* Finds the directory containing a verified `dsh` binary.
* Checks the server PATH first, then the common install locations. Every
* candidate must pass the identity+version probe before it is accepted.
*
* @returns Directory path, or null if not found
*/
export function resolveDeepSeekDir(): string | null {
return deepSeekResolver.resolve()?.directory ?? null;
}
/**
* Whether the `dsh` BINARY is installed. Note this is deliberately weaker than
* what the run mode needs: a dsh with no launchable profile cannot start a
* session. Callers gating the Run button want `isDeepSeekRunnable()`.
*/
export function isDeepSeekAvailable(): boolean {
return resolveDeepSeekDir() !== null;
}
/** Binary present AND at least one profile that can occupy a pane. */
export function isDeepSeekRunnable(): boolean {
return isDeepSeekAvailable() && resolveDefaultDeepSeekProfile() !== null;
}
export function getDeepSeekNotFoundMessage(): string {
return formatCliNotFoundMessage(DEEPSEEK_NOT_FOUND, deepSeekResolver.diagnostics());
}
/**
* Version reported by the resolved `dsh` binary, or null when dsh is
* unavailable. Surfaced through `GET /api/deepseek/status` so a misresolution
* is diagnosable from the UI.
*/
export function getDeepSeekCliVersion(): string | null {
return deepSeekResolver.resolve()?.metadata ?? null;
}
/** Does the named profile exist and can it drive a pane? */
export function profileExists(name: string): boolean {
return existsSync(join(resolveDshHome(), 'profiles', name, 'package.json'));
}
/**
* Why a DeepSeek session cannot start, or null when it can.
*
* Availability for this mode is TWO questions, not one, because `dsh` is a
* profile launcher rather than an agent: the binary must resolve (and prove it
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
* a pane must exist. Every create path — both HTTP routes AND cron fires — must
* ask this before constructing a Session, or the pane boots the box's default
* profile, which may be a logging web server or a one-shot that exits on
* arrival, and the prompt is typed into it.
*/
export function resolveDeepSeekLaunchError(requestedProfile?: string): string | null {
if (!isDeepSeekAvailable()) return getDeepSeekNotFoundMessage();
const profiles = listDeepSeekProfiles();
if (requestedProfile) {
const match = profiles.find((p) => p.name === requestedProfile);
if (!match) {
return `DeepSeek Harness profile "${requestedProfile}" does not exist. Create it with: dsh plugin --profile ${requestedProfile} add <package>`;
}
if (match.kind === 'web' || match.kind === 'headless') {
return `DeepSeek Harness profile "${requestedProfile}" is a ${match.kind} profile and cannot run in a terminal session. Pick an interactive profile, or open the web profile as a Codeman web tab.`;
}
return null;
}
if (!resolveDefaultDeepSeekProfile(profiles)) {
return (
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only the web and headless ' +
'profiles, so the terminal agent comes from a plugin — install one with: ' +
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
);
}
return null;
}
+13
View File
@@ -46,5 +46,18 @@ export {
} from './antigravity-cli-resolver.js';
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-cli-resolver.js';
export {
resolveDeepSeekDir,
isDeepSeekAvailable,
isDeepSeekRunnable,
getDeepSeekCliVersion,
getDeepSeekNotFoundMessage,
listDeepSeekProfiles,
resolveDefaultDeepSeekProfile,
isLaunchableProfile,
resolveDshHome,
profileExists,
} from './deepseek-cli-resolver.js';
export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js';
export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js';
+6 -1
View File
@@ -240,6 +240,7 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
[SSE_EVENTS.HOOK_AGENT_WORKING, '_onHookAgentWorking'],
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
@@ -2252,6 +2253,8 @@ class CodemanApp {
? 'Pi'
: mode === 'grok'
? 'Grok'
: mode === 'deepseek'
? 'DeepSeek'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
@@ -4805,7 +4808,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : ''}
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : ''}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span>
@@ -6238,6 +6241,8 @@ class CodemanApp {
? 'Kill Tmux & Pi'
: session.mode === 'grok'
? 'Kill Tmux & Grok'
: session.mode === 'deepseek'
? 'Kill Tmux & DeepSeek'
: 'Kill Tmux & Claude Code';
}
+1
View File
@@ -949,6 +949,7 @@ const SSE_EVENTS = {
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
HOOK_STOP: 'hook:stop',
HOOK_AGENT_WORKING: 'hook:agent_working',
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
HOOK_TASK_COMPLETED: 'hook:task_completed',
+1
View File
@@ -79,6 +79,7 @@ const HOME_SESSIONS_MODE_BADGE = {
antigravity: 'ag',
pi: 'pi',
grok: 'gk',
deepseek: 'ds',
};
Object.assign(CodemanApp.prototype, {
+1
View File
@@ -110,6 +110,7 @@
'Run Antigravity': '运行 Antigravity',
'Run Pi': '运行 Pi',
'Run Grok': '运行 Grok',
'Run DeepSeek': '运行 DeepSeek',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
+24 -1
View File
@@ -448,6 +448,10 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Grok
</button>
<button class="welcome-btn welcome-btn-deepseek" id="welcomeDeepSeekBtn" style="display: none;" onclick="app.setRunMode('deepseek'); app.runDeepSeek()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run DeepSeek
</button>
</div>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
@@ -630,6 +634,15 @@
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
<span class="run-mode-dot grok"></span>Grok
</button>
<button class="run-mode-option" data-mode="deepseek" onclick="app.setRunMode('deepseek')">
<span class="run-mode-dot deepseek"></span>DeepSeek
</button>
<!-- Shown only when `dsh` is installed but no pane-capable profile is:
DeepSeek ships no terminal front door, so the fix is an install,
not a greyed-out entry the user cannot act on. -->
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell
@@ -642,6 +655,14 @@
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
<span class="run-mode-dot web"></span>Add URL&hellip;
</button>
<!-- The DeepSeek Harness browser UI is the vendor's OWN interactive
surface (the terminal one is third-party), so it gets a shortcut:
POST /api/deepseek/web starts a background `dsh web` fenced to
this origin, and the URL opens as a managed web tab.
Shown only when dsh is installed. -->
<button class="run-mode-option run-mode-option--web" id="runModeDeepSeekWeb" style="display: none;" onclick="app.runDeepSeekWeb()">
<span class="run-mode-dot deepseek"></span>DeepSeek web UI&hellip;
</button>
<div class="run-mode-sep"></div>
<div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div>
@@ -908,6 +929,7 @@
<option value="antigravity">Antigravity</option>
<option value="pi">Pi</option>
<option value="grok">Grok</option>
<option value="deepseek">DeepSeek</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -2676,6 +2698,7 @@
<option value="antigravity" data-cli="antigravity">Antigravity</option>
<option value="pi" data-cli="pi">Pi</option>
<option value="grok" data-cli="grok">Grok</option>
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
<option value="shell">Shell (no agent)</option>
</select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
@@ -2816,7 +2839,7 @@
<div class="form-row">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok + tmux.</span>
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.</span>
</div>
<div class="form-row">
<label>Network</label>
+6
View File
@@ -55,6 +55,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
{ mode: 'pi', label: 'Pi', short: 'Pi' },
{ mode: 'grok', label: 'Grok', short: 'Grok' },
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
@@ -580,6 +581,11 @@ Object.assign(CodemanApp.prototype, {
menu.appendChild(header);
for (const webview of this.webviews ? this.webviews.values() : []) {
// Managed records are Codeman-owned shortcut state (the DeepSeek web UI
// writes one), not saved dashboards: same filter as the desktop run menu,
// or the phone picker lists a stale 127.0.0.1:<port> row that dies on the
// next server restart with no affordance here to restart it.
if (webview.managed) continue;
const option = document.createElement('button');
option.type = 'button';
option.className = 'mobile-overview-run-option';
+23
View File
@@ -986,6 +986,23 @@ html.mobile-init .file-browser-panel {
border-color: rgba(212, 212, 216, 0.5) !important;
}
/* DeepSeek mode colors on mobile. Same `!important` rationale as the pi and
grok blocks above: styles.css nests its skin rules inside
`html:not([data-skin="og"])`, so a bare `.btn-toolbar` rule there outranks a
`.btn-toolbar.btn-x` rule here regardless of load order. */
.btn-toolbar.btn-run.mode-deepseek,
.btn-toolbar.btn-run-gear.mode-deepseek {
background: #16225f !important;
border-color: rgba(124, 147, 255, 0.35) !important;
color: #eef2ff !important;
}
.btn-toolbar.btn-run.mode-deepseek:active,
.btn-toolbar.btn-run-gear.mode-deepseek:active {
background: #3350e6 !important;
border-color: rgba(150, 170, 255, 0.55) !important;
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -3075,6 +3092,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-deepseek, .btn-toolbar.btn-run-gear.mode-deepseek) {
background: linear-gradient(135deg, #2740c4, #4d6bfe);
border-color: #1b2a8f;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
+1 -1
View File
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
+226 -6
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok),
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok/DeepSeek),
* session options modal (per-session settings, color picker, rename),
* session options tabs (Ralph config tab), case settings (CRUD, links),
* create case modal, and mobile case picker.
@@ -406,6 +406,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'grok') {
return await this.runGrok();
}
if (mode === 'deepseek') {
return await this.runDeepSeek();
}
if (mode === 'shell') {
return await this.runShell();
}
@@ -471,10 +474,149 @@ Object.assign(CodemanApp.prototype, {
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/
_refreshRunModeAvailability(menu) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) {
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
}
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
// perfectly installed while no pane-capable profile exists, because DeepSeek
// ships no terminal front door. In that state the honest offer is "add one",
// not a hidden entry with no explanation anywhere.
const avail = window.__codemanCliAvailable || {};
const dsInstall = menu.querySelector('#runModeDeepSeekInstall');
if (dsInstall) {
dsInstall.style.display = !avail.deepseek && avail.deepseekBinary ? 'flex' : 'none';
}
// The web UI needs only the BINARY: it is the one interactive surface
// DeepSeek ships itself, so it works on a box with no terminal profile at
// all (and is the honest thing to offer there).
const dsWeb = menu.querySelector('#runModeDeepSeekWeb');
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
},
/**
* Start the DeepSeek Harness browser UI and open it as a Codeman web tab.
*
* The server is a background child process owned by
* `deepseek-web-server.ts`, NOT a shell session. It was a shell session first,
* on the reasoning that Codeman already supervises those, and that version
* worked - it just put a terminal tab on screen beside the web tab the user
* actually asked for, on every click. Opening a dashboard should open one tab.
*
* `--trusted-host` is the load-bearing flag: 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
* passing Codeman's authority the page renders and every API call fails.
*
* The tab is saved `trusted: true`, and that is REQUIRED rather than a
* convenience: an untrusted webview is sandboxed without `allow-same-origin`,
* which breaks this dashboard twice over. The dsh client-runtime reads
* `localStorage` while loading its plugins and dies there ("the document is
* sandboxed and lacks the 'allow-same-origin' flag"), and an opaque-origin
* frame sends `Origin: null`, so dsh's own trust check 403s every `/api` call
* no matter which authority `--trusted-host` names. Passing `location.host`
* only means anything once the frame actually carries that origin.
*
* The trade this makes is real and worth stating: a trusted proxied frame is
* same-origin with Codeman and can therefore reach Codeman's own API. It is
* defensible only because of what this specific dashboard already is - an
* agent harness Codeman just started itself, on loopback, which can run code
* as the user regardless. It is not a precedent for trusting third-party
* dashboards generally, which is why it is set here rather than defaulted.
*/
async runDeepSeekWeb() {
document.getElementById('runModeMenu')?.classList.remove('active');
const ownsLaunchTerminal = this._beginSessionLaunchStatus('Starting the DeepSeek web UI...');
try {
// One request, and the server owns everything behind it: picking a free
// port, spawning, waiting for the port to answer, and reusing an already
// running server instead of racing it. This used to start the server in a
// shell SESSION, which worked but put a terminal tab on screen next to the
// web tab actually asked for, every single time.
//
// `authority` is what dsh fences its own `/api` behind (`--trusted-host`),
// so it must be the origin this page is loaded from rather than anything
// the server could guess: a Codeman reachable at both loopback and a
// tailnet name has two, and only the browser knows which one is in play.
const startRes = await fetch('/api/deepseek/web', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authority: location.host }),
});
const startData = await startRes.json();
if (!startData.success) throw new Error(startData.error || 'Failed to start the DeepSeek web UI');
const url = startData.data.url;
// One managed record, repointed rather than duplicated: the port is chosen
// per launch, so creating a fresh row each time would stack a dashboard
// per restart, each pointing at a port nothing serves any more.
let webview = [...(this.webviews?.values() || [])].find((w) => w.managed === 'deepseek-web');
if (webview) {
const patchRes = await fetch(`/api/webviews/${webview.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, trusted: true }),
});
const patchData = await patchRes.json();
if (!patchData.success) throw new Error(patchData.error || 'Failed to update the web tab');
webview = patchData.data.webview || patchData.data;
} else {
const wvRes = await fetch('/api/webviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'DeepSeek Harness',
url,
icon: '\u{1F433}',
managed: 'deepseek-web',
trusted: true,
}),
});
const wvData = await wvRes.json();
if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab');
webview = wvData.data.webview || wvData.data;
}
// refreshWebviews, not a hopeful optional-chain: openWebview() reads
// this.webviews and silently no-ops on an id it has not loaded, so
// skipping the refresh made the FIRST click create the record but open
// nothing (the SSE round-trip had not landed yet).
await this.refreshWebviews?.();
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} - opening it as a tab.`);
if (webview?.id) await this.openWebview(webview.id);
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
/**
* Install a DeepSeek Harness terminal profile from the run menu.
*
* Held open for as long as the package manager takes (the endpoint bounds it),
* so the button reports progress rather than appearing to do nothing. On
* success the availability map is patched in place, which is what makes the
* real DeepSeek entry appear without a reload.
*/
async installDeepSeekProfile() {
const label = 'Installing a DeepSeek terminal profile (this can take a minute)...';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(label);
try {
const res = await fetch('/api/deepseek/install-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to install the profile');
window.__codemanCliAvailable = { ...(window.__codemanCliAvailable || {}), deepseek: !!data.data.runnable };
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Installed ${data.data.package} into profile "${data.data.profile}".`);
this.showToast?.(`DeepSeek profile "${data.data.profile}" installed`, 'success');
const menu = document.getElementById('runModeMenu');
if (menu) this._refreshRunModeAvailability(menu);
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
async _loadRunModeHistory() {
@@ -568,7 +710,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'shell' ? 'Run SH' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
@@ -1341,6 +1483,84 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Launch a DeepSeek Harness (`dsh`) session.
*
* Sends `permissionMode: 'danger-full-access'` for the same reason every
* sibling Run button sends its bypass switch: Codeman sessions exist for
* autonomous work. The harness has no bypass FLAG, so this rides the
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
* back down to `workspace-write` for non-granted owners server-side.
*
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
* output-stabilization guessing to definitive idle/blocked hook events.
*
* The two-part availability check is deliberate. `dsh` being installed is not
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
* binary and nothing a pane can run. Reporting that precisely, with the exact
* command that fixes it, is the difference between "the Run button is broken"
* and a 30-second fix.
*/
async runDeepSeek() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting DeepSeek session in ${caseName}...`);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/deepseek/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
);
return;
}
if (!status.runnable) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'deepseek',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
deepSeekConfig: { permissionMode: 'danger-full-access' },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start DeepSeek');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -1406,7 +1626,7 @@ Object.assign(CodemanApp.prototype, {
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok';
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -1436,7 +1656,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for external CLI sessions
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok';
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
@@ -3217,7 +3437,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
},
set(mode) {
this._runMode =
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'claude'
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'claude'
? mode
: 'claude';
},
+14
View File
@@ -67,6 +67,19 @@ Object.assign(CodemanApp.prototype, {
this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding');
},
_onHookAgentWorking(data) {
// The agent started a turn, so whatever it was blocked on is gone. Reported
// by the DeepSeek status bridge; a harness turn cannot run while one of its
// own modal approvals is on screen, so this means the dialog was answered in
// the terminal. Same clearing as _onHookElicitationComplete, and notably NOT
// a notification: a turn STARTING is not news.
if (data.sessionId) {
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
this.clearPendingHooks(data.sessionId, 'permission_prompt');
this.clearPendingHooks(data.sessionId, 'idle_prompt');
}
},
_onHookTeammateIdle(data) {
const session = this.sessions.get(data.sessionId);
this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`);
@@ -1211,6 +1224,7 @@ Object.assign(CodemanApp.prototype, {
['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'],
['welcomeGrokBtn', 'grok'],
['welcomeDeepSeekBtn', 'deepseek'],
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
// without cloudflared can only ever produce "cloudflared not found".
['welcomeTunnelBtn', 'cloudflared'],
+58
View File
@@ -2501,6 +2501,16 @@ body.solo-mode .btn-lifecycle-log {
color: #d4d4d8;
}
/* DeepSeek: the vendor's own brand blue. Deliberately NOT added to the
light-skin `--accent-d` override list above (which rescues gemini/antigravity/
pi/grok, whose pastels wash out on paper backgrounds) — this indigo already
carries enough contrast on the light skins, and overriding it would throw away
the one cue that separates a dsh tab from its neighbours. */
.session-tab .tab-mode.deepseek {
background: rgba(77, 107, 254, 0.18);
color: #7c93ff;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -3882,6 +3892,24 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transform: translateY(-1px);
}
/* DeepSeek Harness: the #4d6bfe blue identity, matching
.btn-toolbar.btn-run.mode-deepseek and .run-mode-dot.deepseek so the welcome
action reads as the same backend. */
.welcome-btn-deepseek {
background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%);
border-color: rgba(124, 147, 255, 0.4);
color: #eef2ff;
box-shadow: 0 2px 8px rgba(77, 107, 254, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.welcome-btn-deepseek:hover {
background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%);
box-shadow: 0 4px 20px rgba(77, 107, 254, 0.3), 0 0 40px rgba(39, 64, 196, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(150, 170, 255, 0.5);
color: #f8faff;
transform: translateY(-1px);
}
.welcome-btn-gemini {
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
border-color: rgba(96, 165, 250, 0.4);
@@ -4975,6 +5003,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #fafafa;
}
/* DeepSeek mode colors. Same cascade note as pi/grok above: this base-sheet pair
only renders on the `og` skin — the nested `html:not([data-skin="og"])` block
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so deepseek also
carries a rule inside that block (search `.btn-toolbar.btn-run.mode-deepseek`). */
.btn-toolbar.btn-run.mode-deepseek,
.btn-toolbar.btn-run-gear.mode-deepseek {
background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%);
border-color: rgba(124, 147, 255, 0.55);
color: #eef2ff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-deepseek:hover,
.btn-toolbar.btn-run-gear.mode-deepseek:hover {
background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%);
box-shadow: 0 0 12px rgba(77, 107, 254, 0.35), 0 2px 8px rgba(39, 64, 196, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(150, 170, 255, 0.65);
color: #f8faff;
}
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -5059,6 +5106,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.antigravity { background: #22d3ee; }
.run-mode-dot.pi { background: #f472b6; }
.run-mode-dot.grok { background: #a1a1aa; }
.run-mode-dot.deepseek { background: #4d6bfe; }
.run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width;
@@ -14403,6 +14451,16 @@ html:not([data-skin="og"]) {
color: #fafafa;
}
.btn-toolbar.btn-run.mode-grok:hover { box-shadow: 0 0 14px -2px rgba(161, 161, 170, 0.5); }
/* DeepSeek keeps its indigo on the non-og skins — same specificity trap as pi
and grok above: without this rule the generic `.btn-toolbar.btn-run` in this
nested block wins and deepseek renders as generic claude blue, which is the
one colour it must not be mistaken for. */
.btn-toolbar.btn-run.mode-deepseek {
background: linear-gradient(135deg, #2740c4, #4d6bfe);
border-color: #1b2a8f;
color: #f8faff;
}
.btn-toolbar.btn-run.mode-deepseek:hover { box-shadow: 0 0 14px -2px rgba(77, 107, 254, 0.55); }
.btn-toolbar.btn-run-gear {
background: var(--accent-d);
border-color: var(--accent);
+1 -1
View File
@@ -2214,7 +2214,7 @@ Object.assign(CodemanApp.prototype, {
}
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/shell) + a LIVE pill.
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/deepseek/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
badgeRow.className = 'history-item-badges';
if (s.mode) {
+4 -1
View File
@@ -302,7 +302,10 @@ Object.assign(CodemanApp.prototype, {
renderWebviewMenuItems() {
const container = document.getElementById('runModeWebviews');
if (!container) return;
const list = [...(this.webviews?.values() || [])];
// Managed records are maintained by their own menu entry (the DeepSeek web
// UI shortcut), so listing them here showed one dashboard twice: the
// shortcut that starts it, and the row it wrote on the previous click.
const list = [...(this.webviews?.values() || [])].filter((w) => !w.managed);
if (list.length === 0) {
container.innerHTML = '<div class="run-mode-empty">No URLs yet</div>';
return;
+1 -1
View File
@@ -11,7 +11,7 @@ export interface ResponseViewerTranscriptBlock {
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
// would drag node-pty and the whole session layer into this pure module, so the
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok']);
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']);
function isPromptLine(line: string): boolean {
return /^\s*›\s*/.test(line);
+15 -2
View File
@@ -23,7 +23,7 @@ import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { ApprovalAnswerSchema } from '../schemas.js';
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
import { hooksAvailableForMode } from '../session-wait-registry.js';
import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import type { SessionPort } from '../ports/index.js';
/**
@@ -99,9 +99,22 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
// Throws 404 (not 403) for sessions the caller does not own, same
// no-existence-leak rule as every other session route.
const session = findSessionOrFail(ctx, item.sessionId, req);
if (!hooksAvailableForMode(session.mode)) {
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// A dsh approval is an ALERT, not an answerable card: the dialog belongs to
// a third-party TUI whose keystroke contract Codeman has not measured, the
// Claude-shaped option parser never reads options off its frames, and
// verifyStillAnswerable() can therefore never be conclusive for it — so the
// '1'/Esc below would be a blind keystroke into a foreign composer. The item
// still raises the red alert and clears on the harness's own working/stop
// reports; answering happens in the terminal.
if (session.mode === 'deepseek') {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
);
}
// Re-capture the pane before aiming keystrokes at it: if the dialog was
// answered in the terminal moments ago, the digit would land in whatever
+52 -7
View File
@@ -13,7 +13,7 @@ import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
import { sanitizeHookData, parseBody } from '../route-helpers.js';
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { sessionWaits, hooksAvailableForMode } from '../session-wait-registry.js';
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import { approvalInbox, type ApprovalKind } from '../approval-inbox.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
@@ -24,8 +24,34 @@ const APPROVAL_KIND_BY_EVENT: Record<string, ApprovalKind> = {
idle_prompt: 'idle',
};
/** Hook events that close a session's pending item without an inbox answer. */
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response']);
/**
* Hook events that close a session's pending item without an inbox answer.
*
* `agent_working` is here because it is the DeepSeek status bridge's report that
* a turn STARTED, and a harness turn cannot be running while one of its own
* modal approvals is on screen — so the agent moving means the dialog was
* answered, in the terminal, by the user. That is the same conclusion the claude
* path reaches through pane capture, which cannot help here because its frame
* parser is Claude-dialog-shaped.
*/
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response', 'agent_working']);
/**
* Last DeepSeek status-bridge sequence number seen per session.
*
* The Herdr contract the dsh TUI speaks stamps every report with `--seq <n>`
* and RETRIES failed deliveries with backoff — so a stale report can land
* AFTER a newer one, and applying it in arrival order resolves an approval
* with a retried `working` while the harness sits blocked, or releases a wait
* with a retried `idle` mid-turn. A report whose seq is not newer than the
* last accepted one is dropped, but only inside a short window: the TUI's
* retry backoff is seconds, so a LOWER seq arriving after the window is a
* restarted TUI's fresh numbering (same pane, new generation), not a stale
* retry, and must be accepted. Insertion-order eviction bounds the map.
*/
const dshSeqBySession = new Map<string, { seq: number; at: number }>();
const DSH_SEQ_STALE_WINDOW_MS = 60_000;
const DSH_SEQ_MAX_SESSIONS = 500;
export function registerHookEventRoutes(
app: FastifyInstance,
@@ -37,6 +63,22 @@ export function registerHookEventRoutes(
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// DeepSeek status-bridge ordering: drop a stale retried report (see
// dshSeqBySession above). Success rather than an error, so the shim exits 0
// and the TUI does not keep retrying a report that will stay stale.
if (data && data.source === 'dsh-status-shim' && typeof data.seq === 'number') {
const last = dshSeqBySession.get(sessionId);
const now = Date.now();
if (last && data.seq <= last.seq && now - last.at < DSH_SEQ_STALE_WINDOW_MS) {
return {};
}
if (!dshSeqBySession.has(sessionId) && dshSeqBySession.size >= DSH_SEQ_MAX_SESSIONS) {
const oldest = dshSeqBySession.keys().next().value;
if (oldest !== undefined) dshSeqBySession.delete(oldest);
}
dshSeqBySession.set(sessionId, { seq: data.seq, at: now });
}
// Wake anything blocked on `GET /api/sessions/:id/wait`. Hooks are the only
// DEFINITIVE signals Codeman gets (`idle` is inferred from output stabilization
// and can flap mid-turn), so these two are what an orchestrating agent should
@@ -50,7 +92,7 @@ export function registerHookEventRoutes(
// could never legitimately emit one is now dropped instead of steering another
// agent's control flow.
const waitSession = ctx.sessions.get(sessionId);
if (waitSession && hooksAvailableForMode(waitSession.mode)) {
if (waitSession && hooksAvailableForMode(waitSession.mode, sessionHookOptions(waitSession))) {
if (event === 'stop') {
sessionWaits.notifySignal(sessionId, 'stop');
} else if (event === 'permission_prompt' || event === 'elicitation_dialog') {
@@ -111,7 +153,7 @@ export function registerHookEventRoutes(
// session that can never show one must not create an answerable item).
let approvalId: string | undefined;
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
if (session && hooksAvailableForMode(session.mode)) {
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
if (approvalKind) {
const toolInput =
safeData.tool_input && typeof safeData.tool_input === 'object'
@@ -154,12 +196,15 @@ export function registerHookEventRoutes(
// the browser loaded with. Debounced, so a hook burst costs one broadcast.
ctx.broadcastSessionStateDebounced(sessionId);
// Send push notifications for hook events
// Send push notifications for hook events. Push Approve/Deny actions ride
// on approvalId, and the answer route refuses keystrokes for dsh dialogs
// (third-party TUI, unmeasured contract) — so a dsh push stays a plain
// notification instead of offering buttons whose answer would be refused.
ctx.sendPushNotifications(`hook:${event}`, {
sessionId,
sessionName,
...safeData,
...(approvalId && { approvalId }),
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
});
// Track in run summary
+5 -2
View File
@@ -38,7 +38,6 @@ import { IntentGoalsSchema, ReadMyMindPredictSchema } from '../schemas.js';
import { parseBody, findSessionOrFail } from '../route-helpers.js';
import { intentStore } from '../../intent-store.js';
import { approvalInbox } from '../approval-inbox.js';
import { hooksAvailableForMode } from '../session-wait-registry.js';
import { buildPredictionContext, type PredictionContextInputs } from '../../readmymind-context.js';
import { collectWorkspaceSignals, readTranscriptSignals } from '../../readmymind-collectors.js';
import { readMyMindPredictor } from '../../readmymind-predictor.js';
@@ -72,7 +71,11 @@ export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort
const body = parseBody(ReadMyMindPredictSchema, req.body ?? {});
const session = findSessionOrFail(ctx, id, req);
if (!hooksAvailableForMode(session.mode)) {
// `mode === 'claude'` directly, NOT hooksAvailableForMode(): that predicate
// answers "can this session deliver stop/blocked", and once `deepseek` earned
// a yes it silently widened this gate to a mode whose sessions have no Claude
// transcript for readTranscriptSignals() to read.
if (session.mode !== 'claude') {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Read My Mind predicts claude-mode sessions only');
}
+141 -10
View File
@@ -25,6 +25,7 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
@@ -53,6 +54,7 @@ import { TabLayoutValidationError } from '../../tab-layout.js';
import {
sessionWaits,
resolveWaitSignals,
sessionHookOptions,
signalForStatus,
WaitCapacityError,
type WaitSignal,
@@ -337,6 +339,14 @@ export function _resetPasteRateBuckets(): void {
* Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove`
* (`--always-approve`), and an ABSENT config already spawns in grok's own
* ask-mode default, so only a sent config needs the flag forced off.
*
* DeepSeek joins the same only-if-sent branch, but its switch is not a flag: the
* harness has no command-line permission option, and its sandbox/approval rows
* read `DSH_PERMISSION_MODE`. Omitting that export leaves the harness on its own
* `workspace-write` preset, which still asks, so an absent config is already
* safe; a sent one is forced down to `workspace-write` rather than to
* `read-only`, because the clamp exists to remove PRIVILEGE, not to break a
* session's ability to edit its own workspace.
*/
async function clampExternalCliBypassForOwner(
owner: string | undefined,
@@ -344,16 +354,18 @@ async function clampExternalCliBypassForOwner(
geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined,
piConfig: PiConfig | undefined,
grokConfig: GrokConfig | undefined
grokConfig: GrokConfig | undefined,
deepSeekConfig: DeepSeekConfig | undefined
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined;
piConfig: PiConfig | undefined;
grokConfig: GrokConfig | undefined;
deepSeekConfig: DeepSeekConfig | undefined;
}> {
const granted = await canUsernameRunPrivilegedCommands(owner);
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig };
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig };
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
@@ -364,18 +376,100 @@ async function clampExternalCliBypassForOwner(
: antigravityConfig;
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
const clampedDeepSeek = deepSeekConfig
? { ...deepSeekConfig, permissionMode: 'workspace-write' as const }
: deepSeekConfig;
return {
codexConfig: clampedCodex,
geminiConfig: clampedGemini,
antigravityConfig: clampedAntigravity,
piConfig: clampedPi,
grokConfig: clampedGrok,
deepSeekConfig: clampedDeepSeek,
};
}
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
/**
* Env-var keys a non-granted owner must not be able to set, because each one
* hands back privilege the config clamp above just removed — or, for the last,
* redirects a credential the server injects.
*
* All are DeepSeek's, and all are reachable because `DSH_*` and `DEEPSEEK_*` are
* allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
* that is also how a user configures the harness's non-privileged knobs.
*
* - `DSH_PERMISSION_MODE` IS the harness's permission switch. Every other CLI's
* bypass is a command-line FLAG, reachable only through the per-CLI config the
* clamp already owns; this one is an env var, so the config clamp alone is
* half a gate.
* - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
* executes at BOOT, before any approval row can apply. A user who can write a
* workspace can put a profile in it, so this is the wider of the two.
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureDeepSeek()`
* forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
* `applyEnvOverrides()` runs — so a non-granted owner who could set the base
* URL would have the operator's API key sent as a bearer credential to a host
* of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
* your OWN key removes privilege rather than granting it.)
*/
const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'] as const;
/**
* Env-var half of the multi-user bypass clamp.
*
* `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI
* but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
* AFTER `_configureDeepSeek()` in tmux-manager, so an override sent on the SAME
* request lands last and wins, and a non-granted owner could restore
* `danger-full-access` on the very request the config clamp downgraded.
*
* Keys are DROPPED rather than rewritten: dropping falls through to what
* `_configureDeepSeek()` exports, which is the clamped config and the server's own
* `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
* granted owner, like every other clamp here
* (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
* and it returns the caller's own object untouched when there is nothing to strip.
*/
async function clampEnvOverridesForOwner(
owner: string | undefined,
envOverrides: Record<string, string> | undefined
): Promise<Record<string, string> | undefined> {
if (!envOverrides) return envOverrides;
if (!OWNER_CLAMPED_ENV_KEYS.some((key) => key in envOverrides)) return envOverrides;
if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides;
const clamped = { ...envOverrides };
for (const key of OWNER_CLAMPED_ENV_KEYS) delete clamped[key];
return clamped;
}
/** Test hook: the env-var half of the same multi-user safety gate. */
export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
/**
* Why a DeepSeek session cannot start, or null when it can.
*
* Availability for this mode is TWO questions, not one, because `dsh` is a
* profile launcher rather than an agent: the binary must resolve (and prove it
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
* a pane must exist. Reporting only the first would let the Run button spawn a
* pane that dies instantly, which is the single most confusing failure this mode
* can produce, so each half gets its own actionable message.
*
* A profile named EXPLICITLY is checked on both counts: existence, and whether
* it is pane-capable — `web` serves a browser UI and `headless` answers one task
* and exits, so both would present as "the tab immediately died".
*/
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
// Thin async wrapper: the implementation moved into the resolver module so
// CRON fires can ask the same question before constructing a Session; the
// dynamic import keeps this file's startup free of the probe machinery.
const { resolveDeepSeekLaunchError: impl } = await import('../../utils/deepseek-cli-resolver.js');
return impl(requestedProfile);
}
// ═══════════════════════════════════════════════════════════════
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
// ═══════════════════════════════════════════════════════════════
@@ -770,6 +864,7 @@ export function registerSessionRoutes(
body.mode !== 'antigravity' &&
body.mode !== 'pi' &&
body.mode !== 'grok' &&
body.mode !== 'deepseek' &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -859,6 +954,10 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
if (body.mode === 'deepseek') {
const err = await resolveDeepSeekLaunchError(body.deepSeekConfig?.profile);
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
}
if (body.mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
@@ -912,7 +1011,10 @@ export function registerSessionRoutes(
? body.piConfig?.model
: mode === 'grok'
? body.grokConfig?.model
: mode !== 'shell'
: // DeepSeek's model is a composition entry in the profile's config
// tree, not a session flag, so there is deliberately nothing to
// read here (see docs/deepseek-integration.md).
mode !== 'shell' && mode !== 'deepseek'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
@@ -925,13 +1027,15 @@ export function registerSessionRoutes(
antigravityConfig: gatedAntigravityConfig,
piConfig: gatedPiConfig,
grokConfig: gatedGrokConfig,
deepSeekConfig: gatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig,
body.antigravityConfig,
body.piConfig,
body.grokConfig
body.grokConfig,
body.deepSeekConfig
);
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
@@ -950,8 +1054,9 @@ export function registerSessionRoutes(
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
remote,
@@ -1182,6 +1287,7 @@ export function registerSessionRoutes(
session.mode !== 'antigravity' &&
session.mode !== 'pi' &&
session.mode !== 'grok' &&
session.mode !== 'deepseek' &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -1277,7 +1383,10 @@ export function registerSessionRoutes(
wait === true || (typeof wait === 'string' && wait.trim().length > 0) || (Array.isArray(wait) && wait.length > 0);
let until: readonly WaitSignal[] = [];
if (wantsWait) {
const resolved = resolveWaitSignals(wait === true ? undefined : wait, { mode: session.mode });
const resolved = resolveWaitSignals(wait === true ? undefined : wait, {
mode: session.mode,
...sessionHookOptions(session),
});
if (resolved.error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, resolved.error);
until = resolved.until;
}
@@ -1454,7 +1563,7 @@ export function registerSessionRoutes(
// Shared with the `wait` field on POST .../input: unknown token is a 400,
// hook-only signals are rejected explicitly but dropped from the default.
const { until, error } = resolveWaitSignals(query.until, { mode: session.mode });
const { until, error } = resolveWaitSignals(query.until, { mode: session.mode, ...sessionHookOptions(session) });
if (error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, error);
// The value actually applied after clamping, echoed below: a caller that asked
@@ -2717,6 +2826,7 @@ export function registerSessionRoutes(
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
envOverrides,
effort,
parentSessionId,
@@ -2766,6 +2876,7 @@ export function registerSessionRoutes(
antigravityConfig ||
piConfig ||
grokConfig ||
deepSeekConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2799,6 +2910,7 @@ export function registerSessionRoutes(
antigravityConfig ||
piConfig ||
grokConfig ||
deepSeekConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2909,6 +3021,12 @@ export function registerSessionRoutes(
}
}
// Check DeepSeek Harness availability if requested (binary AND a pane-capable profile).
if (mode === 'deepseek') {
const err = await resolveDeepSeekLaunchError(deepSeekConfig?.profile);
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -3036,6 +3154,7 @@ export function registerSessionRoutes(
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok' &&
mode !== 'deepseek' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
@@ -3060,7 +3179,8 @@ export function registerSessionRoutes(
? piConfig?.model
: mode === 'grok'
? grokConfig?.model
: mode !== 'shell'
: // DeepSeek's model lives in the profile's config tree, not here.
mode !== 'shell' && mode !== 'deepseek'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
@@ -3072,8 +3192,18 @@ export function registerSessionRoutes(
antigravityConfig: qsGatedAntigravityConfig,
piConfig: qsGatedPiConfig,
grokConfig: qsGatedGrokConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig);
deepSeekConfig: qsGatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner(
owner,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig
);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
const session = new Session({
workingDir: resolvedCasePath,
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
@@ -3091,7 +3221,8 @@ export function registerSessionRoutes(
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
envOverrides,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
envOverrides: qsGatedEnvOverrides,
effort,
remote,
docker,
+234 -1
View File
@@ -16,7 +16,7 @@ import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser } from '../../user-store.js';
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
import {
ConfigUpdateSchema,
@@ -26,6 +26,8 @@ import {
SubagentWindowStatesSchema,
SubagentParentMapSchema,
RevokeSessionSchema,
DeepSeekInstallProfileSchema,
DeepSeekWebStartSchema,
} from '../schemas.js';
import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js';
@@ -48,6 +50,7 @@ import {
} from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import { getRepositoryStatus } from '../repo-status.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
@@ -55,6 +58,21 @@ import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
/**
* Defaults for `POST /api/deepseek/install-profile`.
*
* The package is the community terminal front door with by far the widest use
* (~27.5k weekly downloads at time of writing, roughly 4x the next), MIT, and
* the one whose supervisor-reporting contract Codeman's status bridge speaks.
* It is a DEFAULT, not a hardcoding: the endpoint accepts any npm name, and the
* resolver never assumes this profile exists.
*/
const DEEPSEEK_DEFAULT_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
const DEEPSEEK_DEFAULT_PROFILE = 'dsh-tui';
/** A plugin install compiles and links a dependency tree; npm-scale, not curl-scale. */
const DEEPSEEK_INSTALL_TIMEOUT_MS = 300_000;
// Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
// Screenshots directory
@@ -461,6 +479,221 @@ export function registerSystemRoutes(
};
});
// ========== DeepSeek Harness ==========
// The widest of the per-CLI status shapes, because this mode has the widest
// failure surface. Three fields beyond the sibling `available`/`path`:
//
// - `version`, like pi/grok, so a misresolution is diagnosable — and here the
// stakes are higher, since `dsh` is also an existing Debian program
// (dancer's shell) rather than merely a squattable npm name.
// - `profiles`, because `dsh` is a LAUNCHER: a perfectly installed binary with
// no pane-capable profile cannot start a session, and the UI has to be able
// to say which of the two halves is missing.
// - `runnable` + `defaultProfile`, the answer the Run button actually needs,
// so no caller has to re-derive it from the parts and get it subtly wrong.
app.get('/api/deepseek/status', async () => {
const {
isDeepSeekAvailable,
isDeepSeekRunnable,
resolveDeepSeekDir,
getDeepSeekCliVersion,
listDeepSeekProfiles,
resolveDefaultDeepSeekProfile,
resolveDshHome,
} = await import('../../utils/deepseek-cli-resolver.js');
return {
available: isDeepSeekAvailable(),
runnable: isDeepSeekRunnable(),
path: resolveDeepSeekDir(),
version: getDeepSeekCliVersion(),
dshHome: resolveDshHome(),
defaultProfile: resolveDefaultDeepSeekProfile(),
profiles: listDeepSeekProfiles(),
};
});
// Start (or reuse) the background `dsh web` behind the Run menu shortcut.
//
// This runs as a plain child process rather than a shell SESSION on purpose.
// The session version worked, but it put a terminal tab on screen next to the
// web tab the user actually asked for, every single time. Nothing about a
// long-lived HTTP server needs to be a tab.
//
// Fenced at the same bar as the profile installer, and for the same reason:
// booting a dsh profile executes the plugin code in it, so this is a
// privileged action even though it reads as "open a page".
app.post('/api/deepseek/web', async (req) => {
const { authority } = parseBody(DeepSeekWebStartSchema, req.body);
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'Starting the DeepSeek web UI requires the can-bypass-permissions grant'
);
}
const { resolveDeepSeekDir, getDeepSeekNotFoundMessage } = await import('../../utils/deepseek-cli-resolver.js');
const dir = resolveDeepSeekDir();
if (!dir) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getDeepSeekNotFoundMessage());
const { startDeepSeekWeb } = await import('../../deepseek-web-server.js');
const result = await startDeepSeekWeb(dir, authority);
if (!result.ok) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error);
return { success: true, data: { port: result.port, url: result.url, reused: result.reused } };
});
app.get('/api/deepseek/web', async () => {
const { getDeepSeekWebStatus } = await import('../../deepseek-web-server.js');
return { success: true, data: getDeepSeekWebStatus() };
});
app.delete('/api/deepseek/web', async (req) => {
// Same bar as POST: the server is a single shared instance, so in
// multi-user mode stopping it out from under other users' tabs is a
// privileged act (single-user and granted owners are unaffected).
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'Stopping the DeepSeek web UI requires the can-bypass-permissions grant'
);
}
const { stopDeepSeekWeb } = await import('../../deepseek-web-server.js');
await stopDeepSeekWeb();
return { success: true, data: { stopped: true } };
});
// Bootstrap an interactive profile so the mode becomes usable.
//
// This exists because DeepSeek ships NO terminal front door: `dsh` on its own
// can only serve a browser UI or answer one headless task, and the agent a
// Codeman pane runs is always a plugin the user installed. Without this the
// mode's first-run experience is a dead Run button and a paragraph of shell
// instructions.
//
// It is the only endpoint in Codeman that installs third-party code, so it is
// fenced accordingly:
// - the privileged grant is required in multi-user mode (same bar as a
// `shell` session, which can already do strictly more);
// - the specifier is regex-confined to an npm name at the schema boundary —
// no path, URL, git spec, or leading dash;
// - the spawn is an argv ARRAY through the resolved `dsh`, never a shell
// string, so even a specifier that slipped the regex could not become a
// second command;
// - the request is held open with a bounded timeout, mirroring the
// synchronous-clone precedent in `POST /api/cases/clone` rather than
// introducing a job store for a once-per-install action — and the bound is
// real, because the install runs in its own process GROUP and the timeout
// kills the whole tree (see the spawn below for why the built-in one is
// not enough).
app.post('/api/deepseek/install-profile', async (req) => {
const body = parseBody(DeepSeekInstallProfileSchema, req.body);
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'Installing a DeepSeek Harness profile requires the can-bypass-permissions grant'
);
}
const { resolveDeepSeekDir, getDeepSeekNotFoundMessage } = await import('../../utils/deepseek-cli-resolver.js');
const dir = resolveDeepSeekDir();
if (!dir) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getDeepSeekNotFoundMessage());
const profile = body.profile || DEEPSEEK_DEFAULT_PROFILE;
const pkg = body.package || DEEPSEEK_DEFAULT_TUI_PACKAGE;
const result = await new Promise<{ code: number | null; output: string; timedOut: boolean }>((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(join(dir, 'dsh'), ['plugin', '--profile', profile, 'add', pkg], {
stdio: ['ignore', 'pipe', 'pipe'],
// Own process group, and the timeout enforced by hand rather than by
// spawn's `timeout` option. A plugin install fans out into
// package-manager resolver/build children, and spawn's own timeout
// signals ONLY the direct child: the survivors keep the inherited stdio
// pipes open, `close` never fires, and this request hangs forever with
// no route-level deadline behind it. Same fan-out, same escalation and
// same negative-pid signal as runGit() in git-clone.ts, which is the
// synchronous-spawn precedent this endpoint is modelled on.
detached: true,
// dsh bundles its own package manager, so no system pnpm is required —
// but it still needs a HOME to resolve $DSH_HOME against.
env: process.env,
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
return;
}
let output = '';
let timedOut = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
let reapTimer: NodeJS.Timeout | undefined;
const capture = (chunk: Buffer) => {
// Bounded: a package manager can emit megabytes of progress.
if (output.length < 16_384) output += chunk.toString('utf-8');
};
child.stdout?.on('data', capture);
child.stderr?.on('data', capture);
const killTree = (signal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
};
const finish = (code: number | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
if (reapTimer) clearTimeout(reapTimer);
resolve({ code, output, timedOut });
};
const timer = setTimeout(() => {
timedOut = true;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
// Last resort: a grandchild that escaped the group (double-fork/setsid)
// can hold the pipes open past SIGKILL, and `close` would still never
// arrive. Answer the caller anyway rather than leaking the request.
reapTimer = setTimeout(() => finish(null), 8_000);
}, DEEPSEEK_INSTALL_TIMEOUT_MS);
child.on('error', (err) => {
output = `${output}\n${err.message}`;
finish(null);
});
child.on('close', (code) => finish(code));
});
if (result.code !== 0) {
const detail = result.timedOut
? `timed out after ${Math.round(DEEPSEEK_INSTALL_TIMEOUT_MS / 1000)}s`
: result.output.slice(-1000).trim() || 'no output';
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Installing ${pkg} into profile "${profile}" failed: ${detail}`
);
}
const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } =
await import('../../utils/deepseek-cli-resolver.js');
return {
profile,
package: pkg,
runnable: isDeepSeekRunnable(),
defaultProfile: resolveDefaultDeepSeekProfile(),
profiles: listDeepSeekProfiles(),
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
+1
View File
@@ -132,6 +132,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
// dashboard on an HTTPS Codeman, which is the common case.
embedMode: input.embedMode ?? 'proxy',
trusted: input.trusted ?? false,
managed: input.managed,
owner,
createdAt: Date.now(),
};
+109 -4
View File
@@ -132,6 +132,15 @@ const ALLOWED_ENV_PREFIXES = [
'PI_',
'GROK_',
'XAI_',
// DeepSeek Harness: `DSH_*` carries the launcher's own documented inputs
// (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, and the DSH_TUI_* knobs
// the terminal front door reads); `DEEPSEEK_*` is the vendor namespace holding
// DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL, the same narrow-vendor reasoning that
// admitted XAI_* for grok. Foreign provider keys stay out: a dsh settings.yaml
// can name ANY env var as a provider credential (apiKeyEnv), which is pi's
// 34-provider-key problem in a new shape, and the answer is the same one.
'DSH_',
'DEEPSEEK_',
];
/**
@@ -171,7 +180,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_* keys and CLAUDE_CONFIG_DIR are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_* keys and CLAUDE_CONFIG_DIR are allowed.',
}
);
@@ -336,6 +345,88 @@ const GrokConfigSchema = z
})
.optional();
/**
* Schema for DeepSeek Harness (`dsh`)-specific configuration.
*
* `permissionMode` maps to the `DSH_PERMISSION_MODE` env export, NOT to a flag —
* the harness has no command-line permission switch. An ABSENT config spawns the
* profile under the harness's own `workspace-write` default, which still asks
* for approval, so the multi-user clamp only needs the only-if-sent branch (like
* codex/antigravity/grok).
*
* `profile` is a directory name under `$DSH_HOME/profiles`, so it is constrained
* to a single path SEGMENT: no separators, no dots-only names. It is interpolated
* into the `bash -c "…"` spawn line and joined into a filesystem path, and this
* regex is what keeps both safe.
*/
const DeepSeekConfigSchema = z
.object({
profile: z
.string()
.min(1)
.max(64)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
.optional(),
permissionMode: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
resumeSession: z.boolean().optional(),
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._-]+$/)
.optional(),
statusReporting: z.boolean().optional(),
})
.optional();
/**
* Body of POST /api/deepseek/install-profile.
*
* `package` is a package SPECIFIER handed to `dsh plugin … add`, which runs a
* real package-manager install, so it is the security-relevant field. Two things
* contain it: this regex (an npm name, optionally scoped, optionally with an
* `@version` tail, and NOTHING else — no path, no URL, no git spec, no leading
* dash that could be read as a flag), and the route, which spawns an argv ARRAY
* with no shell. The route additionally requires the privileged grant in
* multi-user mode: installing a plugin is arbitrary code execution on the host,
* the same bar as a `shell` session.
*/
export const DeepSeekInstallProfileSchema = z
.object({
profile: z
.string()
.min(1)
.max(64)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
.optional(),
package: z
.string()
.min(1)
.max(214)
.regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[a-zA-Z0-9][a-zA-Z0-9._-]*)?$/)
.optional(),
})
.strict();
/**
* POST /api/deepseek/web: start the background `dsh web` for one browser authority.
*
* `authority` becomes `--trusted-host`, which is what dsh fences its own `/api`
* behind, so it must be the origin the browser will actually load the tab from
* (`location.host`). It reaches a spawn as one element of an argv ARRAY, never a
* shell string, so this regex is defence in depth rather than the only guard: it
* admits host:port in the shapes a browser authority can take (dotted names,
* IPv4, bracketed IPv6) and nothing that could be read as a second argument.
*/
export const DeepSeekWebStartSchema = z
.object({
authority: z
.string()
.min(1)
.max(255)
.regex(/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)(?::\d{1,5})?$/),
})
.strict();
/**
* The session that spawned the one being created — pure UI decoration, drawn as a
* lineage line between the two tabs. Accepted here and, equivalently, as the
@@ -349,7 +440,7 @@ const parentSessionIdSchema = z.string().max(100).optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
@@ -366,6 +457,7 @@ export const CreateSessionSchema = z.object({
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -502,6 +594,7 @@ const RemoteCommandOverridesSchema = z
antigravity: z.string().min(1).max(300).optional(),
pi: z.string().min(1).max(300).optional(),
grok: z.string().min(1).max(300).optional(),
deepseek: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -776,13 +869,14 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -804,6 +898,11 @@ export const HookEventSchema = z.object({
'stop',
'teammate_idle',
'task_completed',
// A turn STARTED. Unlike the others this one has no Claude Code hook behind
// it: it is reported by the DeepSeek Harness status shim, and exists so a
// dialog answered in the terminal resolves its Approvals Inbox item at once
// instead of lingering red until the next `stop`.
'agent_working',
]),
sessionId: z.string().min(1),
data: z.record(z.string(), z.unknown()).nullable().optional(),
@@ -1310,7 +1409,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
@@ -1600,6 +1699,12 @@ const WebviewBaseSchema = z.object({
* and call the API that spawns agents.
*/
trusted: z.boolean().optional(),
/**
* Marks a record Codeman maintains itself. Declared here because a plain
* `z.object` STRIPS undeclared keys, so an undeclared marker would be dropped
* on the way in and the dedup it drives would never fire.
*/
managed: z.enum(['deepseek-web']).optional(),
});
/** POST /api/webviews */
+22 -2
View File
@@ -91,10 +91,11 @@ import {
attachSessionListeners,
detachSessionListeners,
} from './session-listener-wiring.js';
import { sessionWaits, hooksAvailableForMode } from './session-wait-registry.js';
import { sessionWaits } from './session-wait-registry.js';
import { intentStore } from '../intent-store.js';
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
setupTimedRespawn,
@@ -1093,7 +1094,10 @@ export class WebServer extends EventEmitter {
*/
private async captureIntentPrompt(sessionId: string, text: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session || !hooksAvailableForMode(session.mode)) return;
// `mode === 'claude'` directly: the intent profile is fed from Claude's own
// transcript, so this is a claude question, not a hooks-available one (which
// `deepseek` now answers yes to).
if (!session || session.mode !== 'claude') return;
try {
const settings = await this.readSettings();
if (settings.readMyMindEnabled !== true) return;
@@ -1436,6 +1440,7 @@ export class WebServer extends EventEmitter {
{ isAntigravityAvailable },
{ isPiAvailable },
{ isGrokAvailable },
{ isDeepSeekRunnable, isDeepSeekAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
@@ -1446,6 +1451,7 @@ export class WebServer extends EventEmitter {
import('../utils/antigravity-cli-resolver.js'),
import('../utils/pi-cli-resolver.js'),
import('../utils/grok-cli-resolver.js'),
import('../utils/deepseek-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'),
]);
@@ -1457,6 +1463,13 @@ export class WebServer extends EventEmitter {
antigravity: isAntigravityAvailable(),
pi: isPiAvailable(),
grok: isGrokAvailable(),
// RUNNABLE, not merely installed: `dsh` is a profile launcher, and a dsh
// with no pane-capable profile would offer a Run button that spawns a
// pane which dies on arrival. The Add-Profile affordance in the run menu
// keys off `deepseekBinary` instead, so a user who has the binary but no
// profile is offered the fix rather than a greyed-out entry.
deepseek: isDeepSeekRunnable(),
deepseekBinary: isDeepSeekAvailable(),
cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
// keep without git (issue #236), same reasoning as cloudflared above.
@@ -2738,6 +2751,7 @@ export class WebServer extends EventEmitter {
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
@@ -3122,6 +3136,12 @@ export class WebServer extends EventEmitter {
this._dockerBridgeServer = null;
}
// The background `dsh web` is detached so its whole plugin tree can be
// signalled at once, which also means it would OUTLIVE Codeman and hold its
// port against the next start — the exact EADDRINUSE this feature already
// got wrong once.
void stopDeepSeekWeb();
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();
+97 -10
View File
@@ -170,19 +170,91 @@ export function signalForStatus(status: SessionStatus): WaitSignal | null {
/** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
/** Per-session facts that can turn a mode's hook capability OFF for one session. */
export interface HookCapabilityOptions {
/**
* Whether a session in this mode ever POSTs Codeman hook events, and therefore
* whether `stop` / `blocked` can ever fire for it.
* `deepSeekConfig.statusReporting`, verbatim (so `undefined` means "not sent",
* i.e. ON). `false` is the per-session opt-out that stops `_configureDeepSeek()`
* exporting the `HERDR_*` triple, which is the ONLY thing that makes a dsh
* session emit hook events at all.
*/
deepSeekStatusReporting?: boolean;
/**
* True when the pane's harness runs somewhere the status bridge cannot reach:
* a docker case (`docker exec` does not carry the local tmux env into the
* container, and the loopback-bound API is unreachable from it) or a
* remote-SSH case (the `HERDR_*` triple is set on the LOCAL ssh process, not
* the remote shell). Such a session never posts a hook event however the
* statusReporting flag is set, so `until=stop` on it would burn its whole
* timeout on every turn.
*/
deepSeekBridgeUnreachable?: boolean;
}
/**
* Whether this session ever POSTs Codeman hook events, and therefore whether
* `stop` / `blocked` can ever fire for it.
*
* True for `claude` and nothing else. The tempting predicate is
* `claude` always (Claude Code fires the hooks itself), `deepseek` when its
* status bridge is armed, nothing else. The tempting predicate is
* `!isExternalCliMode(mode)`, and it is WRONG: that helper covers only
* opencode/codex/gemini/antigravity, so `shell` falls through it — and a shell session
* is a plain bash PTY with no Claude Code and no hooks installed. `until=stop` on one
* was accepted and then blocked for the caller's whole timeout, which is precisely the
* infinite-wait-dressed-as-a-timeout this guard exists to prevent.
*
* ⚠️ `deepseek` is a per-SESSION answer, not a per-mode one, which is why the
* options argument exists: `deepSeekConfig.statusReporting: false` disarms the
* bridge for one session, and answering from the mode alone re-creates the exact
* infinite-wait this guard is for. Every call site therefore passes the session's
* own flag; the default stays permissive so a forgotten one degrades to the old
* behavior rather than 400ing a session that works.
*
* ⚠️ It is also the LIMIT of what can be known at request time. Whether the
* installed profile actually implements the supervisor contract is only
* observable once it reports, and `resolveDefaultDeepSeekProfile()` deliberately
* treats an unrecognized profile as launchable, so a dsh session running a
* non-conforming TUI still answers true here and still times out on an explicit
* `until=stop`. The default signal set keeps `idle`/`exit` for exactly that case.
*
* ⚠️ NOT a stand-in for "is this a claude session". It reads like one and it was
* used as one (Read My Mind, intent capture) until `deepseek` joined and silently
* widened both. Those sites compare `mode === 'claude'` directly now; ask this
* function only about hook SIGNALS.
*/
export function hooksAvailableForMode(mode: SessionMode): boolean {
return mode === 'claude';
export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
if (mode === 'claude') return true;
// `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE
// signals rather than having them inferred. The DeepSeek Harness terminal
// front door reports idle/working/blocked to its supervisor, and Codeman is
// that supervisor (see deepseek-status-shim.ts), so a dsh session really can
// deliver `stop` and `blocked` — unless the user turned the bridge off, in
// which case nothing on the box will ever post one. Every other mode is
// output-stabilization guesswork and must keep failing the ask.
if (mode === 'deepseek') {
return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true;
}
return false;
}
/**
* Lift the per-session hook facts off a live session.
*
* Structurally typed on purpose: this module is pure and deliberately imports no
* `Session` (importing it would drag node-pty and the session layer into every
* consumer). One helper rather than an inline object literal at each of the four
* call sites, so a future per-session fact is added in one place instead of
* being forgotten at three of them.
*/
export function sessionHookOptions(session: {
deepSeekStatusReporting?: boolean;
docker?: unknown;
remote?: unknown;
}): HookCapabilityOptions {
return {
deepSeekStatusReporting: session.deepSeekStatusReporting,
deepSeekBridgeUnreachable: Boolean(session.docker || session.remote),
};
}
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
@@ -206,10 +278,15 @@ export interface ResolvedWaitSignals {
* not drift; the second-guessing that produces is worse than the duplication.
*
* @param raw - the caller's value (comma string, array, `true` for "the default")
* @param options - `mode` decides whether the hook-only signals are available, and
* names the mode in the error message so the caller can see why
* @param options - `mode` plus the per-session facts `hooksAvailableForMode()` needs
* (a dsh session with its status bridge disarmed emits no hooks even
* though the mode can). The mode also names itself in the error
* message so the caller can see why.
*/
export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode }): ResolvedWaitSignals {
export function resolveWaitSignals(
raw: unknown,
options: { mode: SessionMode } & HookCapabilityOptions
): ResolvedWaitSignals {
const parsed = parseWaitSignals(raw);
if (parsed.invalid.length > 0) {
return {
@@ -218,7 +295,7 @@ export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode })
};
}
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode) ? [] : HOOK_ONLY_SIGNALS);
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS);
if (parsed.signals.length === 0) {
return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
@@ -228,7 +305,17 @@ export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode })
if (rejected.length > 0) {
return {
until: [],
error: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
// A dsh session is the one case where the mode is capable and THIS session
// is not, so saying "never fire for deepseek sessions" would send the
// caller looking for a bug that is really a setting they chose.
error:
options.mode === 'deepseek'
? options.deepSeekBridgeUnreachable
? `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: it runs in a container or on ` +
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` +
`(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
};
}
return { until: parsed.signals, error: null };
+11 -2
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 156 event constants organized by category:
* 157 event constants organized by category:
* - **Core** (1): init
* - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
@@ -25,7 +25,8 @@
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed
* - **Hooks** (9): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
* - **Clipboard** (1): write
@@ -360,6 +361,13 @@ export const HookElicitationComplete = 'hook:elicitation_complete' as const;
export const HookElicitationResponse = 'hook:elicitation_response' as const;
/** Claude Code hook: response complete. */
export const HookStop = 'hook:stop' as const;
/**
* Agent started a turn. NOT a Claude Code hook: this one is reported by the
* DeepSeek Harness status bridge, which is why the name is agent-generic. It
* exists so a dialog answered in the terminal clears its alert immediately
* instead of waiting for the turn to end.
*/
export const HookAgentWorking = 'hook:agent_working' as const;
/** Claude Code hook: teammate went idle. */
export const HookTeammateIdle = 'hook:teammate_idle' as const;
/** Claude Code hook: teammate task completed. */
@@ -619,6 +627,7 @@ export const SseEvent = {
HookElicitationComplete,
HookElicitationResponse,
HookStop,
HookAgentWorking,
HookTeammateIdle,
HookTaskCompleted,
+277
View File
@@ -0,0 +1,277 @@
/**
* @fileoverview Tests for the DeepSeek Harness (`dsh`) resolver and profile inventory.
*
* `dsh` needs the strictest identity probe of any CLI Codeman resolves. pi and
* grok are short names with npm squatters; `dsh` is worse — it is an EXISTING,
* widely packaged Unix program (Debian's dancer's shell, `apt install dsh`),
* which would sail through a version-token probe and then be handed a spawn
* line. So the resolver demands the harness's own help banner first, and the
* headline test below is the one that pins that rejection.
*
* The second half covers something no sibling resolver has: a profile
* inventory. `dsh` is a launcher, so "is it installed" and "can it run a
* session" are different questions, and the availability gate needs both.
*/
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createDeepSeekResolverForTest,
DEEPSEEK_VERSION_REGEX,
DEEPSEEK_IDENTITY_REGEX,
listDeepSeekProfiles,
resolveDefaultDeepSeekProfile,
isLaunchableProfile,
resolveDshHome,
} from '../src/utils/deepseek-cli-resolver.js';
import {
cliResolveRetryDelayMs,
createProductionCliResolverHost,
type CliResolverHost,
} from '../src/utils/cli-executable-resolver.js';
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createHost(
options: {
processPathResult?: string | null;
loginShellResults?: Array<string | null>;
existingPaths?: string[];
} = {}
): CliResolverHost {
const loginShellResults = [...(options.loginShellResults ?? [])];
const existingPaths = new Set(options.existingPaths ?? []);
return {
processPath: '/service/bin',
shellPath: '/bin/zsh',
shellArgs: ['-l'],
findOnProcessPath: () => options.processPathResult ?? null,
findInLoginShell: () => loginShellResults.shift() ?? null,
exists: (path) => existingPaths.has(path),
};
}
describe('DeepSeek CLI resolver', () => {
it('accepts a candidate the probe verifies and carries the version as metadata', () => {
const binaryPath = '/service/bin/dsh';
const probe = vi.fn(() => '0.1.1-rc.2');
const resolver = createDeepSeekResolverForTest(
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
probe
);
expect(resolver.resolve()).toMatchObject({
binaryPath,
directory: '/service/bin',
source: 'process-path',
metadata: '0.1.1-rc.2',
});
expect(probe).toHaveBeenCalledWith(binaryPath);
});
it('does not let a foreign `dsh` earlier on PATH mask the real one', () => {
// The dancer's-shell case, at resolver level: a `dsh` that is a real program
// and answers --version must still be refused, and must not stop the search.
const impostor = '/usr/bin/dsh';
const genuine = '/login-shell/bin/dsh';
const probe = vi.fn((binPath: string) => (binPath === genuine ? '0.1.1-rc.2' : null));
const resolver = createDeepSeekResolverForTest(
createHost({
processPathResult: impostor,
loginShellResults: [genuine],
existingPaths: [impostor, genuine],
}),
probe
);
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell' });
});
it('negative-caches a miss and retries only after the backoff elapses', () => {
const binaryPath = '/late/bin/dsh';
let now = 0;
const probe = vi.fn(() => '0.1.1-rc.2');
const resolver = createDeepSeekResolverForTest(
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
probe,
() => now
);
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
expect(probe).not.toHaveBeenCalled();
now = cliResolveRetryDelayMs(1);
expect(resolver.resolve()?.metadata).toBe('0.1.1-rc.2');
});
it('extracts the version from the real output shape (a bare `0.1.1-rc.2`)', () => {
// Shared with the dependency registry (doctor), so the accepted shape is
// contract. The prerelease tail is part of the token on purpose: dropping it
// would report a release candidate as a release.
expect(DEEPSEEK_VERSION_REGEX.exec('0.1.1-rc.2')?.[1]).toBe('0.1.1-rc.2');
expect(DEEPSEEK_VERSION_REGEX.exec('dsh 1.2.3')?.[1]).toBe('1.2.3');
expect(DEEPSEEK_VERSION_REGEX.exec('not a version')).toBeNull();
});
it('identifies the harness by its help banner and rejects a foreign dsh', () => {
expect(DEEPSEEK_IDENTITY_REGEX.test('dsh: boot a DeepSeek Harness profile — an ordered stack')).toBe(true);
// Debian's dancer's shell: a real program, a real version, not our agent.
expect(DEEPSEEK_IDENTITY_REGEX.test('Usage: dsh [options] [command] ...\nDistributed shell')).toBe(false);
});
it('never executes a dsh candidate under vitest (the ambient probe is VITEST-gated)', () => {
// A REAL executable fixture that answers BOTH probes convincingly. If the
// guard in probeDeepSeekVersion is ever removed, this script runs, the
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
// behavior rather than by source text. That matters more here than for any
// sibling: `dsh` is a name real machines genuinely carry.
const root = mkdtempSync(join(tmpdir(), 'codeman-dsh-vitest-gate-'));
temporaryDirectories.push(root);
const binaryPath = join(root, 'dsh');
writeFileSync(
binaryPath,
'#!/bin/sh\ncase "$1" in --help) echo "dsh: boot a DeepSeek Harness profile";; *) echo "9.9.9";; esac\n'
);
chmodSync(binaryPath, 0o755);
const hostOptions = {
processPath: root,
shellPath: '/bin/bash',
shellArgs: ['-i', '-l'] as string[],
runCommand: () => '',
isExecutableFile: (path: string) => path === binaryPath,
};
const gated = createDeepSeekResolverForTest(createProductionCliResolverHost(hostOptions));
expect(gated.resolve()).toBeNull();
// Control: identical setup with an injected probe resolves, proving the null
// above comes from the gate, not from the fixture or the host.
const control = createDeepSeekResolverForTest(createProductionCliResolverHost(hostOptions), () => '9.9.9');
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '9.9.9' });
});
});
describe('DeepSeek profile inventory', () => {
let home: string;
const ORIGINAL_DSH_HOME = process.env.DSH_HOME;
function writeProfile(name: string, bundles: string[]): void {
const dir = join(home, 'profiles', name);
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ name: `dsh-profile-${name}`, dsh: { profile: { bundles } } })
);
}
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'codeman-dsh-home-'));
temporaryDirectories.push(home);
process.env.DSH_HOME = home;
});
afterEach(() => {
if (ORIGINAL_DSH_HOME === undefined) delete process.env.DSH_HOME;
else process.env.DSH_HOME = ORIGINAL_DSH_HOME;
});
it('honours DSH_HOME over the default ~/.dsh', () => {
expect(resolveDshHome()).toBe(home);
});
it('is empty (not an error) when dsh has never been run', () => {
rmSync(home, { recursive: true, force: true });
expect(listDeepSeekProfiles()).toEqual([]);
expect(resolveDefaultDeepSeekProfile()).toBeNull();
});
it('classifies the profiles DeepSeek ships as unable to drive a pane', () => {
writeProfile('web', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']);
writeProfile('headless', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']);
const profiles = listDeepSeekProfiles();
expect(profiles.map((p) => `${p.name}:${p.kind}`).sort()).toEqual(['headless:headless', 'web:web']);
expect(profiles.every((p) => !isLaunchableProfile(p))).toBe(true);
// The whole point: a perfectly installed dsh with only the shipped profiles
// still cannot start a Codeman session.
expect(resolveDefaultDeepSeekProfile()).toBeNull();
});
it('prefers an interactive profile and ignores node_modules', () => {
writeProfile('web', ['@deepseek-ai/dsh-web-app']);
writeProfile('dsh-tui', ['@deepseek-ai/dsh-base', '@deepseek-harness-tui/dsh-tui']);
mkdirSync(join(home, 'profiles', 'node_modules', 'something'), { recursive: true });
const names = listDeepSeekProfiles().map((p) => p.name);
expect(names).not.toContain('node_modules');
expect(resolveDefaultDeepSeekProfile()).toBe('dsh-tui');
});
it('treats an unrecognized third-party profile as launchable', () => {
// Anyone can publish an app bundle, so an unknown profile must not be hidden
// from the picker just because this classifier has not heard of it.
writeProfile('custom', ['@someone/dsh-my-own-surface']);
const profile = listDeepSeekProfiles().find((p) => p.name === 'custom')!;
expect(profile.kind).toBe('unknown');
expect(isLaunchableProfile(profile)).toBe(true);
expect(resolveDefaultDeepSeekProfile()).toBe('custom');
});
it('does not treat a bundle-less stock profile as launchable', () => {
// readProfile() yields an empty bundle list for any package.json without a
// `dsh.profile.bundles` array (hand-edited, older layout, mid-install), and
// with no bundles to read the shipped web/headless profiles used to look
// exactly like an unrecognized third-party one — inheriting its
// launchable-by-default treatment and producing the pane-dies-on-arrival
// failure the two-part availability gate exists to prevent.
const bare = (name: string) => {
const dir = join(home, 'profiles', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: `dsh-profile-${name}` }));
};
bare('web');
bare('headless');
const profiles = listDeepSeekProfiles();
expect(profiles.map((p) => `${p.name}:${p.kind}`).sort()).toEqual(['headless:headless', 'web:web']);
expect(profiles.every((p) => !isLaunchableProfile(p))).toBe(true);
expect(resolveDefaultDeepSeekProfile()).toBeNull();
});
it('lets bundle evidence beat the name fallback', () => {
// The name check is a LAST resort, so a profile the user happened to call
// `web` that really composes a terminal app is still interactive. Otherwise
// a directory name would override what the profile actually contains.
writeProfile('web', ['@deepseek-ai/dsh-base', '@someone/dsh-tui']);
const profile = listDeepSeekProfiles().find((p) => p.name === 'web')!;
expect(profile.kind).toBe('interactive');
expect(resolveDefaultDeepSeekProfile()).toBe('web');
});
it('does not read `tui` out of the middle of an unrelated word', () => {
// The loose arm is a TOKEN match: `@someone/tui-app` is a TUI, `intuition`
// is a word. Being wrong is cheap (unknown is launchable too) but it decides
// which profile boots by DEFAULT, and "its name contains t-u-i" is not a
// rule anyone could predict.
writeProfile('intuition', ['@someone/gratuitous-surface']);
expect(listDeepSeekProfiles().find((p) => p.name === 'intuition')!.kind).toBe('unknown');
writeProfile('mine', ['@someone/tui-app']);
expect(listDeepSeekProfiles().find((p) => p.name === 'mine')!.kind).toBe('interactive');
// Preferred over the merely-unknown one, which is the whole point of ranking.
expect(resolveDefaultDeepSeekProfile()).toBe('mine');
});
it('survives a stray directory under profiles/', () => {
mkdirSync(join(home, 'profiles', 'not-a-profile'), { recursive: true });
writeProfile('dsh-tui', ['@deepseek-harness-tui/dsh-tui']);
expect(listDeepSeekProfiles().map((p) => p.name)).toEqual(['dsh-tui']);
});
});
+390
View File
@@ -0,0 +1,390 @@
/**
* DeepSeek Harness (`dsh`) run mode.
*
* The interesting assertions here are the ones that differ from every sibling
* CLI, because dsh is shaped differently in two ways:
*
* 1. the agent is a PROFILE, not the binary, so the spawn line carries
* `--profile <name>` and a profile name has to be treated as a path segment;
* 2. the permission switch is an ENV VAR (`DSH_PERMISSION_MODE`), not a flag,
* so the thing to pin is that nothing permission-shaped ever reaches the
* command line.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { CreateSessionSchema, QuickStartSchema, HookEventSchema } from '../src/web/schemas.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
import { hooksAvailableForMode, resolveWaitSignals, sessionHookOptions } from '../src/web/session-wait-registry.js';
import { _clampExternalCliBypassForOwner, _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
vi.mock('../src/utils/deepseek-cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/deepseek-cli-resolver.js')>();
return { ...actual, resolveDefaultDeepSeekProfile: vi.fn(() => 'dsh-tui') };
});
describe('DeepSeek mode schemas', () => {
it('accepts DeepSeek session creation config', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { profile: 'dsh-tui', permissionMode: 'danger-full-access' },
});
expect(parsed.mode).toBe('deepseek');
expect(parsed.deepSeekConfig).toEqual({ profile: 'dsh-tui', permissionMode: 'danger-full-access' });
});
it('accepts DeepSeek quick-start config', () => {
const parsed = QuickStartSchema.parse({
caseName: 'dsh-case',
mode: 'deepseek',
deepSeekConfig: { resumeSessionId: 'sess_01H9', statusReporting: false },
});
expect(parsed.mode).toBe('deepseek');
expect(parsed.deepSeekConfig?.resumeSessionId).toBe('sess_01H9');
expect(parsed.deepSeekConfig?.statusReporting).toBe(false);
});
it('rejects a profile name that is not a single path segment', () => {
// A profile is BOTH interpolated into a `bash -c "…"` line and joined into a
// filesystem path under $DSH_HOME/profiles, so separators and traversal have
// to die at the schema boundary.
for (const profile of ['../../etc/passwd', 'a/b', './x', '-rf', 'has space', 'semi;colon']) {
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'deepseek', deepSeekConfig: { profile } })
).toThrow();
}
});
it('rejects an unknown permission preset', () => {
// The three presets are the harness's own; anything else would be exported
// verbatim as DSH_PERMISSION_MODE and silently fall back to its default.
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { permissionMode: 'yolo' },
})
).toThrow();
});
it('rejects unsafe resumeSessionId values', () => {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { resumeSessionId: '../../etc/passwd' },
})
).toThrow();
});
it('allows DSH_* and DEEPSEEK_* env overrides but not a foreign provider key', () => {
const ok = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
envOverrides: { DSH_HOME: '/tmp/dsh', DEEPSEEK_API_KEY: 'sk-test' },
});
expect(ok.envOverrides).toEqual({ DSH_HOME: '/tmp/dsh', DEEPSEEK_API_KEY: 'sk-test' });
// A dsh settings.yaml can name ANY env var as a provider credential
// (apiKeyEnv), which is pi's 34-provider-key problem in a new shape. The
// allowlist is global, so admitting them would widen every mode at once.
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
envOverrides: { QWEN5090_API_KEY: 'sk-test' },
})
).toThrow();
});
});
describe('DeepSeek spawn command', () => {
it('boots the requested profile', () => {
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'dsh-tui' },
});
expect(cmd).toBe('dsh --profile dsh-tui');
});
it('falls back to the resolved default profile when none was requested', () => {
const cmd = buildSpawnCommand({ mode: 'deepseek', sessionId: 's1' });
expect(cmd).toBe('dsh --profile dsh-tui');
});
it('never puts anything permission-shaped on the command line', () => {
// The harness has NO permission flag: the switch is the DSH_PERMISSION_MODE
// env export, applied via `tmux setenv`. If this ever starts failing, someone
// has invented a flag that does not exist.
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'dsh-tui', permissionMode: 'danger-full-access' },
});
expect(cmd).toBe('dsh --profile dsh-tui');
expect(cmd).not.toMatch(/danger|approve|permission|yolo|dangerously/i);
});
it('prefers an explicit resume id over the most-recent form', () => {
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'p', resumeSession: true, resumeSessionId: 'sess_42' },
});
expect(cmd).toBe('dsh --profile p --resume sess_42');
});
it('resumes the most recent session when only the flag is set', () => {
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'p', resumeSession: true },
});
expect(cmd).toBe('dsh --profile p --resume');
});
it('drops an unsafe profile rather than interpolating it', () => {
// Defense in depth behind the schema: builders must not trust their callers,
// because this string is interpolated into a `bash -c "…"` argument.
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'evil; rm -rf /' },
});
expect(cmd).not.toContain('rm -rf');
expect(cmd).toBe('dsh --profile dsh-tui');
});
});
describe('DeepSeek mode wiring', () => {
it('is an external CLI mode', () => {
expect(isExternalCliMode('deepseek')).toBe(true);
});
it('is NOT an alt-screen strip mode', () => {
// The strip is for Ink-style repaint TUIs (claude/codex/gemini). A dsh
// terminal profile is a third-party fullscreen TUI, i.e. the opencode case.
expect(isAltScreenStripMode('deepseek')).toBe(false);
});
it('has default remote and docker commands', () => {
expect(defaultRemoteCommandForMode('deepseek')).toContain('dsh');
expect(defaultDockerCommandForMode('deepseek')).toBe('exec dsh');
});
});
describe('DeepSeek status bridge', () => {
it('is the only non-claude mode allowed to deliver hook signals', () => {
// Earned, not granted: the harness terminal front door REPORTS its state to
// a supervisor, so `stop` and `blocked` for a dsh session are definitive
// rather than inferred. Every other external CLI must keep failing this.
expect(hooksAvailableForMode('deepseek')).toBe(true);
expect(hooksAvailableForMode('claude')).toBe(true);
for (const mode of ['shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok'] as const) {
expect(hooksAvailableForMode(mode)).toBe(false);
}
});
it('is a per-SESSION answer for deepseek: a disarmed status bridge emits nothing', () => {
// `statusReporting: false` is what stops _configureDeepSeek() exporting the
// HERDR_* triple, and the triple is the ONLY reason a dsh session posts hook
// events. Answering from the mode alone would accept `until=stop` on a
// session where nothing can ever send one, which is the exact
// infinite-wait-dressed-as-a-timeout this predicate exists to prevent.
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: false })).toBe(false);
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: true })).toBe(true);
// Not sent = ON, so an ordinary session is unaffected.
expect(hooksAvailableForMode('deepseek', {})).toBe(true);
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: undefined })).toBe(true);
// The flag is meaningless for every other mode and must not move them.
expect(hooksAvailableForMode('claude', { deepSeekStatusReporting: false })).toBe(true);
expect(hooksAvailableForMode('codex', { deepSeekStatusReporting: true })).toBe(false);
});
it('refuses an explicit stop/blocked on a dsh session whose bridge is off, and says why', () => {
const off = { mode: 'deepseek' as const, deepSeekStatusReporting: false };
const on = { mode: 'deepseek' as const };
expect(resolveWaitSignals('stop', on)).toEqual({ until: ['stop'], error: null });
const rejected = resolveWaitSignals('stop', off);
expect(rejected.until).toEqual([]);
// The generic "no Claude Code hooks" wording would send the caller hunting a
// bug that is really a setting they chose, so this arm names the setting.
expect(rejected.error).toContain('statusReporting');
expect(rejected.error).not.toContain('no Claude Code hooks');
// An OMITTED `until` must never 400: the hook-only signals are dropped from
// the default set instead, leaving the two that still work.
expect(resolveWaitSignals(undefined, off)).toEqual({ until: ['idle', 'exit'], error: null });
expect(resolveWaitSignals(undefined, on).until).toContain('stop');
});
it('refuses stop/blocked on a docker or remote dsh session, where the bridge cannot reach the harness', () => {
// `docker exec` does not carry the local tmux env into the container and the
// remote shell never sees the local `HERDR_*` setenv, so such a session can
// never post a hook event however statusReporting is set — accepting
// `until=stop` there burns the caller's whole timeout on every turn.
expect(hooksAvailableForMode('deepseek', { deepSeekBridgeUnreachable: true })).toBe(false);
const unreachable = { mode: 'deepseek' as const, deepSeekBridgeUnreachable: true };
const rejected = resolveWaitSignals('stop', unreachable);
expect(rejected.until).toEqual([]);
expect(rejected.error).toContain('container or on a remote host');
// The default set degrades instead of erroring, exactly like the disarmed case.
expect(resolveWaitSignals(undefined, unreachable)).toEqual({ until: ['idle', 'exit'], error: null });
// sessionHookOptions() is what lifts the fact off a live session.
expect(sessionHookOptions({ docker: { containerName: 'c' } }).deepSeekBridgeUnreachable).toBe(true);
expect(sessionHookOptions({ remote: { hostId: 'h' } }).deepSeekBridgeUnreachable).toBe(true);
expect(sessionHookOptions({}).deepSeekBridgeUnreachable).toBe(false);
});
it('keeps the hook predicate out of the two gates that mean "is this claude"', () => {
// Read My Mind and intent capture read Claude's own transcript, so they mean
// mode === 'claude'. They used to ask hooksAvailableForMode(), which was the
// same question until `deepseek` earned a yes and silently widened both to a
// mode with no transcript to read. Static, because the alternative is
// standing up a predictor and a transcript watcher to observe one `if`.
const rmm = readFileSync(join(process.cwd(), 'src/web/routes/readmymind-routes.ts'), 'utf-8');
expect(rmm).toContain("session.mode !== 'claude'");
// Comment lines dropped first: the comment above that `if` names the
// predicate in order to explain why it is NOT the one being called there.
const uncommented = (src: string) =>
src
.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
expect(uncommented(rmm)).not.toMatch(/hooksAvailableForMode\(/);
const server = readFileSync(join(process.cwd(), 'src/web/server.ts'), 'utf-8');
expect(server).toContain("if (!session || session.mode !== 'claude') return;");
});
it('maps the harness lifecycle states onto real hook events', () => {
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.idle).toBe('stop');
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.blocked).toBe('permission_prompt');
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.working).toBe('agent_working');
// Every mapped event must be one the hook endpoint actually accepts, or the
// bridge would post reports the schema silently rejects.
for (const event of Object.values(DEEPSEEK_STATE_TO_HOOK_EVENT)) {
expect(() => HookEventSchema.parse({ event, sessionId: 's1' })).not.toThrow();
}
});
});
describe('DeepSeek multi-user clamp', () => {
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('clamps a sent danger-full-access down to workspace-write, not read-only', () => {
// The clamp removes PRIVILEGE; it must not also break the session's ability
// to edit its own workspace, which read-only would.
return _clampExternalCliBypassForOwner('nobody', undefined, undefined, undefined, undefined, undefined, {
permissionMode: 'danger-full-access',
}).then((out) => {
expect(out.deepSeekConfig?.permissionMode).toBe('workspace-write');
});
});
it('leaves an ABSENT config absent (the only-if-sent branch)', async () => {
// Omitting DSH_PERMISSION_MODE leaves the harness on its own workspace-write
// preset, which still asks — so there is nothing to materialize, unlike pi.
const out = await _clampExternalCliBypassForOwner(
'nobody',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
expect(out.deepSeekConfig).toBeUndefined();
});
});
describe('DeepSeek multi-user clamp: the env-var half', () => {
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('strips DSH_PERMISSION_MODE, which would otherwise undo the config clamp on the same request', async () => {
// applyEnvOverrides() runs AFTER _configureDeepSeek() in tmux-manager, so an
// override sent alongside the config lands last and WINS. Clamping the config
// alone is therefore half a gate: this is the other half.
const out = await _clampEnvOverridesForOwner('nobody', {
DSH_PERMISSION_MODE: 'danger-full-access',
DSH_TELEMETRY_MODE: 'off',
});
expect(out).toEqual({ DSH_TELEMETRY_MODE: 'off' });
});
it('strips DSH_HOME, which points the launcher at a profile tree that executes at boot', async () => {
const out = await _clampEnvOverridesForOwner('nobody', { DSH_HOME: '/home/attacker/evil-dsh' });
expect(out).toEqual({});
});
it("strips DEEPSEEK_BASE_URL, which would aim the server's own forwarded API key at a foreign host", async () => {
// _configureDeepSeek() exports the SERVER's DEEPSEEK_API_KEY into every dsh
// pane, and applyEnvOverrides() lands after it — so a non-granted owner who
// could set the base URL would have the operator's key sent as a bearer
// credential to an endpoint of their choosing. Their OWN key stays settable:
// that removes privilege rather than granting it.
const out = await _clampEnvOverridesForOwner('nobody', {
DEEPSEEK_BASE_URL: 'https://attacker.example/v1',
DEEPSEEK_API_KEY: 'sk-their-own',
});
expect(out).toEqual({ DEEPSEEK_API_KEY: 'sk-their-own' });
});
it('leaves unrelated overrides alone, and returns the same object when there is nothing to strip', async () => {
const input = { DEEPSEEK_API_KEY: 'sk-test', CODEX_HOME: '/tmp/cx' };
const out = await _clampEnvOverridesForOwner('nobody', input);
expect(out).toBe(input);
expect(await _clampEnvOverridesForOwner('nobody', undefined)).toBeUndefined();
});
it('is a no-op in single-user mode', async () => {
delete process.env.CODEMAN_MULTIUSER;
const input = { DSH_PERMISSION_MODE: 'danger-full-access', DSH_HOME: '/opt/dsh' };
// canUsernameRunPrivilegedCommands() returns true when !isMultiUserMode(), so
// the single-user behaviour has to be byte-identical to before this clamp.
expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input);
});
});
describe('DeepSeek profile install is bounded for real', () => {
it('runs in its own process group and escalates the kill to the whole tree', () => {
// `dsh plugin add` fans out into package-manager resolver/build children, and
// spawn's own `timeout` signals only the direct child: survivors hold the
// inherited stdio pipes open, `close` never fires, and the held-open request
// leaks forever. Same failure and same fix as runGit() in git-clone.ts.
// Static, because reproducing it needs a real package manager that hangs.
const src = readFileSync(join(process.cwd(), 'src/web/routes/system-routes.ts'), 'utf-8');
const handler = src.slice(src.indexOf("app.post('/api/deepseek/install-profile'"));
const body = handler.slice(0, handler.indexOf('app.post(', 1) + 1 || handler.length);
expect(body).toContain('detached: true');
expect(body).toContain('process.kill(-child.pid, signal)');
expect(body).toContain("killTree('SIGTERM')");
expect(body).toContain("killTree('SIGKILL')");
// The built-in option is the thing that did NOT work here; it must not come back.
expect(body).not.toContain('timeout: DEEPSEEK_INSTALL_TIMEOUT_MS');
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* The generated DeepSeek Harness status shim.
*
* This file is the one piece of DeepSeek's wiring that is neither TypeScript we
* typecheck nor a route we can `inject()` into: it is a script emitted as a
* string, dropped in the data dir, and executed by a third-party TUI as a
* SUBPROCESS. So the assertions here run it the way the harness does — a real
* `node` process, real argv, real env, against a real listener — rather than
* inspecting the source text.
*
* The exit codes are the contract's load-bearing half: the caller retries with
* backoff on any non-zero, so "cannot ever succeed" (unknown verb, unmapped
* state) must exit 0 or one typo becomes four HTTP requests per state change,
* forever.
*/
import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs';
import { dirname } from 'node:path';
import {
ensureDeepSeekStatusShim,
deepSeekStatusShimPath,
resetDeepSeekStatusShimForTest,
DEEPSEEK_STATE_TO_HOOK_EVENT,
} from '../src/deepseek-status-shim.js';
const PORT = 3251;
describe('DeepSeek status shim: provisioning', () => {
beforeEach(() => {
resetDeepSeekStatusShimForTest();
});
it('writes an executable shim that node can actually parse', () => {
const path = ensureDeepSeekStatusShim();
expect(path).toBeTruthy();
expect(existsSync(path!)).toBe(true);
// 0700: the TUI execs it directly, so a lost exec bit means every report
// fails and is retried four times per state change.
expect(statSync(path!).mode & 0o777).toBe(0o700);
// `node --check` on the real file, because a template-literal typo in
// SHIM_SOURCE is invisible to tsc — the shim is a STRING as far as the
// compiler is concerned.
expect(() => execFileSync(process.execPath, ['--check', path!], { stdio: 'pipe' })).not.toThrow();
});
it('refreshes a shim written by an older Codeman, and leaves no temp file behind', () => {
const path = deepSeekStatusShimPath();
ensureDeepSeekStatusShim();
const current = readFileSync(path, 'utf-8');
// A v1 shim from an older install: right path, stale content.
writeFileSync(path, '#!/usr/bin/env node\n// codeman-dsh-status-shim v1\nprocess.exit(0)\n', { mode: 0o700 });
resetDeepSeekStatusShimForTest();
ensureDeepSeekStatusShim();
expect(readFileSync(path, 'utf-8')).toBe(current);
// The rewrite goes through a temp + rename so a TUI exec'ing this path mid
// refresh can never read a half-written file. The temp must not survive it.
const strays = readdirSync(dirname(path)).filter((f) => f.startsWith('dsh-status-shim') && f.endsWith('.tmp'));
expect(strays).toEqual([]);
});
it('re-asserts the exec bit even when the content already matches', () => {
const path = ensureDeepSeekStatusShim()!;
chmodSync(path, 0o600); // a restored backup / copied data dir
resetDeepSeekStatusShimForTest();
ensureDeepSeekStatusShim();
expect(statSync(path).mode & 0o777).toBe(0o700);
});
});
describe('DeepSeek status shim: the supervisor contract', () => {
let server: Server | undefined;
const received: Array<{ body: unknown; secret: string | undefined }> = [];
let status = 200;
const listen = () =>
new Promise<void>((resolve) => {
server = createServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
received.push({
body: (() => {
try {
return JSON.parse(raw);
} catch {
return raw;
}
})(),
secret: req.headers['x-codeman-hook-secret'] as string | undefined,
});
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end('{}');
});
});
server.listen(PORT, '127.0.0.1', resolve);
});
beforeAll(() => listen());
afterAll(() => {
server?.close();
});
/**
* Run the shim the way the TUI does, and ASYNCHRONOUSLY.
*
* Never spawnSync here: the listener above lives in this same process, so a
* synchronous spawn blocks the event loop that has to accept the connection.
* The shim then waits out its own 1500ms socket timeout and exits 1, which
* reads exactly like a broken shim (measured: `Socket._onTimeout` in its exit
* trace, and the server logging nothing).
*/
const run = (args: string[], env: Record<string, string> = {}) =>
new Promise<{ status: number | null; stderr: string }>((resolve) => {
const path = ensureDeepSeekStatusShim()!;
const child = spawn(process.execPath, [path, ...args], {
env: {
...process.env,
CODEMAN_API_URL: `http://127.0.0.1:${PORT}`,
CODEMAN_SESSION_ID: 'sess-from-env',
...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (c: Buffer) => (stderr += c.toString('utf-8')));
child.on('close', (status) => resolve({ status, stderr }));
});
// The exact command line the harness TUI runs, from the Herdr contract.
const report = (state: string, extra: string[] = []) => [
'pane',
'report-agent',
'pane-arg-id',
'--source',
'custom:dsh-tui',
'--agent',
'dsh-tui',
'--state',
state,
...extra,
'--seq',
'7',
];
it('forwards each harness state as its mapped hook event, and exits 0 on delivery', async () => {
resetDeepSeekStatusShimForTest();
for (const [state, event] of Object.entries(DEEPSEEK_STATE_TO_HOOK_EVENT)) {
received.length = 0;
const out = await run(report(state, ['--message', 'needs a decision']));
expect(out.status, `${state}: ${out.stderr}`).toBe(0);
expect(received).toHaveLength(1);
const body = received[0].body as { event: string; sessionId: string; data: Record<string, unknown> };
expect(body.event).toBe(event);
// The ambient env wins over the pane argument: same code set both, and the
// argument is whatever the TUI chose to pass.
expect(body.sessionId).toBe('sess-from-env');
expect(body.data.agent).toBe('dsh-tui');
expect(body.data.message).toBe('needs a decision');
}
});
it('sends the hook secret read at EXECUTION time, so rotation needs no respawn', async () => {
resetDeepSeekStatusShimForTest();
const secretFile = `${deepSeekStatusShimPath()}.secret-fixture`;
writeFileSync(secretFile, 'rotated-secret\n', { mode: 0o600 });
received.length = 0;
const out = await run(report('idle'), { CODEMAN_HOOK_SECRET_FILE: secretFile });
expect(out.status).toBe(0);
expect(received[0].secret).toBe('rotated-secret');
});
it('exits 0 without posting for anything a retry could never fix', async () => {
resetDeepSeekStatusShimForTest();
for (const args of [
['pane', 'list'], // unknown verb
['something-else', 'report-agent', 'id', '--state', 'idle'], // unknown noun
[...report('rebooting')], // a state this bridge does not map
['pane', 'report-agent', 'id'], // no --state at all
]) {
received.length = 0;
const out = await run(args);
expect(out.status, `args ${args.join(' ')}`).toBe(0);
expect(received).toEqual([]);
}
});
it('exits non-zero when the post genuinely fails, so the caller retries', async () => {
resetDeepSeekStatusShimForTest();
// A rejecting server: transport worked, Codeman said no.
status = 500;
received.length = 0;
expect((await run(report('idle'))).status).not.toBe(0);
expect(received).toHaveLength(1);
status = 200;
// Nothing listening at all.
expect((await run(report('idle'), { CODEMAN_API_URL: 'http://127.0.0.1:1' })).status).not.toBe(0);
// No API url to post to.
expect((await run(report('idle'), { CODEMAN_API_URL: '' })).status).not.toBe(0);
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* The background `dsh web` supervisor and the authority boundary in front of it.
*
* Two things here are worth pinning and neither is obvious from reading the
* module:
*
* 1. `authority` becomes an argv element of a spawned process (`--trusted-host
* <authority>`). The spawn is an argv ARRAY so a shell can never see it, but
* the schema is the layer that stops a value which is not a browser
* authority at all from reaching the command line, and a regex is easy to
* widen by accident.
* 2. The supervisor tracks at most ONE server. The status accessor is what every
* caller reads to decide whether to start another, so "no server" must report
* as absent rather than as a half-populated record.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { DeepSeekWebStartSchema } from '../src/web/schemas.js';
import { getDeepSeekWebStatus, resetDeepSeekWebForTest, stopDeepSeekWeb } from '../src/deepseek-web-server.js';
describe('DeepSeekWebStartSchema: the authority reaching --trusted-host', () => {
it('accepts the authority shapes a browser can actually report', () => {
for (const authority of [
'localhost:3000',
'127.0.0.1:5013',
'tnode.tailf80371.ts.net:8444',
'codeman.example.com',
'[::1]:3000',
'host-with-dashes.local:80',
]) {
expect(DeepSeekWebStartSchema.safeParse({ authority }).success, authority).toBe(true);
}
});
it('rejects values that are not an authority at all', () => {
for (const authority of [
'',
'http://localhost:3000', // a URL, not an authority
'localhost:3000 --trusted-host evil', // an embedded second argument
'-oProxyCommand=evil', // leading dash, readable as a flag
'localhost:3000/../path',
'local host:3000',
'user:pass@localhost:3000',
'a'.repeat(256),
]) {
expect(DeepSeekWebStartSchema.safeParse({ authority }).success, authority).toBe(false);
}
});
it('is strict, so an unexpected field cannot ride along', () => {
expect(DeepSeekWebStartSchema.safeParse({ authority: 'localhost:3000', port: 1 }).success).toBe(false);
});
it('requires the field rather than defaulting it', () => {
// A guessed default would silently fence dsh's /api against the wrong
// origin, which presents as a dashboard whose every call 403s.
expect(DeepSeekWebStartSchema.safeParse({}).success).toBe(false);
});
});
describe('DeepSeek web supervisor: status', () => {
beforeEach(() => {
resetDeepSeekWebForTest();
});
it('reports absent as fully null, not a half-filled record', () => {
expect(getDeepSeekWebStatus()).toEqual({ running: false, port: null, url: null, authority: null });
});
it('stopping when nothing runs resolves rather than throwing', async () => {
await expect(stopDeepSeekWeb()).resolves.toBeUndefined();
expect(getDeepSeekWebStatus().running).toBe(false);
});
});
+11 -1
View File
@@ -424,7 +424,17 @@ describe('mobile overview run picker (CLI availability gating)', () => {
isCliAvailable: () => true,
});
const menu = app._buildMobileOverviewRunMenu();
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']);
expect(modeButtons(menu)).toEqual([
'claude',
'opencode',
'codex',
'gemini',
'antigravity',
'pi',
'grok',
'deepseek',
'shell',
]);
});
it('gates every mode the picker actually offers', () => {
+15
View File
@@ -19,6 +19,7 @@ import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
@@ -55,6 +56,16 @@ vi.mock('../src/utils/grok-cli-resolver.js', () => ({
resolveGrokDir: vi.fn(() => null),
getGrokCliVersion: vi.fn(() => null),
}));
// DeepSeek is the one mode with a two-part availability answer (binary AND a
// pane-capable profile), so both probes are mocked independently.
vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({
isDeepSeekAvailable: vi.fn(() => false),
isDeepSeekRunnable: vi.fn(() => false),
resolveDeepSeekDir: vi.fn(() => null),
getDeepSeekCliVersion: vi.fn(() => null),
listDeepSeekProfiles: vi.fn(() => []),
resolveDefaultDeepSeekProfile: vi.fn(() => null),
}));
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null),
@@ -160,6 +171,8 @@ describe('WebServer.renderIndexHtml', () => {
antigravity: false,
pi: true,
grok: false,
deepseek: false,
deepseekBinary: false,
cloudflared: true,
git: true,
});
@@ -176,6 +189,8 @@ describe('WebServer.renderIndexHtml', () => {
isAntigravityAvailable,
isPiAvailable,
isGrokAvailable,
isDeepSeekAvailable,
isDeepSeekRunnable,
isCloudflaredAvailable,
isGitAvailable,
]) {