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