feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode

Adds `mode: 'deepseek'` alongside claude/shell/opencode/codex/gemini/
antigravity/pi/grok, plus a shortcut that opens the harness's own browser UI
as a Codeman web tab.

DeepSeek is wired unlike its siblings in three ways, each of which is the
reason for a design decision rather than an accident:

1. The agent is a PROFILE, not the binary. `dsh` is a launcher over
   $DSH_HOME/profiles/<name>, and DeepSeek ships only `web`, `headless` and
   `base` -- the interactive terminal front door is always a third-party
   plugin. So availability is two questions: `isDeepSeekAvailable()` (binary)
   and `isDeepSeekRunnable()` (binary AND a pane-capable profile). The Run
   button gates on the latter, because reporting only the binary would spawn a
   pane that dies on arrival. When the binary is present but no profile is,
   the run menu offers to install one (POST /api/deepseek/install-profile).

2. The permission switch is an env var, not a flag. The harness has no
   command-line permission option; its sandbox/approval rows read
   DSH_PERMISSION_MODE (read-only / workspace-write / danger-full-access).
   Exported via `tmux setenv`, never on the spawn line. Absent = the harness's
   own workspace-write, which still asks, so the multi-user clamp is the
   only-if-sent branch and clamps to workspace-write, never read-only.

3. It is the only non-claude mode that passes hooksAvailableForMode(), and it
   earned that. The terminal front door reports idle/working/blocked to a
   supervising process over a generic env-gated contract; a generated shim
   (deepseek-status-shim.ts) makes Codeman that supervisor and forwards each
   report to /api/hook-event as stop / agent_working / permission_prompt. So a
   dsh session gets definitive respawn triggers, real wait-endpoint signals and
   real Approvals Inbox items instead of output-stabilization guesswork.
   `agent_working` is new (157th SSE constant) and joins
   APPROVAL_RESOLVING_EVENTS so a dialog answered in the terminal clears its
   alert at once.

The resolver needs the strictest identity probe of the family: `dsh` is not
merely a squattable npm name, Debian ships an unrelated `dsh` (dancer's shell),
so `dsh --help` must print the harness's own banner before a candidate is
handed a spawn line.

Model is deliberately not a session field -- it is a composition entry in the
profile's config tree. Env allowlist gains DSH_* and DEEPSEEK_* only; provider
keys named by a settings-file `apiKeyEnv` stay out, which is pi's
34-provider-key problem in a new shape.

Verified live against dsh 0.1.1-rc.2 and @deepseek-harness-tui/dsh-tui: the
status endpoint's two-part answer, the no-profile refusal, the profile
bootstrap, a real session whose pane runs `dsh --profile dsh-tui` with the
permission mode injected via setenv, and the full status bridge -- a
send-and-wait returned signal "stop" from a real turn, and blocked/working
created and cleared an Approvals Inbox item.

Docs: docs/deepseek-integration.md (guide), docs/deepseek-integration-plan.md
(decisions + honest gaps). Tests: test/deepseek-mode.test.ts,
test/deepseek-cli-resolver.test.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-24 03:37:56 +02:00
parent 9cfd8e8989
commit 4cda150493
48 changed files with 2489 additions and 66 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 \ && rm -rf /root/.grok /root/.local/bin/grok /root/.local/bin/agent \
&& grok --version && 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 # `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 # 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 # 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: # `.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 # 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. # 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 \ 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 \ && 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/.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 \ && chgrp -R 0 /home/agent \
&& chmod -R g=u /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 ## 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'`) 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`.
**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.
⚠️ **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.
⚠️ **`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). 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. 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). **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).
+124
View File
@@ -0,0 +1,124 @@
# 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` | 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. |
| 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 3080 --trusted-host <codeman-authority>`
in an ordinary shell session and opens the URL as an ordinary web tab.
Built entirely from parts that already exist: the server is a shell session
(visible, scrollable, killable, dies with its tab) and the UI is a web tab.
Nothing new supervises a long-lived HTTP server, because Codeman already does.
`--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.
## 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 end to end through the webview proxy, in particular whether
`--trusted-host <codeman-authority>` is the right authority for dsh's `/api`
fence in every deployment shape (loopback, tailscale, tunnel).
## 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.
+218
View File
@@ -0,0 +1,218 @@
# 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).
> **`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.
## 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`).
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.
+56 -3
View File
@@ -125,6 +125,14 @@ PI_SEARCH_PATHS=(
"$HOME/bin/pi" "$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 CLI search paths (from src/utils/grok-cli-resolver.ts)
GROK_SEARCH_PATHS=( GROK_SEARCH_PATHS=(
"$HOME/.grok/bin/grok" "$HOME/.grok/bin/grok"
@@ -594,6 +602,46 @@ check_grok() {
return 1 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 cheap banner grep is enough and costs one exec.
check_dsh() {
local candidate
if command -v dsh &>/dev/null; then
candidate="$(command -v dsh)"
if "$candidate" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then
return 0
fi
fi
for path in "${DSH_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]] && "$path" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then
return 0
fi
done
return 1
}
get_dsh_path() {
local candidate
if command -v dsh &>/dev/null; then
candidate="$(command -v dsh)"
if "$candidate" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then
echo "$candidate"
return
fi
fi
for path in "${DSH_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]] && "$path" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then
echo "$path"
return
fi
done
}
get_grok_path() { get_grok_path() {
if command -v grok &>/dev/null; then if command -v grok &>/dev/null; then
command -v grok command -v grok
@@ -2123,6 +2171,7 @@ main() {
local has_antigravity=false local has_antigravity=false
local has_pi=false local has_pi=false
local has_grok=false local has_grok=false
local has_dsh=false
info "Checking AI CLI tools..." info "Checking AI CLI tools..."
if check_claude; then if check_claude; then
@@ -2153,10 +2202,14 @@ main() {
has_grok=true has_grok=true
success "Grok CLI found at $(get_grok_path)" success "Grok CLI found at $(get_grok_path)"
fi 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 "" 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)" headless_guard "install an AI CLI (curl | bash from its vendor)"
echo "" echo ""
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}" echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
@@ -2512,7 +2565,7 @@ main() {
echo -e " https://github.com/Ark0N/Codeman" echo -e " https://github.com/Ark0N/Codeman"
echo "" 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 " ${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://claude.ai/install.sh | bash${NC} # Claude Code"
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode" echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
+1
View File
@@ -63,6 +63,7 @@
"antigravity", "antigravity",
"pi", "pi",
"grok", "grok",
"deepseek",
"gemini-cli", "gemini-cli",
"ai-agents", "ai-agents",
"agent", "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 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). 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 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, **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. 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): `POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}` `{"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 `.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. 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 ⚠️ 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 `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`, 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). 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 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: `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, - ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers` which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:2261`), so it is permanently 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 (`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 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: 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) | | synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` | | liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` | | 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 | | delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume ## 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 ### 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 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, 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 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 done
printf '%s\n' "$TXT" printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and # (.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.) # 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 # 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, ⚠️ `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 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** 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. `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 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` 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 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 verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. Fall back to the terminal buffer why the loop above is bounded rather than open-ended. Fall back to the terminal buffer
there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive 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 ⚠️ `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. in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing 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 { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
import { GROK_VERSION_REGEX } from '../utils/grok-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'; 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', id: 'libreoffice',
label: 'LibreOffice', label: 'LibreOffice',
+3 -2
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, * answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript,
* so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve` * so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve`
* is NOT a clamp. * is NOT a clamp.
* Codex, antigravity and grok need nothing here: their absent config already spawns safe * Codex, antigravity, grok and deepseek 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). * (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. * Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched.
*/ */
export function clampCronExternalCliConfigs( export function clampCronExternalCliConfigs(
+233
View File
@@ -0,0 +1,233 @@
/**
* @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, 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 = 1;
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 and cannot be spoofed
// by an argument the agent itself could influence.
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.
}
const body = JSON.stringify({
event,
sessionId,
data: {
source: 'dsh-status-shim',
agent: flag('--agent') || 'dsh',
...(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()
process.exit(res.statusCode && res.statusCode >= 200 && res.statusCode < 300 ? 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 });
writeFileSync(path, SHIM_SOURCE, { mode: 0o700 });
}
// 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;
}
+12
View File
@@ -146,6 +146,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
antigravity: 'exec agy', antigravity: 'exec agy',
pi: 'exec pi', pi: 'exec pi',
grok: 'exec grok', grok: 'exec grok',
deepseek: 'exec dsh',
}; };
return commands[mode as DockerCommandMode] || commands.shell; return commands[mode as DockerCommandMode] || commands.shell;
} }
@@ -625,6 +626,17 @@ const CRED_STORES: CredStorePolicy[] = [
rel: '.grok', rel: '.grok',
seedFiles: ['auth.json', 'config.toml', 'pager.toml'], 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/gcloud', seedWhole: true },
{ rel: '.config/opencode', seedWhole: true }, { rel: '.config/opencode', seedWhole: true },
]; ];
+3
View File
@@ -20,6 +20,7 @@ import type {
AntigravityConfig, AntigravityConfig,
PiConfig, PiConfig,
GrokConfig, GrokConfig,
DeepSeekConfig,
SessionRemote, SessionRemote,
SessionDocker, SessionDocker,
} from './types.js'; } from './types.js';
@@ -80,6 +81,7 @@ export interface CreateSessionOptions {
antigravityConfig?: AntigravityConfig; antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig; piConfig?: PiConfig;
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */ /** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string; resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */ /** 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; antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig; piConfig?: PiConfig;
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
/** Resume a previous Claude conversation when respawning */ /** Resume a previous Claude conversation when respawning */
resumeSessionId?: string; resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */ /** 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'), antigravity: remoteLoginShellCommand('agy'),
pi: remoteLoginShellCommand('pi'), pi: remoteLoginShellCommand('pi'),
grok: remoteLoginShellCommand('grok'), 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; return commands[mode as RemoteCommandMode] || commands.shell;
} }
+26 -2
View File
@@ -52,6 +52,7 @@ import {
type AntigravityConfig, type AntigravityConfig,
type PiConfig, type PiConfig,
type GrokConfig, type GrokConfig,
type DeepSeekConfig,
type SessionRemote, type SessionRemote,
type SessionDocker, type SessionDocker,
} from './types.js'; } from './types.js';
@@ -178,7 +179,8 @@ export function isExternalCliMode(mode: SessionMode): boolean {
mode === 'gemini' || mode === 'gemini' ||
mode === 'antigravity' || mode === 'antigravity' ||
mode === 'pi' || mode === 'pi' ||
mode === 'grok' mode === 'grok' ||
mode === 'deepseek'
); );
} }
@@ -196,6 +198,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Pi'; return 'Pi';
case 'grok': case 'grok':
return 'Grok'; return 'Grok';
case 'deepseek':
return 'DeepSeek';
case 'shell': case 'shell':
return 'Shell'; return 'Shell';
case 'claude': case 'claude':
@@ -521,6 +525,9 @@ export class Session extends EventEmitter {
private _piConfig: PiConfig | undefined; private _piConfig: PiConfig | undefined;
// Grok configuration (only for mode === 'grok') // Grok configuration (only for mode === 'grok')
private _grokConfig: GrokConfig | undefined; private _grokConfig: GrokConfig | undefined;
// DeepSeek Harness configuration (only for mode === 'deepseek')
private _deepSeekConfig: DeepSeekConfig | undefined;
private _resumeSessionId: string | undefined; private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux // Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -618,6 +625,8 @@ export class Session extends EventEmitter {
piConfig?: PiConfig; piConfig?: PiConfig;
/** Grok configuration (only for mode === 'grok') */ /** Grok configuration (only for mode === 'grok') */
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig;
/** Resume a previous Claude conversation (used after server reboot) */ /** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string; resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */ /** 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; this._piConfig = config.piConfig;
} }
// Apply DeepSeek Harness configuration
if (config.deepSeekConfig) {
this._deepSeekConfig = config.deepSeekConfig;
}
// Apply Grok configuration // Apply Grok configuration
if (config.grokConfig) { if (config.grokConfig) {
this._grokConfig = config.grokConfig; this._grokConfig = config.grokConfig;
@@ -1325,6 +1339,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig, antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig, piConfig: this._piConfig,
grokConfig: this._grokConfig, grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
effort: this._effort, effort: this._effort,
// COD-118: runtime-only — surfaced so the frontend can require explicit user // COD-118: runtime-only — surfaced so the frontend can require explicit user
@@ -1501,7 +1516,8 @@ export class Session extends EventEmitter {
this.mode === 'gemini' || this.mode === 'gemini' ||
this.mode === 'antigravity' || this.mode === 'antigravity' ||
this.mode === 'pi' || this.mode === 'pi' ||
this.mode === 'grok' this.mode === 'grok' ||
this.mode === 'deepseek'
), ),
}) })
); );
@@ -1572,6 +1588,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig, antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig, piConfig: this._piConfig,
grokConfig: this._grokConfig, grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides, envOverrides: this._envOverrides,
effort: this._effort, effort: this._effort,
@@ -1831,6 +1848,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig, antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig, piConfig: this._piConfig,
grokConfig: this._grokConfig, grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides, envOverrides: this._envOverrides,
effort: this._effort, effort: this._effort,
@@ -1924,6 +1942,12 @@ export class Session extends EventEmitter {
if (this.mode === 'grok') { if (this.mode === 'grok') {
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.'); 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 { try {
// Pass --session-id to use the SAME ID as the Codeman session // Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab // This ensures subagents can be directly matched to the correct tab
+146 -2
View File
@@ -53,6 +53,7 @@ import {
type AntigravityConfig, type AntigravityConfig,
type PiConfig, type PiConfig,
type GrokConfig, type GrokConfig,
type DeepSeekConfig,
type SessionRemote, type SessionRemote,
type SessionDocker, type SessionDocker,
type DockerCommandMode, type DockerCommandMode,
@@ -95,6 +96,9 @@ import {
getPiNotFoundMessage, getPiNotFoundMessage,
resolveGrokDir, resolveGrokDir,
getGrokNotFoundMessage, getGrokNotFoundMessage,
resolveDeepSeekDir,
getDeepSeekNotFoundMessage,
resolveDefaultDeepSeekProfile,
resolveLocalShell, resolveLocalShell,
loginShellArgs, loginShellArgs,
} from './utils/index.js'; } from './utils/index.js';
@@ -119,6 +123,7 @@ import {
// ============================================================================ // ============================================================================
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
/** How long a cached process snapshot stays usable. */ /** How long a cached process snapshot stays usable. */
const PROC_SNAPSHOT_TTL_MS = 2000; const PROC_SNAPSHOT_TTL_MS = 2000;
@@ -846,6 +851,51 @@ function buildGrokCommand(config?: GrokConfig): string {
return parts.join(' '); 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. * Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication. * Shared by createSession() and respawnPane() to avoid duplication.
@@ -890,6 +940,7 @@ export function buildSpawnCommand(options: {
antigravityConfig?: AntigravityConfig; antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig; piConfig?: PiConfig;
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
resumeSessionId?: string; resumeSessionId?: string;
effort?: EffortLevel; effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */ /** 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') { if (options.mode === 'grok') {
return buildGrokCommand(options.grokConfig); 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 "…"` // #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`, // 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 // 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}`; return `${modeCommand} --session ${resumeId}`;
case 'grok': case 'grok':
return `${modeCommand} --resume ${resumeId}`; return `${modeCommand} --resume ${resumeId}`;
case 'deepseek':
return `${modeCommand} --resume ${resumeId}`;
default: default:
return modeCommand; // shell / opencode: no resume return modeCommand; // shell / opencode: no resume
} }
@@ -1749,10 +1805,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [ const exports = [
'export LANG=en_US.UTF-8', 'export LANG=en_US.UTF-8',
'export LC_ALL=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' ? 'export COLORTERM=truecolor'
: 'unset COLORTERM', : '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'] ? ['unset NO_COLOR']
: []), : []),
// Stamp each Codex pane with a unique originator so the response-viewer // 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(); const dir = resolveGrokDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; 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 }; return { pathExport: '', dir: null };
} }
@@ -1883,6 +1953,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
setGeminiEnvVars(this.tmux(), muxName); 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. * Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session). * 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, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
deepSeekConfig,
resumeSessionId, resumeSessionId,
envOverrides, envOverrides,
effort, effort,
@@ -1963,6 +2093,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'pi' && !cliDir) { if (mode === 'pi' && !cliDir) {
throw new Error(getPiNotFoundMessage()); throw new Error(getPiNotFoundMessage());
} }
if (mode === 'deepseek' && !cliDir) {
throw new Error(getDeepSeekNotFoundMessage());
}
if (mode === 'grok' && !cliDir) { if (mode === 'grok' && !cliDir) {
throw new Error(getGrokNotFoundMessage()); throw new Error(getGrokNotFoundMessage());
} }
@@ -1981,6 +2114,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
deepSeekConfig,
resumeSessionId, resumeSessionId,
effort, effort,
sessionName: name, sessionName: name,
@@ -2049,6 +2183,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'gemini') { if (mode === 'gemini') {
this._configureGemini(muxName); 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 // 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. // 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, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
deepSeekConfig,
resumeSessionId, resumeSessionId,
envOverrides, envOverrides,
effort, effort,
@@ -2236,6 +2375,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
deepSeekConfig,
resumeSessionId, resumeSessionId,
effort, effort,
sessionName: name, sessionName: name,
@@ -2260,6 +2400,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'gemini') { if (mode === 'gemini') {
this._configureGemini(muxName); 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. // Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides); 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: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' },
{ id: 'pi', label: 'pi', detail: 'pi.dev' }, { id: 'pi', label: 'pi', detail: 'pi.dev' },
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' }, { 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 { export interface TuiQuickStartOptions {
caseName: string; caseName: string;
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'; mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek';
sessionName?: string; sessionName?: string;
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */ /** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
parentSessionId?: string; parentSessionId?: string;
+71 -4
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt) * - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode * - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' * - 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') * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color * - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -17,6 +17,7 @@
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId) * - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust) * - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue) * - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
* *
* Cross-domain relationships: * Cross-domain relationships:
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain) * - 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'; export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
/** Session mode: which CLI backend a session runs */ /** 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< export type RemoteCommandMode = Extract<
SessionMode, 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). */ /** Which CLI backends a Docker case can run (same set as remote). */
export type DockerCommandMode = Extract< export type DockerCommandMode = Extract<
SessionMode, 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. */ /** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
@@ -388,6 +398,61 @@ export interface GrokConfig {
resumeSessionId?: string; 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 * Configuration for creating a new session
*/ */
@@ -553,6 +618,8 @@ export interface SessionState {
piConfig?: PiConfig; piConfig?: PiConfig;
/** Grok-specific configuration (only for mode === 'grok') */ /** Grok-specific configuration (only for mode === 'grok') */
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */ /** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string; resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
+337
View File
@@ -0,0 +1,337 @@
/**
* @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.
*/
const TUI_BUNDLE_PATTERN = /dsh-tui|dsh-terminal-app|tui/i;
/** 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 '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'));
}
+13
View File
@@ -46,5 +46,18 @@ export {
} from './antigravity-cli-resolver.js'; } from './antigravity-cli-resolver.js';
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js'; export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-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 { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js'; export type { FileQueryMatcher } from './file-query.js';
+10 -5
View File
@@ -240,6 +240,7 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'], [SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'], [SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
[SSE_EVENTS.HOOK_STOP, '_onHookStop'], [SSE_EVENTS.HOOK_STOP, '_onHookStop'],
[SSE_EVENTS.HOOK_AGENT_WORKING, '_onHookAgentWorking'],
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'], [SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'], [SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
@@ -2252,9 +2253,11 @@ class CodemanApp {
? 'Pi' ? 'Pi'
: mode === 'grok' : mode === 'grok'
? 'Grok' ? 'Grok'
: mode === 'opencode' : mode === 'deepseek'
? 'OpenCode' ? 'DeepSeek'
: 'Claude'; : mode === 'opencode'
? 'OpenCode'
: 'Claude';
} }
async toggleResponseViewer() { async toggleResponseViewer() {
@@ -4805,7 +4808,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span> <span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info"> <span class="tab-info">
<span class="tab-name-row"> <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> <span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${inlineSessionActions ? tabActionsHtml : ''} ${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span> <span class="tab-detached-badge" aria-hidden="true">detached</span>
@@ -6238,7 +6241,9 @@ class CodemanApp {
? 'Kill Tmux & Pi' ? 'Kill Tmux & Pi'
: session.mode === 'grok' : session.mode === 'grok'
? 'Kill Tmux & Grok' ? 'Kill Tmux & Grok'
: 'Kill Tmux & Claude Code'; : session.mode === 'deepseek'
? 'Kill Tmux & DeepSeek'
: 'Kill Tmux & Claude Code';
} }
document.getElementById('closeConfirmModal').classList.add('active'); document.getElementById('closeConfirmModal').classList.add('active');
+7
View File
@@ -54,6 +54,12 @@ const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notificati
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay const THROTTLE_DELAY_MS = 100; // General UI throttle delay
/**
* Port the DeepSeek Harness browser UI is started on by the run-menu shortcut.
* dsh's own default, so a hand-started `dsh web` and the shortcut land on the
* same place and share one saved tab.
*/
const DEEPSEEK_WEB_PORT = 3080;
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch) const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
@@ -949,6 +955,7 @@ const SSE_EVENTS = {
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete', HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response', HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
HOOK_STOP: 'hook:stop', HOOK_STOP: 'hook:stop',
HOOK_AGENT_WORKING: 'hook:agent_working',
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle', HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
HOOK_TASK_COMPLETED: 'hook:task_completed', HOOK_TASK_COMPLETED: 'hook:task_completed',
+1
View File
@@ -79,6 +79,7 @@ const HOME_SESSIONS_MODE_BADGE = {
antigravity: 'ag', antigravity: 'ag',
pi: 'pi', pi: 'pi',
grok: 'gk', grok: 'gk',
deepseek: 'ds',
}; };
Object.assign(CodemanApp.prototype, { Object.assign(CodemanApp.prototype, {
+1
View File
@@ -110,6 +110,7 @@
'Run Antigravity': '运行 Antigravity', 'Run Antigravity': '运行 Antigravity',
'Run Pi': '运行 Pi', 'Run Pi': '运行 Pi',
'Run Grok': '运行 Grok', 'Run Grok': '运行 Grok',
'Run DeepSeek': '运行 DeepSeek',
'Run Shell': '运行 Shell', 'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端', 'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例', 'Create New Case': '新建案例',
+23 -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> <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 Run Grok
</button> </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>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()"> <div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div> <div class="welcome-qr-inner" id="welcomeQrInner"></div>
@@ -630,6 +634,15 @@
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')"> <button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
<span class="run-mode-dot grok"></span>Grok <span class="run-mode-dot grok"></span>Grok
</button> </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> <div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')"> <button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell <span class="run-mode-dot shell"></span>Terminal / Shell
@@ -642,6 +655,13 @@
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()"> <button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
<span class="run-mode-dot web"></span>Add URL&hellip; <span class="run-mode-dot web"></span>Add URL&hellip;
</button> </button>
<!-- The DeepSeek Harness browser UI is the vendor's OWN interactive
surface (the terminal one is third-party), so it gets a shortcut:
this starts `dsh web` in a shell session and opens it as a 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-sep"></div>
<div class="run-mode-header">Recent Sessions</div> <div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div> <div class="run-mode-history" id="runModeHistory"></div>
@@ -908,6 +928,7 @@
<option value="antigravity">Antigravity</option> <option value="antigravity">Antigravity</option>
<option value="pi">Pi</option> <option value="pi">Pi</option>
<option value="grok">Grok</option> <option value="grok">Grok</option>
<option value="deepseek">DeepSeek</option>
</select> </select>
</div> </div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div> <div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -2676,6 +2697,7 @@
<option value="antigravity" data-cli="antigravity">Antigravity</option> <option value="antigravity" data-cli="antigravity">Antigravity</option>
<option value="pi" data-cli="pi">Pi</option> <option value="pi" data-cli="pi">Pi</option>
<option value="grok" data-cli="grok">Grok</option> <option value="grok" data-cli="grok">Grok</option>
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
<option value="shell">Shell (no agent)</option> <option value="shell">Shell (no agent)</option>
</select> </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> <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 +2838,7 @@
<div class="form-row"> <div class="form-row">
<label>Image</label> <label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false"> <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>
<div class="form-row"> <div class="form-row">
<label>Network</label> <label>Network</label>
+1
View File
@@ -55,6 +55,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' }, { mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
{ mode: 'pi', label: 'Pi', short: 'Pi' }, { mode: 'pi', label: 'Pi', short: 'Pi' },
{ mode: 'grok', label: 'Grok', short: 'Grok' }, { mode: 'grok', label: 'Grok', short: 'Grok' },
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' }, { mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
]; ];
+23
View File
@@ -986,6 +986,23 @@ html.mobile-init .file-browser-panel {
border-color: rgba(212, 212, 216, 0.5) !important; 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 dropdown menu — positioned above toolbar on mobile */
.run-mode-menu { .run-mode-menu {
bottom: 100%; bottom: 100%;
@@ -3075,6 +3092,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff; 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 { 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; border-left-color: var(--control-border-hover) !important;
} }
+1 -1
View File
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') { _buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude'; 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'; const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return { return {
id: 'new-session', id: 'new-session',
+198 -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 modal (per-session settings, color picker, rename),
* session options tabs (Ralph config tab), case settings (CRUD, links), * session options tabs (Ralph config tab), case settings (CRUD, links),
* create case modal, and mobile case picker. * create case modal, and mobile case picker.
@@ -406,6 +406,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'grok') { if (mode === 'grok') {
return await this.runGrok(); return await this.runGrok();
} }
if (mode === 'deepseek') {
return await this.runDeepSeek();
}
if (mode === 'shell') { if (mode === 'shell') {
return await this.runShell(); return await this.runShell();
} }
@@ -471,10 +474,121 @@ Object.assign(CodemanApp.prototype, {
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed. * run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/ */
_refreshRunModeAvailability(menu) { _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}"]`); const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; 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.
*
* Deliberately built from parts that already exist rather than a new process
* manager: the server runs in an ordinary SHELL session, so it is visible,
* scrollable, killable and dies with its tab like anything else, and the UI
* itself is an ordinary web tab. Nothing here needs to know how to supervise a
* long-lived HTTP server, because Codeman already does.
*
* `--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.
*/
async runDeepSeekWeb() {
document.getElementById('runModeMenu')?.classList.remove('active');
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const port = DEEPSEEK_WEB_PORT;
const url = `http://127.0.0.1:${port}`;
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting the DeepSeek web UI in ${caseName}...`);
try {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'shell',
sessionName: `dsh-web-${caseName}`,
}),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start the shell session');
const sessionId = data.data.sessionId;
await this._ensureCreatedSessionVisible(sessionId, data.data.session);
// The shell needs a moment to reach its prompt before it will accept a
// command; the same settle the other shell-driven flows use.
await new Promise((r) => setTimeout(r, 1200));
const cmd = `dsh web --no-open --host 127.0.0.1 --port ${port} --trusted-host ${location.host}`;
await fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: `${cmd}\r` }),
});
// Reuse a saved tab for the same URL rather than stacking duplicates every
// time the server is restarted.
let webview = [...(this.webviews?.values() || [])].find((w) => w.url === url);
if (!webview) {
const wvRes = await fetch('/api/webviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'DeepSeek Harness', url, icon: '🐳' }),
});
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;
await this.loadWebviews?.();
}
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() { async _loadRunModeHistory() {
@@ -568,7 +682,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
} }
if (label) { 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 +1455,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 // Session Options Modal
@@ -1406,7 +1598,7 @@ Object.assign(CodemanApp.prototype, {
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId); if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only) // 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'); this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons // Update respawn status display and buttons
@@ -1436,7 +1628,7 @@ Object.assign(CodemanApp.prototype, {
} }
// Hide Claude-specific options for external CLI sessions // 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]'); const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; }); claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
@@ -3217,7 +3409,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
}, },
set(mode) { set(mode) {
this._runMode = 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 ? mode
: 'claude'; : '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'); 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) { _onHookTeammateIdle(data) {
const session = this.sessions.get(data.sessionId); 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}`); 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'], ['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'], ['welcomePiBtn', 'pi'],
['welcomeGrokBtn', 'grok'], ['welcomeGrokBtn', 'grok'],
['welcomeDeepSeekBtn', 'deepseek'],
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box // Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
// without cloudflared can only ever produce "cloudflared not found". // without cloudflared can only ever produce "cloudflared not found".
['welcomeTunnelBtn', 'cloudflared'], ['welcomeTunnelBtn', 'cloudflared'],
+40
View File
@@ -2501,6 +2501,16 @@ body.solo-mode .btn-lifecycle-log {
color: #d4d4d8; 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 - Compact */
.timer-banner { .timer-banner {
display: flex; display: flex;
@@ -4975,6 +4985,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #fafafa; 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 */ /* Dropdown menu */
.run-mode-menu { .run-mode-menu {
display: none; display: none;
@@ -5059,6 +5088,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.antigravity { background: #22d3ee; } .run-mode-dot.antigravity { background: #22d3ee; }
.run-mode-dot.pi { background: #f472b6; } .run-mode-dot.pi { background: #f472b6; }
.run-mode-dot.grok { background: #a1a1aa; } .run-mode-dot.grok { background: #a1a1aa; }
.run-mode-dot.deepseek { background: #4d6bfe; }
.run-mode-dot.shell { background: #94a3b8; } .run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width; /* Phone-only Enter button (see index.html). Hidden by default at every width;
@@ -14403,6 +14433,16 @@ html:not([data-skin="og"]) {
color: #fafafa; color: #fafafa;
} }
.btn-toolbar.btn-run.mode-grok:hover { box-shadow: 0 0 14px -2px rgba(161, 161, 170, 0.5); } .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 { .btn-toolbar.btn-run-gear {
background: var(--accent-d); background: var(--accent-d);
border-color: var(--accent); border-color: var(--accent);
+1 -1
View File
@@ -2214,7 +2214,7 @@ Object.assign(CodemanApp.prototype, {
} }
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir))); 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'); const badgeRow = document.createElement('div');
badgeRow.className = 'history-item-badges'; badgeRow.className = 'history-item-badges';
if (s.mode) { if (s.mode) {
+1 -1
View File
@@ -11,7 +11,7 @@ export interface ResponseViewerTranscriptBlock {
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here // 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 // 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. // 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 { function isPromptLine(line: string): boolean {
return /^\s*›\s*/.test(line); return /^\s*›\s*/.test(line);
+11 -2
View File
@@ -24,8 +24,17 @@ const APPROVAL_KIND_BY_EVENT: Record<string, ApprovalKind> = {
idle_prompt: 'idle', 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']);
export function registerHookEventRoutes( export function registerHookEventRoutes(
app: FastifyInstance, app: FastifyInstance,
+93 -6
View File
@@ -25,6 +25,7 @@ import {
type AntigravityConfig, type AntigravityConfig,
type PiConfig, type PiConfig,
type GrokConfig, type GrokConfig,
type DeepSeekConfig,
} from '../../types.js'; } from '../../types.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
@@ -337,6 +338,14 @@ export function _resetPasteRateBuckets(): void {
* Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove` * Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove`
* (`--always-approve`), and an ABSENT config already spawns in grok's own * (`--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. * 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( async function clampExternalCliBypassForOwner(
owner: string | undefined, owner: string | undefined,
@@ -344,16 +353,18 @@ async function clampExternalCliBypassForOwner(
geminiConfig: GeminiConfig | undefined, geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined, antigravityConfig: AntigravityConfig | undefined,
piConfig: PiConfig | undefined, piConfig: PiConfig | undefined,
grokConfig: GrokConfig | undefined grokConfig: GrokConfig | undefined,
deepSeekConfig: DeepSeekConfig | undefined
): Promise<{ ): Promise<{
codexConfig: CodexConfig | undefined; codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined; geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined; antigravityConfig: AntigravityConfig | undefined;
piConfig: PiConfig | undefined; piConfig: PiConfig | undefined;
grokConfig: GrokConfig | undefined; grokConfig: GrokConfig | undefined;
deepSeekConfig: DeepSeekConfig | undefined;
}> { }> {
const granted = await canUsernameRunPrivilegedCommands(owner); 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 // 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) // 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). // and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
@@ -364,18 +375,63 @@ async function clampExternalCliBypassForOwner(
: antigravityConfig; : antigravityConfig;
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false }; const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig; const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
const clampedDeepSeek = deepSeekConfig
? { ...deepSeekConfig, permissionMode: 'workspace-write' as const }
: deepSeekConfig;
return { return {
codexConfig: clampedCodex, codexConfig: clampedCodex,
geminiConfig: clampedGemini, geminiConfig: clampedGemini,
antigravityConfig: clampedAntigravity, antigravityConfig: clampedAntigravity,
piConfig: clampedPi, piConfig: clampedPi,
grokConfig: clampedGrok, grokConfig: clampedGrok,
deepSeekConfig: clampedDeepSeek,
}; };
} }
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */ /** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner; export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
/**
* 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> {
const { isDeepSeekAvailable, getDeepSeekNotFoundMessage, listDeepSeekProfiles, resolveDefaultDeepSeekProfile } =
await import('../../utils/deepseek-cli-resolver.js');
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()) {
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;
}
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input) // Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -770,6 +826,7 @@ export function registerSessionRoutes(
body.mode !== 'antigravity' && body.mode !== 'antigravity' &&
body.mode !== 'pi' && body.mode !== 'pi' &&
body.mode !== 'grok' && body.mode !== 'grok' &&
body.mode !== 'deepseek' &&
body.envOverrides && body.envOverrides &&
Object.keys(body.envOverrides).length > 0 && Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/')); (workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -859,6 +916,10 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage()); 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') { if (body.mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js'); const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) { if (!isGrokAvailable()) {
@@ -912,7 +973,10 @@ export function registerSessionRoutes(
? body.piConfig?.model ? body.piConfig?.model
: mode === 'grok' : mode === 'grok'
? body.grokConfig?.model ? 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 ? modelConfig?.defaultModel || undefined
: undefined; : undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig(); const claudeModeConfig = await ctx.getClaudeModeConfig();
@@ -925,13 +989,15 @@ export function registerSessionRoutes(
antigravityConfig: gatedAntigravityConfig, antigravityConfig: gatedAntigravityConfig,
piConfig: gatedPiConfig, piConfig: gatedPiConfig,
grokConfig: gatedGrokConfig, grokConfig: gatedGrokConfig,
deepSeekConfig: gatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner( } = await clampExternalCliBypassForOwner(
owner, owner,
body.codexConfig, body.codexConfig,
body.geminiConfig, body.geminiConfig,
body.antigravityConfig, body.antigravityConfig,
body.piConfig, body.piConfig,
body.grokConfig body.grokConfig,
body.deepSeekConfig
); );
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({ const session = new Session({
@@ -950,6 +1016,7 @@ export function registerSessionRoutes(
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined, antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined, piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
resumeSessionId: validatedResumeId, resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides, envOverrides: body.envOverrides,
effort: body.effort, effort: body.effort,
@@ -2717,6 +2784,7 @@ export function registerSessionRoutes(
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
deepSeekConfig,
envOverrides, envOverrides,
effort, effort,
parentSessionId, parentSessionId,
@@ -2766,6 +2834,7 @@ export function registerSessionRoutes(
antigravityConfig || antigravityConfig ||
piConfig || piConfig ||
grokConfig || grokConfig ||
deepSeekConfig ||
openCodeConfig openCodeConfig
) { ) {
return createErrorResponse( return createErrorResponse(
@@ -2909,6 +2978,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. // 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 // 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. // external project directories are honoured by quick-start just like regular case routes.
@@ -3036,6 +3111,7 @@ export function registerSessionRoutes(
mode !== 'antigravity' && mode !== 'antigravity' &&
mode !== 'pi' && mode !== 'pi' &&
mode !== 'grok' && mode !== 'grok' &&
mode !== 'deepseek' &&
!remote && !remote &&
envOverrides && envOverrides &&
Object.keys(envOverrides).length > 0 Object.keys(envOverrides).length > 0
@@ -3060,7 +3136,8 @@ export function registerSessionRoutes(
? piConfig?.model ? piConfig?.model
: mode === 'grok' : mode === 'grok'
? grokConfig?.model ? grokConfig?.model
: mode !== 'shell' : // DeepSeek's model lives in the profile's config tree, not here.
mode !== 'shell' && mode !== 'deepseek'
? qsModelConfig?.defaultModel || undefined ? qsModelConfig?.defaultModel || undefined
: undefined; : undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig(); const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
@@ -3072,7 +3149,16 @@ export function registerSessionRoutes(
antigravityConfig: qsGatedAntigravityConfig, antigravityConfig: qsGatedAntigravityConfig,
piConfig: qsGatedPiConfig, piConfig: qsGatedPiConfig,
grokConfig: qsGatedGrokConfig, 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 qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({ const session = new Session({
workingDir: resolvedCasePath, workingDir: resolvedCasePath,
@@ -3091,6 +3177,7 @@ export function registerSessionRoutes(
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined, antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
envOverrides, envOverrides,
effort, effort,
remote, remote,
+122 -1
View File
@@ -16,7 +16,7 @@ import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.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 { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
import { import {
ConfigUpdateSchema, ConfigUpdateSchema,
@@ -26,6 +26,7 @@ import {
SubagentWindowStatesSchema, SubagentWindowStatesSchema,
SubagentParentMapSchema, SubagentParentMapSchema,
RevokeSessionSchema, RevokeSessionSchema,
DeepSeekInstallProfileSchema,
} from '../schemas.js'; } from '../schemas.js';
import { subagentWatcher } from '../../subagent-watcher.js'; import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js'; import { imageWatcher } from '../../image-watcher.js';
@@ -48,6 +49,7 @@ import {
} from '../route-helpers.js'; } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js'; import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import { getRepositoryStatus } from '../repo-status.js'; import { getRepositoryStatus } from '../repo-status.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
@@ -55,6 +57,20 @@ import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js'; import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.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) // Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024; const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
// Screenshots directory // Screenshots directory
@@ -461,6 +477,111 @@ 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(),
};
});
// 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.
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 }>((resolve) => {
const child = spawn(join(dir, 'dsh'), ['plugin', '--profile', profile, 'add', pkg], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: DEEPSEEK_INSTALL_TIMEOUT_MS,
// 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,
});
let output = '';
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);
child.on('error', (err) => resolve({ code: null, output: `${output}\n${err.message}` }));
child.on('close', (code) => resolve({ code, output }));
});
if (result.code !== 0) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Installing ${pkg} into profile "${profile}" failed: ${result.output.slice(-1000).trim() || 'no output'}`
);
}
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) // State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
+83 -4
View File
@@ -132,6 +132,15 @@ const ALLOWED_ENV_PREFIXES = [
'PI_', 'PI_',
'GROK_', 'GROK_',
'XAI_', '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: 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,68 @@ const GrokConfigSchema = z
}) })
.optional(); .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();
/** /**
* The session that spawned the one being created — pure UI decoration, drawn as a * 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 * lineage line between the two tabs. Accepted here and, equivalently, as the
@@ -349,7 +420,7 @@ const parentSessionIdSchema = z.string().max(100).optional();
export const CreateSessionSchema = z.object({ export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(), 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(), name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */ /** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema, parentSessionId: parentSessionIdSchema,
@@ -366,6 +437,7 @@ export const CreateSessionSchema = z.object({
antigravityConfig: AntigravityConfigSchema, antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema, piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema, grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z resumeSessionId: z
.string() .string()
@@ -502,6 +574,7 @@ const RemoteCommandOverridesSchema = z
antigravity: z.string().min(1).max(300).optional(), antigravity: z.string().min(1).max(300).optional(),
pi: z.string().min(1).max(300).optional(), pi: z.string().min(1).max(300).optional(),
grok: z.string().min(1).max(300).optional(), grok: z.string().min(1).max(300).optional(),
deepseek: z.string().min(1).max(300).optional(),
}) })
.strict() .strict()
.optional(); .optional();
@@ -776,13 +849,14 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for * 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). */ * remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(), 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, openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema, codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema, geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema, antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema, piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema, grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema, effort: effortLevelSchema,
@@ -804,6 +878,11 @@ export const HookEventSchema = z.object({
'stop', 'stop',
'teammate_idle', 'teammate_idle',
'task_completed', '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), sessionId: z.string().min(1),
data: z.record(z.string(), z.unknown()).nullable().optional(), data: z.record(z.string(), z.unknown()).nullable().optional(),
@@ -1310,7 +1389,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */ /** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({ const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200), 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, workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(), launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']), promptMode: z.enum(['inline_text', 'prompt_file_path']),
+10
View File
@@ -1436,6 +1436,7 @@ export class WebServer extends EventEmitter {
{ isAntigravityAvailable }, { isAntigravityAvailable },
{ isPiAvailable }, { isPiAvailable },
{ isGrokAvailable }, { isGrokAvailable },
{ isDeepSeekRunnable, isDeepSeekAvailable },
{ isCloudflaredAvailable }, { isCloudflaredAvailable },
{ isGitAvailable }, { isGitAvailable },
] = await Promise.all([ ] = await Promise.all([
@@ -1446,6 +1447,7 @@ export class WebServer extends EventEmitter {
import('../utils/antigravity-cli-resolver.js'), import('../utils/antigravity-cli-resolver.js'),
import('../utils/pi-cli-resolver.js'), import('../utils/pi-cli-resolver.js'),
import('../utils/grok-cli-resolver.js'), import('../utils/grok-cli-resolver.js'),
import('../utils/deepseek-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'), import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'), import('../git-clone.js'),
]); ]);
@@ -1457,6 +1459,13 @@ export class WebServer extends EventEmitter {
antigravity: isAntigravityAvailable(), antigravity: isAntigravityAvailable(),
pi: isPiAvailable(), pi: isPiAvailable(),
grok: isGrokAvailable(), 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(), cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot // 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. // keep without git (issue #236), same reasoning as cloudflared above.
@@ -2738,6 +2747,7 @@ export class WebServer extends EventEmitter {
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined, antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined, piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined, grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined,
envOverrides: savedEnvOverrides, envOverrides: savedEnvOverrides,
effort: savedState?.effort, effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory, attachmentHistory: savedAttachmentHistory,
+7 -1
View File
@@ -182,7 +182,13 @@ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
* infinite-wait-dressed-as-a-timeout this guard exists to prevent. * infinite-wait-dressed-as-a-timeout this guard exists to prevent.
*/ */
export function hooksAvailableForMode(mode: SessionMode): boolean { export function hooksAvailableForMode(mode: SessionMode): boolean {
return mode === 'claude'; // `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`. Every other mode is output-stabilization
// guesswork and must keep failing the ask.
return mode === 'claude' || mode === 'deepseek';
} }
/** Outcome of resolving a caller-supplied wait target against a session's mode. */ /** Outcome of resolving a caller-supplied wait target against a session's mode. */
+11 -2
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`). * and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync. * Both files MUST be kept in sync.
* *
* 156 event constants organized by category: * 157 event constants organized by category:
* - **Core** (1): init * - **Core** (1): init
* - **Transport** (1): sse:heartbeat * - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ... * - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
@@ -25,7 +25,8 @@
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled * - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed * - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected * - **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) * - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error * - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
* - **Clipboard** (1): write * - **Clipboard** (1): write
@@ -360,6 +361,13 @@ export const HookElicitationComplete = 'hook:elicitation_complete' as const;
export const HookElicitationResponse = 'hook:elicitation_response' as const; export const HookElicitationResponse = 'hook:elicitation_response' as const;
/** Claude Code hook: response complete. */ /** Claude Code hook: response complete. */
export const HookStop = 'hook:stop' as const; 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. */ /** Claude Code hook: teammate went idle. */
export const HookTeammateIdle = 'hook:teammate_idle' as const; export const HookTeammateIdle = 'hook:teammate_idle' as const;
/** Claude Code hook: teammate task completed. */ /** Claude Code hook: teammate task completed. */
@@ -619,6 +627,7 @@ export const SseEvent = {
HookElicitationComplete, HookElicitationComplete,
HookElicitationResponse, HookElicitationResponse,
HookStop, HookStop,
HookAgentWorking,
HookTeammateIdle, HookTeammateIdle,
HookTaskCompleted, HookTaskCompleted,
+232
View File
@@ -0,0 +1,232 @@
/**
* @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('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']);
});
});
+240
View File
@@ -0,0 +1,240 @@
/**
* 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 } from '../src/web/session-wait-registry.js';
import { _clampExternalCliBypassForOwner } from '../src/web/routes/session-routes.js';
import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js';
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('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();
});
});
+11 -1
View File
@@ -424,7 +424,17 @@ describe('mobile overview run picker (CLI availability gating)', () => {
isCliAvailable: () => true, isCliAvailable: () => true,
}); });
const menu = app._buildMobileOverviewRunMenu(); 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', () => { 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 { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js'; import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
import { isGrokAvailable } from '../src/utils/grok-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 { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js'; import { isGitAvailable } from '../src/git-clone.js';
@@ -55,6 +56,16 @@ vi.mock('../src/utils/grok-cli-resolver.js', () => ({
resolveGrokDir: vi.fn(() => null), resolveGrokDir: vi.fn(() => null),
getGrokCliVersion: 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', () => ({ vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false), isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null), resolveCloudflaredPath: vi.fn(() => null),
@@ -160,6 +171,8 @@ describe('WebServer.renderIndexHtml', () => {
antigravity: false, antigravity: false,
pi: true, pi: true,
grok: false, grok: false,
deepseek: false,
deepseekBinary: false,
cloudflared: true, cloudflared: true,
git: true, git: true,
}); });
@@ -176,6 +189,8 @@ describe('WebServer.renderIndexHtml', () => {
isAntigravityAvailable, isAntigravityAvailable,
isPiAvailable, isPiAvailable,
isGrokAvailable, isGrokAvailable,
isDeepSeekAvailable,
isDeepSeekRunnable,
isCloudflaredAvailable, isCloudflaredAvailable,
isGitAvailable, isGitAvailable,
]) { ]) {