mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode
Adds `mode: 'deepseek'` alongside claude/shell/opencode/codex/gemini/ antigravity/pi/grok, plus a shortcut that opens the harness's own browser UI as a Codeman web tab. DeepSeek is wired unlike its siblings in three ways, each of which is the reason for a design decision rather than an accident: 1. The agent is a PROFILE, not the binary. `dsh` is a launcher over $DSH_HOME/profiles/<name>, and DeepSeek ships only `web`, `headless` and `base` -- the interactive terminal front door is always a third-party plugin. So availability is two questions: `isDeepSeekAvailable()` (binary) and `isDeepSeekRunnable()` (binary AND a pane-capable profile). The Run button gates on the latter, because reporting only the binary would spawn a pane that dies on arrival. When the binary is present but no profile is, the run menu offers to install one (POST /api/deepseek/install-profile). 2. The permission switch is an env var, not a flag. The harness has no command-line permission option; its sandbox/approval rows read DSH_PERMISSION_MODE (read-only / workspace-write / danger-full-access). Exported via `tmux setenv`, never on the spawn line. Absent = the harness's own workspace-write, which still asks, so the multi-user clamp is the only-if-sent branch and clamps to workspace-write, never read-only. 3. It is the only non-claude mode that passes hooksAvailableForMode(), and it earned that. The terminal front door reports idle/working/blocked to a supervising process over a generic env-gated contract; a generated shim (deepseek-status-shim.ts) makes Codeman that supervisor and forwards each report to /api/hook-event as stop / agent_working / permission_prompt. So a dsh session gets definitive respawn triggers, real wait-endpoint signals and real Approvals Inbox items instead of output-stabilization guesswork. `agent_working` is new (157th SSE constant) and joins APPROVAL_RESOLVING_EVENTS so a dialog answered in the terminal clears its alert at once. The resolver needs the strictest identity probe of the family: `dsh` is not merely a squattable npm name, Debian ships an unrelated `dsh` (dancer's shell), so `dsh --help` must print the harness's own banner before a candidate is handed a spawn line. Model is deliberately not a session field -- it is a composition entry in the profile's config tree. Env allowlist gains DSH_* and DEEPSEEK_* only; provider keys named by a settings-file `apiKeyEnv` stay out, which is pi's 34-provider-key problem in a new shape. Verified live against dsh 0.1.1-rc.2 and @deepseek-harness-tui/dsh-tui: the status endpoint's two-part answer, the no-profile refusal, the profile bootstrap, a real session whose pane runs `dsh --profile dsh-tui` with the permission mode injected via setenv, and the full status bridge -- a send-and-wait returned signal "stop" from a real turn, and blocked/working created and cleared an Approvals Inbox item. Docs: docs/deepseek-integration.md (guide), docs/deepseek-integration-plan.md (decisions + honest gaps). Tests: test/deepseek-mode.test.ts, test/deepseek-cli-resolver.test.ts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,18 @@ RUN curl -fsSL https://x.ai/cli/install.sh | bash \
|
|||||||
&& rm -rf /root/.grok /root/.local/bin/grok /root/.local/bin/agent \
|
&& rm -rf /root/.grok /root/.local/bin/grok /root/.local/bin/agent \
|
||||||
&& grok --version
|
&& grok --version
|
||||||
|
|
||||||
|
# DeepSeek Harness (`dsh`). A normal npm package, but the ONLY entry here whose
|
||||||
|
# binary runs nothing on its own: `dsh` is a profile launcher, and DeepSeek ships
|
||||||
|
# only `web` and `headless`, so without an interactive profile a
|
||||||
|
# `mode: 'deepseek'` container would start a pane that dies on arrival. The
|
||||||
|
# profile itself is installed further down, into the `agent` HOME, because
|
||||||
|
# Codeman deliberately does NOT seed `profiles/` from the host: it is a
|
||||||
|
# per-profile node_modules tree, host-arch-specific and far too large to copy on
|
||||||
|
# every container start.
|
||||||
|
RUN npm install -g @deepseek-ai/dsh \
|
||||||
|
&& npm cache clean --force \
|
||||||
|
&& dsh --version
|
||||||
|
|
||||||
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
|
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
|
||||||
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
|
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
|
||||||
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
|
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
|
||||||
@@ -88,9 +100,19 @@ ENV HOME=/home/agent
|
|||||||
# `.pi/agent` and `.grok` ARE pre-created: both are seeded per-FILE (pi:
|
# `.pi/agent` and `.grok` ARE pre-created: both are seeded per-FILE (pi:
|
||||||
# auth/settings/trust/models; grok: auth.json/config.toml/pager.toml), and a
|
# auth/settings/trust/models; grok: auth.json/config.toml/pager.toml), and a
|
||||||
# per-file seed copy, unlike a whole-dir one, does not create its parent directory.
|
# per-file seed copy, unlike a whole-dir one, does not create its parent directory.
|
||||||
|
# `.dsh` is pre-created for the same per-file reason (.env/settings.yaml/
|
||||||
|
# cordis.patch.yml), and the interactive profile is built into it HERE rather than
|
||||||
|
# after `USER agent`: this layer's closing chgrp/chmod is what makes the whole tree
|
||||||
|
# writable by the arbitrary uid the container actually runs as, and a profile
|
||||||
|
# installed after it would miss that fixup. DSH_HOME points the launcher at the
|
||||||
|
# agent's dir while this still runs as root.
|
||||||
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
|
||||||
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
|
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
|
||||||
/home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \
|
/home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \
|
||||||
|
/home/agent/.dsh \
|
||||||
|
&& DSH_HOME=/home/agent/.dsh HOME=/home/agent \
|
||||||
|
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui \
|
||||||
|
&& test -f /home/agent/.dsh/profiles/dsh-tui/package.json \
|
||||||
&& chgrp -R 0 /home/agent \
|
&& chgrp -R 0 /home/agent \
|
||||||
&& chmod -R g=u /home/agent
|
&& chmod -R g=u /home/agent
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,23 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
|||||||
|
|
||||||
## Session launch modes
|
## Session launch modes
|
||||||
|
|
||||||
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok)
|
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)
|
||||||
|
|
||||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All six modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All six modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
|
||||||
|
|
||||||
|
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
|
||||||
|
|
||||||
|
⚠️ **The agent is a PROFILE, not the binary.** `dsh` is a launcher over `$DSH_HOME/profiles/<name>` (an ordered stack of plugin-bundle patch layers), and DeepSeek ships only `web` (browser UI), `headless` (one-shot) and `base` (no app). The interactive terminal front door is ALWAYS third-party. So availability is TWO questions, not one, and `isDeepSeekRunnable()` (binary AND a pane-capable profile) is what the Run button gates on while `isDeepSeekAvailable()` (binary only) gates the "add a profile" affordance and the web-UI shortcut. Reporting only the binary would let Run spawn a pane that dies on arrival, which is this mode's single most confusing failure. `buildDeepSeekCommand()` emits `dsh --profile <name> [--resume [id]]`; an absent profile resolves through `resolveDefaultDeepSeekProfile()`, which prefers a recognized TUI, then an UNRECOGNIZED profile (third-party by construction — a classifier that has not heard of a bundle must not hide it), and refuses `web`/`headless`, which cannot drive a pane.
|
||||||
|
|
||||||
|
⚠️ **The permission switch is an ENV VAR, not a flag.** The harness has no `--dangerously-skip-permissions` equivalent; its sandbox/approval rows read `DSH_PERMISSION_MODE` with three presets (`read-only` / `workspace-write` / `danger-full-access`; measured from `dsh --dump-default-config`). It is exported via `tmux setenv` in `_configureDeepSeek()`, never on the command line, and `test/deepseek-mode.test.ts` pins that nothing permission-shaped ever reaches the spawn line. This is the ONE place a Codeman env export is the right mechanism rather than the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks in-session `/effort`), the harness reads it with `??` as a boot-time DEFAULT, so it stays soft. Absent = `workspace-write`, which still asks, so the multi-user clamp is the only-if-sent branch (codex/antigravity/grok shape, not pi's materialize) — and it clamps down to `workspace-write`, NOT `read-only`, because the clamp removes privilege without breaking a session's ability to edit its own workspace.
|
||||||
|
|
||||||
|
⚠️ **It is the only non-claude mode that passes `hooksAvailableForMode()`, and it earned that.** The community terminal front door reports its own lifecycle to a supervising process through a generic env-var-gated contract inherited from Herdr: with `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` set it shells out `<bin> pane report-agent <paneId> --state idle|working|blocked …` on every state change and treats exit 0 as delivered. `deepseek-status-shim.ts` GENERATES a small script into the data dir (like `self-update-runner.sh`, so npm installs and git clones behave alike) and points `HERDR_BIN_PATH` at it; it forwards to `POST /api/hook-event` as `idle→stop`, `blocked→permission_prompt`, `working→agent_working`. So a dsh session gets real respawn triggers, real `wait` stop/blocked signals and real Approvals Inbox items instead of output-stabilization guesswork. This is an interface implementation, not an impersonation — no real `herdr` binary is ever executed. A TUI that does not implement the contract simply never calls the shim and falls back to stabilization, so the feature is inert rather than harmful there.
|
||||||
|
|
||||||
|
⚠️ **`agent_working` is a hook event with no Claude Code hook behind it** (157th SSE constant). It exists because a harness turn cannot run while one of its own modal approvals is on screen, so "the agent started working" proves a dialog was answered in the terminal. It joins `APPROVAL_RESOLVING_EVENTS`; without it a dsh session's red alert would survive until the next `stop`, the exact stuck-alert bug the claude path already had to fix once — and the pane-capture staleness sweep that fixed it there is Claude-dialog-shaped and cannot help here.
|
||||||
|
|
||||||
|
⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity.
|
||||||
|
|
||||||
|
Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`.
|
||||||
|
|
||||||
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
|
**Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# DeepSeek Harness (`dsh`) integration plan
|
||||||
|
|
||||||
|
> **Status**: Executed. This document records the plan, the decision behind each
|
||||||
|
> wiring point, and what was and was not verified. The user-facing guide is
|
||||||
|
> [`deepseek-integration.md`](./deepseek-integration.md); the per-decision
|
||||||
|
> invariants live in
|
||||||
|
> [`architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek`](./architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek).
|
||||||
|
> Template: the grok integration ([`grok-integration-plan.md`](./grok-integration-plan.md)),
|
||||||
|
> itself calibrated against pi. Every fact below was measured against a live
|
||||||
|
> **dsh 0.1.1-rc.2** install and **@deepseek-harness-tui/dsh-tui 0.9.0**, not read
|
||||||
|
> from documentation.
|
||||||
|
|
||||||
|
## 1. What the DeepSeek Harness is
|
||||||
|
|
||||||
|
[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
|
||||||
|
(open-sourced 2026-08-13, MIT) is a plugin-native agent framework: tools, skills,
|
||||||
|
sessions, sandboxes and whole APPS are Cordis plugins composed into *profiles*.
|
||||||
|
`dsh` is the launcher — `dsh --profile <name>` boots
|
||||||
|
`$DSH_HOME/profiles/<name>`, an ordered stack of plugin-bundle patch layers under
|
||||||
|
the user's own overrides. State lives in `~/.dsh` (`.env` 0600, `settings.yaml`,
|
||||||
|
`cordis.patch.yml`, `profiles/`, `sessions/`, `storages/`).
|
||||||
|
|
||||||
|
## 2. Shape decisions (why DeepSeek is wired the way it is)
|
||||||
|
|
||||||
|
DeepSeek is a ninth run mode. Never a location overlay, never a web tab (the
|
||||||
|
browser UI is handled separately, §3). Three of its decisions have no precedent
|
||||||
|
in the six external CLIs before it.
|
||||||
|
|
||||||
|
| Question | Decision | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| What does a pane run? | `dsh --profile <name>`, profile discovered | **The decision that shapes everything else.** DeepSeek ships `web`, `headless` and `base` — no terminal agent. The interactive front door is always a third-party plugin, so Codeman resolves a binary AND a profile inventory, and "available" means both. `resolveDefaultDeepSeekProfile()` prefers a recognized TUI, then an UNRECOGNIZED profile (anyone can publish an app bundle; a classifier that has not heard of one must not hide it), and refuses `web`/`headless`, which cannot occupy a pane. |
|
||||||
|
| Which TUI? | none blessed; default for BOOTSTRAP only | `POST /api/deepseek/install-profile` defaults to `@deepseek-harness-tui/dsh-tui` (~27.5k weekly downloads, ~4x the next, MIT, and it speaks the status contract in §2.3), but accepts any npm name and the resolver never assumes that profile exists. Codeman offers a default; it does not pick a winner. |
|
||||||
|
| Permission bypass | `DSH_PERMISSION_MODE` env export, no flag | The harness has NO command-line permission option; its sandbox/approval rows read one env var with three presets (`read-only` / `workspace-write` / `danger-full-access`, read off `dsh --dump-default-config`). This is the one legitimate exception to the `CLAUDE_CODE_EFFORT_LEVEL` ban: that var hard-locks in-session switching, whereas the harness reads this with `??` as a boot-time DEFAULT, so it stays soft. Exported via `tmux setenv`, never on the command line. The Run button sends `danger-full-access`, matching every sibling Run button. |
|
||||||
|
| Multi-user clamp branch | only-if-sent, clamped to `workspace-write` | Omitting the export leaves the harness on `workspace-write`, which still ASKS, so an absent config is already safe (the codex/antigravity/grok shape, not pi's materialize). Clamping to `workspace-write` rather than `read-only` is deliberate: the clamp removes privilege, it must not break a session's ability to edit its own workspace. |
|
||||||
|
| Idle detection | **real hook events via a status shim** | The standout decision. The TUI already reports its lifecycle to a supervising process through a generic env-gated contract inherited from Herdr: `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` make it run `<bin> pane report-agent <id> --state idle\|working\|blocked …` on every state change, exit 0 = delivered. `deepseek-status-shim.ts` generates a script into the data dir and points `HERDR_BIN_PATH` at it. So deepseek is the only non-claude mode that passes `hooksAvailableForMode()` — earned by emitting definitive signals, not granted. An interface implementation, not an impersonation: no real `herdr` binary is ever executed, and a TUI that ignores the contract simply falls back to output stabilization. |
|
||||||
|
| `agent_working` event | new, 157th SSE constant | The one hook event with no Claude Code hook behind it. A harness turn cannot run while its own modal approval is on screen, so "started working" proves a dialog was answered in the terminal. Without it a dsh red alert would survive until the next `stop` — the exact stuck-alert bug the claude path already fixed once, and its pane-capture staleness sweep is Claude-dialog-shaped and cannot help here. |
|
||||||
|
| Resolver | identity probe THEN version probe | Strictest of the family, and not by preference. `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) which would answer a version probe convincingly and then be handed a spawn line. `dsh --help` must match `DeepSeek Harness` first. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release. |
|
||||||
|
| Env allowlist | `DSH_*` + `DEEPSEEK_*` | `DSH_*` covers the launcher's documented inputs (`DSH_HOME`, `DSH_PERMISSION_MODE`, `DSH_TELEMETRY_MODE`, the `DSH_TUI_*` knobs); `DEEPSEEK_*` is the vendor namespace holding `DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`, same reasoning that admitted `XAI_*` for grok. ⚠️ Pi's lesson repeats exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), and the allowlist is one GLOBAL list, so admitting those would widen every mode at once. They stay out. |
|
||||||
|
| Model | NOT a session field | The model is a composition entry (`agent-default-model`) in the profile's config tree, set in `~/.dsh/settings.yaml` + `cordis.patch.yml`. Both create paths deliberately resolve no model for this mode rather than inventing a flag. |
|
||||||
|
| Alt-screen strip | OUT of `isAltScreenStripMode()` | Third-party fullscreen TUIs with their own scrollback and mouse handling — the opencode case, not the Ink case. |
|
||||||
|
| Local echo | `'buffer'` via the `_updateLocalEchoState` fallthrough | UNMEASURED against a live authenticated session (see §5), same honest gap grok shipped with. The leading TUI's composer supports `@` completion and history search, which *may* make it per-keystroke reactive like codex; if so the fallback is the `'off'` branch. |
|
||||||
|
| Docker | image installs dsh AND a profile | Profiles are deliberately NOT seeded from the host: each is a per-profile `node_modules` tree, host-arch-specific and far too large to copy per container start. Only `~/.dsh/.env`, `settings.yaml`, `cordis.patch.yml` are seeded (auth + model composition). The profile install rides the `useradd` layer so the closing `chgrp`/`chmod g=u` covers it, which is what keeps it usable under the arbitrary uid the container runs as. |
|
||||||
|
| Remote SSH | `exec "$SHELL" -i -l -c 'dsh'` | Boots the remote box's default profile; a remote with several needs the per-host `commands.deepseek` override, since `deepSeekConfig` does not cross ssh. |
|
||||||
|
|
||||||
|
## 3. The web profile
|
||||||
|
|
||||||
|
The browser UI is the only interactive surface DeepSeek ships itself, so it gets
|
||||||
|
a **shortcut, not a run mode**: `Run ▸ DeepSeek web UI…` starts
|
||||||
|
`dsh web --no-open --host 127.0.0.1 --port 3080 --trusted-host <codeman-authority>`
|
||||||
|
in an ordinary shell session and opens the URL as an ordinary web tab.
|
||||||
|
|
||||||
|
Built entirely from parts that already exist: the server is a shell session
|
||||||
|
(visible, scrollable, killable, dies with its tab) and the UI is a web tab.
|
||||||
|
Nothing new supervises a long-lived HTTP server, because Codeman already does.
|
||||||
|
`--trusted-host` is load-bearing — dsh fences its `/api` behind a browser-trust
|
||||||
|
check on the request authority, and a Codeman web tab reaches it through
|
||||||
|
Codeman's own origin via the webview proxy, not directly.
|
||||||
|
|
||||||
|
## 4. Touch points (the checklist)
|
||||||
|
|
||||||
|
Backend: `types/session.ts` (SessionMode + `DeepSeekConfig` + SessionState),
|
||||||
|
`utils/deepseek-cli-resolver.ts` (new) + barrel, `deepseek-status-shim.ts` (new),
|
||||||
|
`tmux-manager.ts` (`buildDeepSeekCommand`, dispatch, resume flag, PATH export,
|
||||||
|
truecolor, `_configureDeepSeek`, availability error, plumbing), `session.ts`
|
||||||
|
(external-mode gate, label, config plumbing, tmux-required error, attach env),
|
||||||
|
`mux-interface.ts`, `schemas.ts` (prefixes, `DeepSeekConfigSchema`,
|
||||||
|
`DeepSeekInstallProfileSchema`, both mode enums, remote overrides, cron agentType,
|
||||||
|
`agent_working`), `session-wait-registry.ts` (`hooksAvailableForMode`),
|
||||||
|
`hook-event-routes.ts` (`APPROVAL_RESOLVING_EVENTS`), `session-routes.ts` (clamp +
|
||||||
|
both create paths + `resolveDeepSeekLaunchError`), `system-routes.ts`
|
||||||
|
(`GET /api/deepseek/status`, `POST /api/deepseek/install-profile`), `server.ts`
|
||||||
|
(availability inject + mux restore), `sse-events.ts`, `docker-hosts.ts`,
|
||||||
|
`remote-hosts.ts`, `config/dependency-registry.ts`,
|
||||||
|
`response-viewer-transcript.ts`, `cron/cron-service.ts` (comment),
|
||||||
|
`tui/tui-client.ts` + `tui-app.ts`.
|
||||||
|
|
||||||
|
Frontend: `index.html` (welcome button, run-mode entry, install affordance, web-UI
|
||||||
|
shortcut, cron option, clone Brain option), `session-ui.js` (`runDeepSeek()`,
|
||||||
|
`runDeepSeekWeb()`, `installDeepSeekProfile()`, dispatch, availability, "Run DS"
|
||||||
|
label, external-CLI gates), `app.js` (label, `ds` tab badge, kill-menu, SSE map),
|
||||||
|
`settings-ui.js` (welcome gate + `_onHookAgentWorking`), `constants.js`,
|
||||||
|
`mobile-overview.js`, `home-sessions.js`, `panels-ui.js`, `i18n.js`,
|
||||||
|
`terminal-ui.js`, `styles.css` + `mobile.css` (brand-indigo identity; the non-og
|
||||||
|
skin block and the mobile `!important` pair are both load-bearing).
|
||||||
|
|
||||||
|
Meta: `docker/agent.Dockerfile`, `install.sh`, `package.json` keyword,
|
||||||
|
`skills/codeman/reference/*`, CLAUDE.md, `architecture-invariants.md`.
|
||||||
|
|
||||||
|
Tests: `test/deepseek-mode.test.ts` + `test/deepseek-cli-resolver.test.ts` (new);
|
||||||
|
`run-mode-ui`, `render-index-html`, `mobile-overview`, `agent-skill-mode-lists`
|
||||||
|
(extended).
|
||||||
|
|
||||||
|
## 5. Verification performed
|
||||||
|
|
||||||
|
See the summary at the end of the implementing session for the live run. In
|
||||||
|
short: the CI gate green; the resolver, profile inventory, spawn-line and clamp
|
||||||
|
behaviour covered by 31 new unit tests; and an isolated instance used to exercise
|
||||||
|
`GET /api/deepseek/status` and a real session against the live dsh install.
|
||||||
|
|
||||||
|
**Not verified (honest gaps):**
|
||||||
|
|
||||||
|
- The local-echo `'buffer'` policy against the TUI's real composer (§2). If it
|
||||||
|
turns out per-keystroke reactive like codex's, flip it to the `'off'` branch;
|
||||||
|
teaching `PredictiveEchoAddon` its composer row is the larger follow-up.
|
||||||
|
- Scrollback/repaint behaviour of a third-party fullscreen TUI under the narrow
|
||||||
|
strip during a long session.
|
||||||
|
- A Docker case with `mode: 'deepseek'` (needs a `--no-cache` agent-image
|
||||||
|
rebuild — see the `--no-cache` rule in CLAUDE.md).
|
||||||
|
- A remote-SSH deepseek case.
|
||||||
|
- The web-UI shortcut end to end through the webview proxy, in particular whether
|
||||||
|
`--trusted-host <codeman-authority>` is the right authority for dsh's `/api`
|
||||||
|
fence in every deployment shape (loopback, tailscale, tunnel).
|
||||||
|
|
||||||
|
## 6. Follow-ups
|
||||||
|
|
||||||
|
- **Response viewer**: read `~/.dsh/sessions/**` (JSONL) the way codex rollouts
|
||||||
|
are read back. Highest-value follow-up, and very achievable.
|
||||||
|
- **`headless` as an execution backend** for Codeman's own AI checks
|
||||||
|
(`ai-idle-checker`, `ai-plan-checker`), today Claude-only.
|
||||||
|
- **Profile/model picker in Session Options**, reading `GET /api/deepseek/status`
|
||||||
|
`.profiles`.
|
||||||
|
- **`--patch` overlays per session**, which is the harness-native way to change
|
||||||
|
agent composition without touching the user's profile.
|
||||||
|
- Measure the local-echo policy and pin the result the way pi did.
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# DeepSeek Harness (`dsh`) in Codeman
|
||||||
|
|
||||||
|
Codeman can run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
|
||||||
|
as a session backend, alongside Claude Code, OpenCode, Codex, Gemini,
|
||||||
|
Antigravity, Pi and Grok. It is the ninth run mode, and the one that is wired
|
||||||
|
least like the others, for two reasons worth understanding before you use it.
|
||||||
|
|
||||||
|
## 1. The agent is a profile, not the binary
|
||||||
|
|
||||||
|
`dsh` is a **launcher**, not an agent. It boots a *profile*: an ordered stack of
|
||||||
|
plugin-bundle patch layers under `$DSH_HOME/profiles/<name>` (`$DSH_HOME`
|
||||||
|
defaults to `~/.dsh`). DeepSeek ships three bundles and none of them is a
|
||||||
|
terminal agent:
|
||||||
|
|
||||||
|
| Profile | What it is | Can Codeman run it in a tab? |
|
||||||
|
| ------------ | --------------------------------- | ---------------------------- |
|
||||||
|
| `web` | the browser UI, served on :3080 | no — but see §5 |
|
||||||
|
| `headless` | answers one task and exits | no |
|
||||||
|
| (`base`) | the shared core, no app at all | no |
|
||||||
|
|
||||||
|
The interactive terminal front door is **always a third-party plugin**. So
|
||||||
|
"DeepSeek is installed" and "Codeman can start a DeepSeek session" are different
|
||||||
|
questions, and Codeman answers both separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s localhost:3000/api/deepseek/status | jq
|
||||||
|
{
|
||||||
|
"available": true, # the `dsh` binary resolved and proved its identity
|
||||||
|
"runnable": false, # ...but nothing installed can drive a pane
|
||||||
|
"path": "/home/you/.local/bin",
|
||||||
|
"version": "0.1.1-rc.2",
|
||||||
|
"dshHome": "/home/you/.dsh",
|
||||||
|
"defaultProfile": null,
|
||||||
|
"profiles": [ { "name": "web", "kind": "web", "bundles": [...] } ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installing a terminal profile
|
||||||
|
|
||||||
|
From the UI: open the **Run** dropdown. When `dsh` is installed but no
|
||||||
|
pane-capable profile is, the menu shows **DeepSeek — add a terminal profile…**.
|
||||||
|
One click installs one and the normal DeepSeek entry appears.
|
||||||
|
|
||||||
|
By hand, or to pick a different front door:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
|
||||||
|
margin the most used community TUI, it is MIT, and it implements the status
|
||||||
|
contract described in §3. It is a **default, not a requirement**: any profile
|
||||||
|
under `$DSH_HOME/profiles` that is not `web` or `headless` shows up in the
|
||||||
|
inventory and can be launched, including one you compose yourself. The endpoint
|
||||||
|
accepts any npm package name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sX POST localhost:3000/api/deepseek/install-profile \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"profile":"my-tui","package":"@someone/dsh-tui"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Installing a plugin is arbitrary code execution on the host, so in multi-user
|
||||||
|
mode this endpoint requires the can-bypass-permissions grant (the same bar as a
|
||||||
|
`shell` session).
|
||||||
|
|
||||||
|
> **`dsh` is also a Debian program.** `apt install dsh` gives you "dancer's
|
||||||
|
> shell", a distributed shell, which would answer `--version` convincingly.
|
||||||
|
> Codeman's resolver therefore demands the harness's own help banner before it
|
||||||
|
> will point a spawn line at a candidate, and `GET /api/deepseek/status` reports
|
||||||
|
> `path` and `version` so a misresolution is diagnosable rather than presenting
|
||||||
|
> as "the mode just doesn't work".
|
||||||
|
|
||||||
|
## 2. Permissions are an env var, not a flag
|
||||||
|
|
||||||
|
The harness has **no `--dangerously-skip-permissions` equivalent**. Its sandbox
|
||||||
|
and approval rows are configuration, driven by one documented input,
|
||||||
|
`DSH_PERMISSION_MODE`, with three presets (read off `dsh --dump-default-config`):
|
||||||
|
|
||||||
|
| `DSH_PERMISSION_MODE` | sandbox | approvals | notes |
|
||||||
|
| --------------------- | -------------------- | --------- | ------------------------- |
|
||||||
|
| `read-only` | `read-only` | ask | |
|
||||||
|
| `workspace-write` | `workspace-write` | ask | the harness's own default |
|
||||||
|
| `danger-full-access` | `danger-full-access` | **never** | what the Run button sends |
|
||||||
|
|
||||||
|
Codeman exports it via `tmux setenv`, never on the command line. Because the
|
||||||
|
harness reads it with `??`, it is a **soft default**: it sets the boot-time
|
||||||
|
preset and you can still change permission mode inside the session.
|
||||||
|
|
||||||
|
Omitting it entirely leaves the harness on `workspace-write`, which still asks —
|
||||||
|
which is why the multi-user clamp only needs to force a *sent* value down. A
|
||||||
|
non-granted owner's `danger-full-access` becomes `workspace-write`, not
|
||||||
|
`read-only`: the clamp removes privilege without breaking the session's ability
|
||||||
|
to edit its own workspace.
|
||||||
|
|
||||||
|
## 3. Real idle detection (the interesting part)
|
||||||
|
|
||||||
|
Every other external CLI mode in Codeman is **readiness-guessed**: Codeman
|
||||||
|
watches the PTY go quiet and infers that a turn ended. Claude is the exception,
|
||||||
|
because Claude Code fires hooks.
|
||||||
|
|
||||||
|
DeepSeek is the second exception. The community terminal front door already
|
||||||
|
reports its own lifecycle to a supervising process through a generic,
|
||||||
|
env-var-gated contract (inherited from [Herdr](https://herdr.dev)): when
|
||||||
|
`HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set, it shells out on
|
||||||
|
every state change with
|
||||||
|
|
||||||
|
```
|
||||||
|
"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
|
||||||
|
--source custom:dsh-tui --agent dsh-tui \
|
||||||
|
--state idle|working|blocked [--message ...] --seq N
|
||||||
|
```
|
||||||
|
|
||||||
|
Codeman points `HERDR_BIN_PATH` at a small generated shim
|
||||||
|
(`~/.codeman/dsh-status-shim.mjs`, written at session create) which forwards each
|
||||||
|
report to `POST /api/hook-event`. The mapping:
|
||||||
|
|
||||||
|
| Harness state | Codeman hook event | What you get |
|
||||||
|
| ------------- | ------------------ | -------------------------------------------------------- |
|
||||||
|
| `blocked` | `permission_prompt`| red "needs you" tab alert + an Approvals Inbox item |
|
||||||
|
| `idle` | `stop` | definitive end-of-turn: respawn triggers, `wait` returns |
|
||||||
|
| `working` | `agent_working` | clears an alert answered in the terminal, at once |
|
||||||
|
|
||||||
|
So a DeepSeek session gets Claude-grade signals: `GET /api/sessions/:id/wait`
|
||||||
|
really can block on `stop` and `blocked` for it, and it is the only non-Claude
|
||||||
|
mode for which that is true (`hooksAvailableForMode`).
|
||||||
|
|
||||||
|
This is an interface implementation, not an impersonation — nothing on your
|
||||||
|
machine executes a real `herdr` binary. If you use a terminal profile that does
|
||||||
|
*not* implement the contract, the shim is simply never called and the mode falls
|
||||||
|
back to output-stabilization readiness like its siblings. Turn it off per session
|
||||||
|
with `deepSeekConfig.statusReporting: false`.
|
||||||
|
|
||||||
|
## 4. Starting a session
|
||||||
|
|
||||||
|
From the UI, pick **DeepSeek** in the Run dropdown (or the **Run DeepSeek**
|
||||||
|
welcome button) and press Run. Over the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sX POST localhost:3000/api/quick-start \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"caseName": "myproject",
|
||||||
|
"mode": "deepseek",
|
||||||
|
"deepSeekConfig": {
|
||||||
|
"profile": "dsh-tui",
|
||||||
|
"permissionMode": "danger-full-access"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`deepSeekConfig` fields: `profile`, `permissionMode`, `resumeSession`,
|
||||||
|
`resumeSessionId`, `statusReporting`. Resume prefers an explicit id over the
|
||||||
|
most-recent form, and both are passed through to the profile's app, which is
|
||||||
|
where `--resume` is understood.
|
||||||
|
|
||||||
|
**Models are not a session field.** The model is a composition entry in the
|
||||||
|
profile's config tree (`agent-default-model`), not a CLI flag, so Codeman does
|
||||||
|
not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
|
||||||
|
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
|
||||||
|
profile. That is also how you point dsh at a local or third-party provider.
|
||||||
|
|
||||||
|
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
|
||||||
|
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
|
||||||
|
all flow through). Provider keys with *other* names are deliberately not: a dsh
|
||||||
|
`settings.yaml` can nominate any env var as a credential via `apiKeyEnv`, and
|
||||||
|
Codeman's allowlist is global, so admitting them would widen it for every mode at
|
||||||
|
once. Authenticate those the way dsh does, from the file or the server's own
|
||||||
|
environment.
|
||||||
|
|
||||||
|
## 5. The web UI as a tab
|
||||||
|
|
||||||
|
The browser UI is the one interactive surface DeepSeek ships itself, so it gets a
|
||||||
|
shortcut rather than a run mode: **Run ▸ DeepSeek web UI…** starts
|
||||||
|
`dsh web --no-open --host 127.0.0.1 --port 3080 --trusted-host <codeman-host>` in
|
||||||
|
an ordinary shell session and opens `http://127.0.0.1:3080` as a Codeman web tab.
|
||||||
|
|
||||||
|
Nothing bespoke supervises it: the server is a normal shell session (visible,
|
||||||
|
scrollable, killable, dies with its tab) and the UI is a normal web tab. The
|
||||||
|
`--trusted-host` flag is load-bearing — dsh fences its `/api` behind a
|
||||||
|
browser-trust check on the request authority, and a Codeman web tab reaches it
|
||||||
|
through Codeman's own origin via the webview proxy, not directly. Without it the
|
||||||
|
page renders and every API call fails.
|
||||||
|
|
||||||
|
## 6. Docker and remote cases
|
||||||
|
|
||||||
|
Docker cases work: the agent image installs `dsh` and bootstraps a `dsh-tui`
|
||||||
|
profile into the container. Profiles are deliberately **not** seeded from the
|
||||||
|
host (each is a per-profile `node_modules` tree, host-arch-specific and far too
|
||||||
|
large to copy on every container start); only `~/.dsh/.env`, `settings.yaml` and
|
||||||
|
`cordis.patch.yml` are seeded, which is what carries auth and model composition
|
||||||
|
in. As with pi and grok, in-container sessions are invisible host-side:
|
||||||
|
`~/.dsh/sessions` inside a container is that container's own.
|
||||||
|
|
||||||
|
Remote SSH cases default to `dsh` through a login shell, which boots the remote
|
||||||
|
box's default profile. If the remote has several, name one with the per-host
|
||||||
|
`commands.deepseek` override — the local `deepSeekConfig` does not cross ssh.
|
||||||
|
|
||||||
|
## 7. What is not wired
|
||||||
|
|
||||||
|
Deliberately minimal, on the same reasoning as the grok integration: the harness
|
||||||
|
is a fast-moving developer preview and every flag added is a flag validated
|
||||||
|
forever.
|
||||||
|
|
||||||
|
- `--patch` overlays per session (the profile's own layers apply as normal).
|
||||||
|
- `dsh plugin` management beyond first-time profile install.
|
||||||
|
- The `headless` profile as a one-shot execution backend for Codeman's own
|
||||||
|
internal AI checks (today those are Claude-only).
|
||||||
|
- Reading `~/.dsh/sessions/**` into the response viewer, the way codex rollouts
|
||||||
|
are read back. DeepSeek sessions are JSONL and this is very achievable; it is
|
||||||
|
the highest-value follow-up.
|
||||||
|
- Model/provider selection from Session Options.
|
||||||
|
|
||||||
|
## Verified against
|
||||||
|
|
||||||
|
`dsh 0.1.1-rc.2` and `@deepseek-harness-tui/dsh-tui 0.9.0`. The permission
|
||||||
|
presets, the profile layout, and the supervisor contract above were all read off
|
||||||
|
the live install rather than from documentation.
|
||||||
+56
-3
@@ -125,6 +125,14 @@ PI_SEARCH_PATHS=(
|
|||||||
"$HOME/bin/pi"
|
"$HOME/bin/pi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# DeepSeek Harness search paths (from src/utils/deepseek-cli-resolver.ts)
|
||||||
|
DSH_SEARCH_PATHS=(
|
||||||
|
"$HOME/.local/bin/dsh"
|
||||||
|
"/usr/local/bin/dsh"
|
||||||
|
"$HOME/.npm-global/bin/dsh"
|
||||||
|
"$HOME/bin/dsh"
|
||||||
|
)
|
||||||
|
|
||||||
# Grok CLI search paths (from src/utils/grok-cli-resolver.ts)
|
# Grok CLI search paths (from src/utils/grok-cli-resolver.ts)
|
||||||
GROK_SEARCH_PATHS=(
|
GROK_SEARCH_PATHS=(
|
||||||
"$HOME/.grok/bin/grok"
|
"$HOME/.grok/bin/grok"
|
||||||
@@ -594,6 +602,46 @@ check_grok() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# `dsh` is the hardest name of the lot: Debian ships an unrelated `dsh`
|
||||||
|
# (dancer's shell). The server-side resolver settles it by demanding the
|
||||||
|
# harness's own help banner; detection here only feeds the "you have no AI CLI"
|
||||||
|
# hint, so the same cheap banner grep is enough and costs one exec.
|
||||||
|
check_dsh() {
|
||||||
|
local candidate
|
||||||
|
if command -v dsh &>/dev/null; then
|
||||||
|
candidate="$(command -v dsh)"
|
||||||
|
if "$candidate" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
for path in "${DSH_SEARCH_PATHS[@]}"; do
|
||||||
|
if [[ -x "$path" ]] && "$path" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get_dsh_path() {
|
||||||
|
local candidate
|
||||||
|
if command -v dsh &>/dev/null; then
|
||||||
|
candidate="$(command -v dsh)"
|
||||||
|
if "$candidate" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then
|
||||||
|
echo "$candidate"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
for path in "${DSH_SEARCH_PATHS[@]}"; do
|
||||||
|
if [[ -x "$path" ]] && "$path" --help 2>/dev/null | grep -qi "DeepSeek Harness"; then
|
||||||
|
echo "$path"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
get_grok_path() {
|
get_grok_path() {
|
||||||
if command -v grok &>/dev/null; then
|
if command -v grok &>/dev/null; then
|
||||||
command -v grok
|
command -v grok
|
||||||
@@ -2123,6 +2171,7 @@ main() {
|
|||||||
local has_antigravity=false
|
local has_antigravity=false
|
||||||
local has_pi=false
|
local has_pi=false
|
||||||
local has_grok=false
|
local has_grok=false
|
||||||
|
local has_dsh=false
|
||||||
|
|
||||||
info "Checking AI CLI tools..."
|
info "Checking AI CLI tools..."
|
||||||
if check_claude; then
|
if check_claude; then
|
||||||
@@ -2153,10 +2202,14 @@ main() {
|
|||||||
has_grok=true
|
has_grok=true
|
||||||
success "Grok CLI found at $(get_grok_path)"
|
success "Grok CLI found at $(get_grok_path)"
|
||||||
fi
|
fi
|
||||||
|
if check_dsh; then
|
||||||
|
has_dsh=true
|
||||||
|
success "DeepSeek Harness found at $(get_dsh_path)"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" ]]; then
|
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok."
|
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or DeepSeek Harness."
|
||||||
headless_guard "install an AI CLI (curl | bash from its vendor)"
|
headless_guard "install an AI CLI (curl | bash from its vendor)"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
|
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
|
||||||
@@ -2512,7 +2565,7 @@ main() {
|
|||||||
echo -e " https://github.com/Ark0N/Codeman"
|
echo -e " https://github.com/Ark0N/Codeman"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok; then
|
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh; then
|
||||||
echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:"
|
echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:"
|
||||||
echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code"
|
echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code"
|
||||||
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
|
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"antigravity",
|
"antigravity",
|
||||||
"pi",
|
"pi",
|
||||||
"grok",
|
"grok",
|
||||||
|
"deepseek",
|
||||||
"gemini-cli",
|
"gemini-cli",
|
||||||
"ai-agents",
|
"ai-agents",
|
||||||
"agent",
|
"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
|
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
||||||
returns is too early (verified live: empty on the first call, full prose seconds later).
|
returns is too early (verified live: empty on the first call, full prose seconds later).
|
||||||
It is also `""` before the worker's first completed turn, and permanently `""` for
|
It is also `""` before the worker's first completed turn, and permanently `""` for
|
||||||
`shell`, `opencode`, `gemini`, `antigravity`, `pi` and `grok`, which write no Claude transcript.
|
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `deepseek`, which write no Claude transcript.
|
||||||
|
|
||||||
**Fix** Poll it, bounded (10 tries, 1 s apart). If it is still empty on a hook-less mode,
|
**Fix** Poll it, bounded (10 tries, 1 s apart). If it is still empty on a hook-less mode,
|
||||||
that is expected, not a failure: read `terminal?tail=` and strip ANSI instead.
|
that is expected, not a failure: read `terminal?tail=` and strip ANSI instead.
|
||||||
@@ -336,14 +336,14 @@ ESC=$(printf '\033')
|
|||||||
|
|
||||||
`POST /api/v1/quick-start` body (all optional):
|
`POST /api/v1/quick-start` body (all optional):
|
||||||
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
|
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
|
||||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok`; response is
|
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek`; response is
|
||||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||||
|
|
||||||
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
|
||||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||||
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`
|
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`
|
||||||
and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed).
|
and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed).
|
||||||
Pi's and grok's also carry `.data.version`, because `pi` is a short generic name and
|
Pi's and grok's also carry `.data.version`, because `pi` is a short generic name and
|
||||||
`grok` is a name with npm squatters, so an unrelated binary on `$PATH` can shadow either:
|
`grok` is a name with npm squatters, so an unrelated binary on `$PATH` can shadow either:
|
||||||
@@ -464,7 +464,7 @@ Quirks that will bite you:
|
|||||||
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
||||||
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
||||||
returns early for every external CLI mode (`session.ts:2261`), so it is permanently
|
returns early for every external CLI mode (`session.ts:2261`), so it is permanently
|
||||||
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`. ⚠️ **`shell` is NOT one of those**
|
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`. ⚠️ **`shell` is NOT one of those**
|
||||||
(`isExternalCliMode`, `session.ts:174-183`, lists only those six), so the parser does
|
(`isExternalCliMode`, `session.ts:174-183`, lists only those six), so the parser does
|
||||||
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
|
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
|
||||||
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
|
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
|
|||||||
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
||||||
| liveness / death check | HTTP `wait?until=exit` |
|
| liveness / death check | HTTP `wait?until=exit` |
|
||||||
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
||||||
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`) | HTTP only (no other CLI has messaging) |
|
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`) | HTTP only (no other CLI has messaging) |
|
||||||
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
||||||
|
|
||||||
## Availability: probe, never assume
|
## Availability: probe, never assume
|
||||||
@@ -347,7 +347,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
|
|||||||
|
|
||||||
### Mixed fleets: the pairing matrix
|
### Mixed fleets: the pairing matrix
|
||||||
|
|
||||||
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`) cannot be peers
|
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`) cannot be peers
|
||||||
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
||||||
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
||||||
subject to the namespace rule: **messaging works between two sessions that share one
|
subject to the namespace rule: **messaging works between two sessions that share one
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||||
# always "" for shell/opencode/gemini/antigravity/pi/grok, which have no transcript, use
|
# always "" for shell/opencode/gemini/antigravity/pi/grok/deepseek, which have no transcript, use
|
||||||
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
||||||
|
|
||||||
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ recovered by submitting it with `{"input":"\r"}`.
|
|||||||
|
|
||||||
⚠️ `stop` and `blocked` fire for `claude` sessions only (they are Claude Code hooks,
|
⚠️ `stop` and `blocked` fire for `claude` sessions only (they are Claude Code hooks,
|
||||||
and only when the workspace actually has them, see [§5.1](#51-where-to-spawn)). On
|
and only when the workspace actually has them, see [§5.1](#51-where-to-spawn)). On
|
||||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`, requesting them explicitly is a
|
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`, requesting them explicitly is a
|
||||||
400, and lifecycle transitions there are coarse (a short shell command may emit **no**
|
400, and lifecycle transitions there are coarse (a short shell command may emit **no**
|
||||||
`idle` transition at all, verified live), so synchronize those with markers.
|
`idle` transition at all, verified live), so synchronize those with markers.
|
||||||
|
|
||||||
@@ -369,7 +369,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
|
|||||||
single read taken the instant send-and-wait returns comes back `""` even though the
|
single read taken the instant send-and-wait returns comes back `""` even though the
|
||||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`; the first four
|
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`; the first four
|
||||||
verified live, pi from the same source path), which is
|
verified live, pi from the same source path), which is
|
||||||
why the loop above is bounded rather than open-ended. Fall back to the terminal buffer
|
why the loop above is bounded rather than open-ended. Fall back to the terminal buffer
|
||||||
there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive
|
there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive
|
||||||
@@ -454,7 +454,7 @@ turn), and both better than diffing terminal samples:
|
|||||||
```
|
```
|
||||||
|
|
||||||
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
||||||
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`** (those parsers are skipped wholesale) and
|
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`** (those parsers are skipped wholesale) and
|
||||||
in practice empty for `shell`. Source-verified, not measured live.
|
in practice empty for `shell`. Source-verified, not measured live.
|
||||||
|
|
||||||
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
|
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
|
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
|
||||||
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
|
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
|
||||||
|
import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js';
|
||||||
|
|
||||||
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
|
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
|
||||||
|
|
||||||
@@ -163,6 +164,32 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'dsh',
|
||||||
|
label: 'DeepSeek Harness CLI',
|
||||||
|
category: 'core',
|
||||||
|
required: false,
|
||||||
|
usedBy: ['DeepSeek sessions'],
|
||||||
|
// Version match required, and for a sharper reason than pi or grok: `dsh` is
|
||||||
|
// not merely a squattable npm name, it is an existing Debian program
|
||||||
|
// (dancer's shell, `apt install dsh`). The run mode's resolver additionally
|
||||||
|
// demands the harness's own help banner before it will point a spawn line at
|
||||||
|
// a candidate; the doctor is advisory and settles for the shared
|
||||||
|
// DEEPSEEK_VERSION_REGEX, so the two cannot disagree about the VERSION even
|
||||||
|
// though the resolver is the stricter of the pair about IDENTITY.
|
||||||
|
resolvers: [
|
||||||
|
{
|
||||||
|
match: ALL,
|
||||||
|
resolver: {
|
||||||
|
kind: 'path',
|
||||||
|
bins: ['dsh'],
|
||||||
|
versionArg: '--version',
|
||||||
|
versionRegex: DEEPSEEK_VERSION_REGEX,
|
||||||
|
requireVersionMatch: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'libreoffice',
|
id: 'libreoffice',
|
||||||
label: 'LibreOffice',
|
label: 'LibreOffice',
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
|
|||||||
* answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript,
|
* answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript,
|
||||||
* so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve`
|
* so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve`
|
||||||
* is NOT a clamp.
|
* is NOT a clamp.
|
||||||
* Codex, antigravity and grok need nothing here: their absent config already spawns safe
|
* Codex, antigravity, grok and deepseek need nothing here: their absent config already spawns safe
|
||||||
* (grok's bare spawn is its own ask-mode default; --always-approve is only ever sent).
|
* (grok's bare spawn is its own ask-mode default and deepseek's omits DSH_PERMISSION_MODE
|
||||||
|
* entirely, leaving the harness on workspace-write, which asks; both switches are only ever sent).
|
||||||
* Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched.
|
* Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched.
|
||||||
*/
|
*/
|
||||||
export function clampCronExternalCliConfigs(
|
export function clampCronExternalCliConfigs(
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The DeepSeek Harness -> Codeman status bridge.
|
||||||
|
*
|
||||||
|
* ## Why this exists
|
||||||
|
*
|
||||||
|
* Every external CLI mode before this one (opencode, codex, gemini, antigravity,
|
||||||
|
* pi, grok) is READINESS-GUESSED: Codeman watches the PTY go quiet and infers a
|
||||||
|
* turn ended. Claude is the exception, because Claude Code fires real hooks. The
|
||||||
|
* DeepSeek Harness TUI gives us a third option, and a much better one than
|
||||||
|
* guessing: the community terminal front door already reports its own lifecycle
|
||||||
|
* to an owning supervisor, and it does so through a fully GENERIC, env-var-gated
|
||||||
|
* contract it inherited from Herdr (herdr.dev).
|
||||||
|
*
|
||||||
|
* When all three of `HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set,
|
||||||
|
* the TUI shells out on every state change:
|
||||||
|
*
|
||||||
|
* "$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
|
||||||
|
* --source custom:dsh-tui --agent dsh-tui \
|
||||||
|
* --state idle|working|blocked [--message <text>] --seq <n>
|
||||||
|
*
|
||||||
|
* and treats exit code 0 as "delivered" (retrying with backoff otherwise). So
|
||||||
|
* Codeman points `HERDR_BIN_PATH` at the script below and gets DEFINITIVE
|
||||||
|
* idle/working/blocked signals for dsh sessions: real respawn triggers, real
|
||||||
|
* `wait`/`wait-output` stop+blocked signals, and real Approvals Inbox items,
|
||||||
|
* on par with Claude's hooks rather than with output stabilization.
|
||||||
|
*
|
||||||
|
* This is an interface implementation, not an impersonation: we implement the
|
||||||
|
* one verb (`pane report-agent`) that the contract defines, and nothing on the
|
||||||
|
* machine ever executes a real `herdr` binary — `HERDR_BIN_PATH` is our own
|
||||||
|
* script, in our own data dir. `HERDR_ENV=1` is the flag the TUI checks to know
|
||||||
|
* a supervisor is present; a supervisor IS present, it is Codeman.
|
||||||
|
*
|
||||||
|
* ## Why it is generated rather than committed
|
||||||
|
*
|
||||||
|
* The shim must be an executable file at a stable absolute path in every
|
||||||
|
* install shape: a git clone (where `scripts/` exists), an `npm i -g aicodeman`
|
||||||
|
* (where `files` ships only `dist` plus two named scripts), and any
|
||||||
|
* `CODEMAN_INSTANCE`. Writing it into the data dir at session-create time makes
|
||||||
|
* one code path cover all of them, single-sources the content here in TS, and
|
||||||
|
* follows the precedent of `self-update-runner.sh`. It is rewritten whenever the
|
||||||
|
* embedded version marker changes, so an upgraded Codeman refreshes a stale shim
|
||||||
|
* without the user knowing it exists.
|
||||||
|
*
|
||||||
|
* @module deepseek-status-shim
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { dataPath } from './config/instance.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped whenever SHIM_SOURCE changes. The marker is embedded in the generated
|
||||||
|
* file, so `ensureDeepSeekStatusShim()` can tell a current shim from one written
|
||||||
|
* by an older Codeman and rewrite only when needed (rather than rewriting on
|
||||||
|
* every session create, or — worse — leaving a stale one in place forever).
|
||||||
|
*/
|
||||||
|
const SHIM_VERSION = 1;
|
||||||
|
const SHIM_MARKER = `codeman-dsh-status-shim v${SHIM_VERSION}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping from the harness's three lifecycle states to Codeman hook events.
|
||||||
|
*
|
||||||
|
* - `blocked` -> `permission_prompt`: the TUI reports blocked when a tool
|
||||||
|
* approval or an `ask_user_question` questionnaire is on screen, which is
|
||||||
|
* exactly the red "needs you" alert and an answerable Approvals Inbox item.
|
||||||
|
* - `idle` -> `stop`: the definitive end-of-turn signal, the one respawn and the
|
||||||
|
* wait endpoints care about.
|
||||||
|
* - `working` -> `agent_working`: a turn STARTED. Codeman infers "working" from
|
||||||
|
* PTY output well enough on its own, but the event is what RESOLVES a pending
|
||||||
|
* approval when the user answers a dialog in the terminal instead of in the
|
||||||
|
* inbox. Without it a dsh session's red alert would survive until the next
|
||||||
|
* `stop`, which is the exact stuck-alert bug the claude path already had to
|
||||||
|
* fix once (and the pane-capture staleness sweep that fixed it there is
|
||||||
|
* Claude-dialog-shaped, so it cannot help here).
|
||||||
|
*/
|
||||||
|
export const DEEPSEEK_STATE_TO_HOOK_EVENT: Readonly<Record<string, string>> = Object.freeze({
|
||||||
|
idle: 'stop',
|
||||||
|
blocked: 'permission_prompt',
|
||||||
|
working: 'agent_working',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generated script.
|
||||||
|
*
|
||||||
|
* Constraints it must satisfy, each learned from an existing Codeman hook bug:
|
||||||
|
* - **TLS**: `CODEMAN_API_URL` is loopback HTTPS with a self-signed cert on
|
||||||
|
* `--https`/tailscale installs, so certificate verification is disabled for
|
||||||
|
* the request. Without this the whole bridge dies silently, exactly as the
|
||||||
|
* claude hook curls did before they grew `-k`.
|
||||||
|
* - **Secret**: the hook-secret file is read AT EXECUTION TIME, never baked in,
|
||||||
|
* so rotation needs no respawn and the value never lands on a command line.
|
||||||
|
* - **Exit codes**: 0 means delivered. Anything else makes the TUI retry with
|
||||||
|
* backoff, so transport failures self-heal, but an unknown verb or an
|
||||||
|
* unmapped state exits 0 to avoid a pointless retry storm over something that
|
||||||
|
* will never succeed.
|
||||||
|
* - **Timeout**: bounded below the caller's own 2s budget, so we lose the race
|
||||||
|
* deliberately rather than being killed mid-flight.
|
||||||
|
*/
|
||||||
|
const SHIM_SOURCE = `#!/usr/bin/env node
|
||||||
|
// ${SHIM_MARKER}
|
||||||
|
// GENERATED BY CODEMAN — do not edit. Rewritten from src/deepseek-status-shim.ts
|
||||||
|
// whenever its version marker changes.
|
||||||
|
//
|
||||||
|
// Implements the one verb the DeepSeek Harness TUI's supervisor contract uses:
|
||||||
|
// pane report-agent <paneId> --state <idle|working|blocked> [--message <t>] ...
|
||||||
|
// and forwards it to this Codeman instance as a hook event.
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import http from 'node:http'
|
||||||
|
import https from 'node:https'
|
||||||
|
|
||||||
|
const STATE_TO_EVENT = ${JSON.stringify(DEEPSEEK_STATE_TO_HOOK_EVENT)}
|
||||||
|
const TIMEOUT_MS = 1500
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const flag = (name) => {
|
||||||
|
const i = argv.indexOf(name)
|
||||||
|
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown verb: succeed silently. Retrying could never make it succeed, and a
|
||||||
|
// non-zero exit here would make the caller retry four times per state change.
|
||||||
|
if (argv[0] !== 'pane' || argv[1] !== 'report-agent') process.exit(0)
|
||||||
|
|
||||||
|
const event = STATE_TO_EVENT[String(flag('--state') ?? '')]
|
||||||
|
if (!event) process.exit(0)
|
||||||
|
|
||||||
|
// The pane id we hand the TUI IS the Codeman session id, but prefer the ambient
|
||||||
|
// env: it is set by the same code that set HERDR_PANE_ID and cannot be spoofed
|
||||||
|
// by an argument the agent itself could influence.
|
||||||
|
const sessionId = process.env.CODEMAN_SESSION_ID || argv[2]
|
||||||
|
const apiUrl = process.env.CODEMAN_API_URL
|
||||||
|
if (!sessionId || !apiUrl) process.exit(1)
|
||||||
|
|
||||||
|
let secret = ''
|
||||||
|
try {
|
||||||
|
secret = readFileSync(process.env.CODEMAN_HOOK_SECRET_FILE || '', 'utf-8').trim()
|
||||||
|
} catch {
|
||||||
|
// Missing file: the loopback bypass still applies when no tunnel is running.
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
event,
|
||||||
|
sessionId,
|
||||||
|
data: {
|
||||||
|
source: 'dsh-status-shim',
|
||||||
|
agent: flag('--agent') || 'dsh',
|
||||||
|
...(flag('--message') ? { message: flag('--message') } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let url
|
||||||
|
try {
|
||||||
|
url = new URL('/api/hook-event', apiUrl)
|
||||||
|
} catch {
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = url.protocol === 'https:' ? https : http
|
||||||
|
const req = transport.request(
|
||||||
|
{
|
||||||
|
protocol: url.protocol,
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port,
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
'X-Codeman-Hook-Secret': secret,
|
||||||
|
},
|
||||||
|
// Loopback HTTPS with a self-signed cert (--https / tailscale installs).
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
res.resume()
|
||||||
|
process.exit(res.statusCode && res.statusCode >= 200 && res.statusCode < 300 ? 0 : 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
req.on('error', () => process.exit(1))
|
||||||
|
req.end(body)
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Absolute path of the generated shim for this instance. */
|
||||||
|
export function deepSeekStatusShimPath(): string {
|
||||||
|
return dataPath('dsh-status-shim.mjs');
|
||||||
|
}
|
||||||
|
|
||||||
|
let ensuredThisProcess = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the shim if it is missing or stale, and return its path.
|
||||||
|
*
|
||||||
|
* Idempotent and cheap: after the first call in a process it does nothing, and
|
||||||
|
* even the first call only rewrites when the on-disk marker differs. Never
|
||||||
|
* throws — a data dir that cannot be written is a degraded status bridge, not a
|
||||||
|
* failed session start, so callers fall back to output-stabilization readiness
|
||||||
|
* by receiving null.
|
||||||
|
*/
|
||||||
|
export function ensureDeepSeekStatusShim(): string | null {
|
||||||
|
const path = deepSeekStatusShimPath();
|
||||||
|
if (ensuredThisProcess) return path;
|
||||||
|
try {
|
||||||
|
let current = '';
|
||||||
|
try {
|
||||||
|
current = readFileSync(path, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// Missing — fall through to the write.
|
||||||
|
}
|
||||||
|
if (!current.includes(SHIM_MARKER)) {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, SHIM_SOURCE, { mode: 0o700 });
|
||||||
|
}
|
||||||
|
// Re-assert the mode even when the content matched: a shim that lost its
|
||||||
|
// executable bit (a restored backup, a copied data dir) would make every
|
||||||
|
// report fail, and the TUI would retry four times per state change forever.
|
||||||
|
chmodSync(path, 0o700);
|
||||||
|
ensuredThisProcess = true;
|
||||||
|
return path;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[DeepSeek] Could not install the status shim at ${path}: ${(err as Error).message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: forget the per-process memo so a fresh temp HOME is re-provisioned. */
|
||||||
|
export function resetDeepSeekStatusShimForTest(): void {
|
||||||
|
ensuredThisProcess = false;
|
||||||
|
}
|
||||||
@@ -146,6 +146,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
|
|||||||
antigravity: 'exec agy',
|
antigravity: 'exec agy',
|
||||||
pi: 'exec pi',
|
pi: 'exec pi',
|
||||||
grok: 'exec grok',
|
grok: 'exec grok',
|
||||||
|
deepseek: 'exec dsh',
|
||||||
};
|
};
|
||||||
return commands[mode as DockerCommandMode] || commands.shell;
|
return commands[mode as DockerCommandMode] || commands.shell;
|
||||||
}
|
}
|
||||||
@@ -625,6 +626,17 @@ const CRED_STORES: CredStorePolicy[] = [
|
|||||||
rel: '.grok',
|
rel: '.grok',
|
||||||
seedFiles: ['auth.json', 'config.toml', 'pager.toml'],
|
seedFiles: ['auth.json', 'config.toml', 'pager.toml'],
|
||||||
},
|
},
|
||||||
|
// DeepSeek Harness keeps credentials in `~/.dsh/.env` (0600) and composition in
|
||||||
|
// `settings.yaml` / `cordis.patch.yml`. `profiles/` is deliberately NOT seeded:
|
||||||
|
// it is a pnpm workspace holding a full node_modules tree per profile, which is
|
||||||
|
// both enormous and host-arch-specific. An in-container dsh therefore needs its
|
||||||
|
// profile installed IN the image (see docker/agent.Dockerfile), and the seeded
|
||||||
|
// files only supply auth and model composition. Same host-invisibility trade-off
|
||||||
|
// as pi and grok: `~/.dsh/sessions` inside a container is that container's own.
|
||||||
|
{
|
||||||
|
rel: '.dsh',
|
||||||
|
seedFiles: ['.env', 'settings.yaml', 'cordis.patch.yml'],
|
||||||
|
},
|
||||||
{ rel: '.config/gcloud', seedWhole: true },
|
{ rel: '.config/gcloud', seedWhole: true },
|
||||||
{ rel: '.config/opencode', seedWhole: true },
|
{ rel: '.config/opencode', seedWhole: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
AntigravityConfig,
|
AntigravityConfig,
|
||||||
PiConfig,
|
PiConfig,
|
||||||
GrokConfig,
|
GrokConfig,
|
||||||
|
DeepSeekConfig,
|
||||||
SessionRemote,
|
SessionRemote,
|
||||||
SessionDocker,
|
SessionDocker,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
@@ -80,6 +81,7 @@ export interface CreateSessionOptions {
|
|||||||
antigravityConfig?: AntigravityConfig;
|
antigravityConfig?: AntigravityConfig;
|
||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||||
@@ -113,6 +115,7 @@ export interface RespawnPaneOptions {
|
|||||||
antigravityConfig?: AntigravityConfig;
|
antigravityConfig?: AntigravityConfig;
|
||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
/** Resume a previous Claude conversation when respawning */
|
/** Resume a previous Claude conversation when respawning */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
|||||||
antigravity: remoteLoginShellCommand('agy'),
|
antigravity: remoteLoginShellCommand('agy'),
|
||||||
pi: remoteLoginShellCommand('pi'),
|
pi: remoteLoginShellCommand('pi'),
|
||||||
grok: remoteLoginShellCommand('grok'),
|
grok: remoteLoginShellCommand('grok'),
|
||||||
|
// `dsh` alone boots nothing: the launcher needs a profile, and the remote box's
|
||||||
|
// profile inventory is unknown here. The per-host `commands.deepseek` override
|
||||||
|
// is the escape hatch for naming one.
|
||||||
|
deepseek: remoteLoginShellCommand('dsh'),
|
||||||
};
|
};
|
||||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-2
@@ -52,6 +52,7 @@ import {
|
|||||||
type AntigravityConfig,
|
type AntigravityConfig,
|
||||||
type PiConfig,
|
type PiConfig,
|
||||||
type GrokConfig,
|
type GrokConfig,
|
||||||
|
type DeepSeekConfig,
|
||||||
type SessionRemote,
|
type SessionRemote,
|
||||||
type SessionDocker,
|
type SessionDocker,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
@@ -178,7 +179,8 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
|||||||
mode === 'gemini' ||
|
mode === 'gemini' ||
|
||||||
mode === 'antigravity' ||
|
mode === 'antigravity' ||
|
||||||
mode === 'pi' ||
|
mode === 'pi' ||
|
||||||
mode === 'grok'
|
mode === 'grok' ||
|
||||||
|
mode === 'deepseek'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +198,8 @@ function getModeLabel(mode: SessionMode): string {
|
|||||||
return 'Pi';
|
return 'Pi';
|
||||||
case 'grok':
|
case 'grok':
|
||||||
return 'Grok';
|
return 'Grok';
|
||||||
|
case 'deepseek':
|
||||||
|
return 'DeepSeek';
|
||||||
case 'shell':
|
case 'shell':
|
||||||
return 'Shell';
|
return 'Shell';
|
||||||
case 'claude':
|
case 'claude':
|
||||||
@@ -521,6 +525,9 @@ export class Session extends EventEmitter {
|
|||||||
private _piConfig: PiConfig | undefined;
|
private _piConfig: PiConfig | undefined;
|
||||||
// Grok configuration (only for mode === 'grok')
|
// Grok configuration (only for mode === 'grok')
|
||||||
private _grokConfig: GrokConfig | undefined;
|
private _grokConfig: GrokConfig | undefined;
|
||||||
|
|
||||||
|
// DeepSeek Harness configuration (only for mode === 'deepseek')
|
||||||
|
private _deepSeekConfig: DeepSeekConfig | undefined;
|
||||||
private _resumeSessionId: string | undefined;
|
private _resumeSessionId: string | undefined;
|
||||||
|
|
||||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||||
@@ -618,6 +625,8 @@ export class Session extends EventEmitter {
|
|||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
/** Grok configuration (only for mode === 'grok') */
|
/** Grok configuration (only for mode === 'grok') */
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||||
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
/** Resume a previous Claude conversation (used after server reboot) */
|
/** Resume a previous Claude conversation (used after server reboot) */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||||
@@ -727,6 +736,11 @@ export class Session extends EventEmitter {
|
|||||||
this._piConfig = config.piConfig;
|
this._piConfig = config.piConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply DeepSeek Harness configuration
|
||||||
|
if (config.deepSeekConfig) {
|
||||||
|
this._deepSeekConfig = config.deepSeekConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply Grok configuration
|
// Apply Grok configuration
|
||||||
if (config.grokConfig) {
|
if (config.grokConfig) {
|
||||||
this._grokConfig = config.grokConfig;
|
this._grokConfig = config.grokConfig;
|
||||||
@@ -1325,6 +1339,7 @@ export class Session extends EventEmitter {
|
|||||||
antigravityConfig: this._antigravityConfig,
|
antigravityConfig: this._antigravityConfig,
|
||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||||
@@ -1501,7 +1516,8 @@ export class Session extends EventEmitter {
|
|||||||
this.mode === 'gemini' ||
|
this.mode === 'gemini' ||
|
||||||
this.mode === 'antigravity' ||
|
this.mode === 'antigravity' ||
|
||||||
this.mode === 'pi' ||
|
this.mode === 'pi' ||
|
||||||
this.mode === 'grok'
|
this.mode === 'grok' ||
|
||||||
|
this.mode === 'deepseek'
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1572,6 +1588,7 @@ export class Session extends EventEmitter {
|
|||||||
antigravityConfig: this._antigravityConfig,
|
antigravityConfig: this._antigravityConfig,
|
||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
@@ -1831,6 +1848,7 @@ export class Session extends EventEmitter {
|
|||||||
antigravityConfig: this._antigravityConfig,
|
antigravityConfig: this._antigravityConfig,
|
||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
@@ -1924,6 +1942,12 @@ export class Session extends EventEmitter {
|
|||||||
if (this.mode === 'grok') {
|
if (this.mode === 'grok') {
|
||||||
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.');
|
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.');
|
||||||
}
|
}
|
||||||
|
// DeepSeek sessions require tmux for DEEPSEEK_API_KEY / DSH_PERMISSION_MODE
|
||||||
|
// injection via setenv — and for the HERDR_* status-bridge triple, without
|
||||||
|
// which the mode silently loses its definitive idle/blocked signals.
|
||||||
|
if (this.mode === 'deepseek') {
|
||||||
|
throw new Error('DeepSeek Harness sessions require tmux. Direct PTY fallback is not supported.');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Pass --session-id to use the SAME ID as the Codeman session
|
// Pass --session-id to use the SAME ID as the Codeman session
|
||||||
// This ensures subagents can be directly matched to the correct tab
|
// This ensures subagents can be directly matched to the correct tab
|
||||||
|
|||||||
+146
-2
@@ -53,6 +53,7 @@ import {
|
|||||||
type AntigravityConfig,
|
type AntigravityConfig,
|
||||||
type PiConfig,
|
type PiConfig,
|
||||||
type GrokConfig,
|
type GrokConfig,
|
||||||
|
type DeepSeekConfig,
|
||||||
type SessionRemote,
|
type SessionRemote,
|
||||||
type SessionDocker,
|
type SessionDocker,
|
||||||
type DockerCommandMode,
|
type DockerCommandMode,
|
||||||
@@ -95,6 +96,9 @@ import {
|
|||||||
getPiNotFoundMessage,
|
getPiNotFoundMessage,
|
||||||
resolveGrokDir,
|
resolveGrokDir,
|
||||||
getGrokNotFoundMessage,
|
getGrokNotFoundMessage,
|
||||||
|
resolveDeepSeekDir,
|
||||||
|
getDeepSeekNotFoundMessage,
|
||||||
|
resolveDefaultDeepSeekProfile,
|
||||||
resolveLocalShell,
|
resolveLocalShell,
|
||||||
loginShellArgs,
|
loginShellArgs,
|
||||||
} from './utils/index.js';
|
} from './utils/index.js';
|
||||||
@@ -119,6 +123,7 @@ import {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||||
|
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
|
||||||
|
|
||||||
/** How long a cached process snapshot stays usable. */
|
/** How long a cached process snapshot stays usable. */
|
||||||
const PROC_SNAPSHOT_TTL_MS = 2000;
|
const PROC_SNAPSHOT_TTL_MS = 2000;
|
||||||
@@ -846,6 +851,51 @@ function buildGrokCommand(config?: GrokConfig): string {
|
|||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the DeepSeek Harness (`dsh`) command with appropriate flags.
|
||||||
|
*
|
||||||
|
* Unlike every sibling builder, the interesting decision here is not a flag but
|
||||||
|
* WHICH PROFILE to boot: `dsh` is a launcher over `$DSH_HOME/profiles/<name>`,
|
||||||
|
* and DeepSeek ships no interactive terminal profile of its own, so the agent a
|
||||||
|
* pane runs is always one the user installed. An absent `profile` resolves to
|
||||||
|
* the first pane-capable profile on the box; when there is none we still emit a
|
||||||
|
* bare `dsh --profile <default>` rather than inventing a name, because the
|
||||||
|
* availability gate in createSession() has already refused the spawn by then and
|
||||||
|
* this path only runs for a session that passed it.
|
||||||
|
*
|
||||||
|
* There is deliberately NO permission flag: the harness has none. The sandbox
|
||||||
|
* and approval rows read `DSH_PERMISSION_MODE`, exported through `tmux setenv`
|
||||||
|
* in buildEnvExports() so it never lands on this command line.
|
||||||
|
*
|
||||||
|
* Like the sibling builders, every user value is regex-allowlisted and silently
|
||||||
|
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
|
||||||
|
*/
|
||||||
|
function buildDeepSeekCommand(config?: DeepSeekConfig): string {
|
||||||
|
const parts = ['dsh'];
|
||||||
|
|
||||||
|
// A profile name is a single path segment: it is both interpolated into the
|
||||||
|
// shell line and joined into a filesystem path.
|
||||||
|
const requested = config?.profile;
|
||||||
|
const safeProfile =
|
||||||
|
requested && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(requested)
|
||||||
|
? requested
|
||||||
|
: (resolveDefaultDeepSeekProfile() ?? undefined);
|
||||||
|
if (safeProfile) parts.push('--profile', safeProfile);
|
||||||
|
|
||||||
|
// The launcher forwards everything after its own flags to the profile's app,
|
||||||
|
// which is where `--resume` is understood. An explicit id wins over the
|
||||||
|
// most-recent-session form, mirroring the sibling builders.
|
||||||
|
const safeSessionId =
|
||||||
|
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
|
||||||
|
if (safeSessionId) {
|
||||||
|
parts.push('--resume', safeSessionId);
|
||||||
|
} else if (config?.resumeSession) {
|
||||||
|
parts.push('--resume');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the spawn command for any session mode.
|
* Build the spawn command for any session mode.
|
||||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||||
@@ -890,6 +940,7 @@ export function buildSpawnCommand(options: {
|
|||||||
antigravityConfig?: AntigravityConfig;
|
antigravityConfig?: AntigravityConfig;
|
||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
|
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
|
||||||
@@ -942,6 +993,9 @@ export function buildSpawnCommand(options: {
|
|||||||
if (options.mode === 'grok') {
|
if (options.mode === 'grok') {
|
||||||
return buildGrokCommand(options.grokConfig);
|
return buildGrokCommand(options.grokConfig);
|
||||||
}
|
}
|
||||||
|
if (options.mode === 'deepseek') {
|
||||||
|
return buildDeepSeekCommand(options.deepSeekConfig);
|
||||||
|
}
|
||||||
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
|
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
|
||||||
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
|
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
|
||||||
// so a `$SHELL` here is expanded by the SERVER process's shell against the
|
// so a `$SHELL` here is expanded by the SERVER process's shell against the
|
||||||
@@ -1159,6 +1213,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
|
|||||||
return `${modeCommand} --session ${resumeId}`;
|
return `${modeCommand} --session ${resumeId}`;
|
||||||
case 'grok':
|
case 'grok':
|
||||||
return `${modeCommand} --resume ${resumeId}`;
|
return `${modeCommand} --resume ${resumeId}`;
|
||||||
|
case 'deepseek':
|
||||||
|
return `${modeCommand} --resume ${resumeId}`;
|
||||||
default:
|
default:
|
||||||
return modeCommand; // shell / opencode: no resume
|
return modeCommand; // shell / opencode: no resume
|
||||||
}
|
}
|
||||||
@@ -1749,10 +1805,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const exports = [
|
const exports = [
|
||||||
'export LANG=en_US.UTF-8',
|
'export LANG=en_US.UTF-8',
|
||||||
'export LC_ALL=en_US.UTF-8',
|
'export LC_ALL=en_US.UTF-8',
|
||||||
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
|
mode === 'codex' ||
|
||||||
|
mode === 'gemini' ||
|
||||||
|
mode === 'antigravity' ||
|
||||||
|
mode === 'pi' ||
|
||||||
|
mode === 'grok' ||
|
||||||
|
mode === 'deepseek'
|
||||||
? 'export COLORTERM=truecolor'
|
? 'export COLORTERM=truecolor'
|
||||||
: 'unset COLORTERM',
|
: 'unset COLORTERM',
|
||||||
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
|
...(mode === 'codex' ||
|
||||||
|
mode === 'gemini' ||
|
||||||
|
mode === 'antigravity' ||
|
||||||
|
mode === 'pi' ||
|
||||||
|
mode === 'grok' ||
|
||||||
|
mode === 'deepseek'
|
||||||
? ['unset NO_COLOR']
|
? ['unset NO_COLOR']
|
||||||
: []),
|
: []),
|
||||||
// Stamp each Codex pane with a unique originator so the response-viewer
|
// Stamp each Codex pane with a unique originator so the response-viewer
|
||||||
@@ -1853,6 +1919,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
const dir = resolveGrokDir();
|
const dir = resolveGrokDir();
|
||||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||||
}
|
}
|
||||||
|
if (mode === 'deepseek') {
|
||||||
|
const dir = resolveDeepSeekDir();
|
||||||
|
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||||
|
}
|
||||||
return { pathExport: '', dir: null };
|
return { pathExport: '', dir: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1883,6 +1953,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
setGeminiEnvVars(this.tmux(), muxName);
|
setGeminiEnvVars(this.tmux(), muxName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure DeepSeek Harness environment on a tmux session.
|
||||||
|
*
|
||||||
|
* Two independent things, both via `tmux setenv` so they are inherited by the
|
||||||
|
* pane without appearing in `ps`:
|
||||||
|
*
|
||||||
|
* 1. `DSH_PERMISSION_MODE` — the harness's only permission input. Exported
|
||||||
|
* ONLY when the caller sent one, so an absent config lands on the harness's
|
||||||
|
* own `workspace-write` default (which asks) rather than on ours. That
|
||||||
|
* "only if sent" shape is what the multi-user clamp relies on.
|
||||||
|
* 2. The `HERDR_*` triple — the supervisor contract the terminal front door
|
||||||
|
* uses to report idle/working/blocked. Pointing `HERDR_BIN_PATH` at our own
|
||||||
|
* generated shim is what upgrades this mode from output-stabilization
|
||||||
|
* guessing to definitive hook events (see deepseek-status-shim.ts). The
|
||||||
|
* pane id IS the Codeman session id, which is how the shim attributes a
|
||||||
|
* report without trusting anything the agent could influence.
|
||||||
|
*
|
||||||
|
* Also forwards DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from the server env when
|
||||||
|
* present, matching the codex/gemini precedent for headless auth.
|
||||||
|
*/
|
||||||
|
private _configureDeepSeek(muxName: string, sessionId: string, config?: DeepSeekConfig): void {
|
||||||
|
const tmuxCmd = this.tmux();
|
||||||
|
const setenv = (key: string, value: string): void => {
|
||||||
|
const escaped = value.replace(/'/g, "'\\''");
|
||||||
|
try {
|
||||||
|
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* Non-critical */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DSH_HOME']) {
|
||||||
|
const val = process.env[key];
|
||||||
|
if (val) setenv(key, val);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum-validated at the schema boundary; re-checked here because this value
|
||||||
|
// reaches a shell line, and a builder must never trust its caller.
|
||||||
|
if (
|
||||||
|
config?.permissionMode &&
|
||||||
|
['read-only', 'workspace-write', 'danger-full-access'].includes(config.permissionMode)
|
||||||
|
) {
|
||||||
|
setenv('DSH_PERMISSION_MODE', config.permissionMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.statusReporting !== false) {
|
||||||
|
const shim = ensureDeepSeekStatusShim();
|
||||||
|
if (shim) {
|
||||||
|
setenv('HERDR_ENV', '1');
|
||||||
|
setenv('HERDR_BIN_PATH', shim);
|
||||||
|
setenv('HERDR_PANE_ID', sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||||
* In test mode: creates an in-memory session only (no real tmux session).
|
* In test mode: creates an in-memory session only (no real tmux session).
|
||||||
@@ -1903,6 +2032,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
deepSeekConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
@@ -1963,6 +2093,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (mode === 'pi' && !cliDir) {
|
if (mode === 'pi' && !cliDir) {
|
||||||
throw new Error(getPiNotFoundMessage());
|
throw new Error(getPiNotFoundMessage());
|
||||||
}
|
}
|
||||||
|
if (mode === 'deepseek' && !cliDir) {
|
||||||
|
throw new Error(getDeepSeekNotFoundMessage());
|
||||||
|
}
|
||||||
if (mode === 'grok' && !cliDir) {
|
if (mode === 'grok' && !cliDir) {
|
||||||
throw new Error(getGrokNotFoundMessage());
|
throw new Error(getGrokNotFoundMessage());
|
||||||
}
|
}
|
||||||
@@ -1981,6 +2114,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
deepSeekConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
effort,
|
effort,
|
||||||
sessionName: name,
|
sessionName: name,
|
||||||
@@ -2049,6 +2183,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (mode === 'gemini') {
|
if (mode === 'gemini') {
|
||||||
this._configureGemini(muxName);
|
this._configureGemini(muxName);
|
||||||
}
|
}
|
||||||
|
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
|
||||||
|
if (mode === 'deepseek') {
|
||||||
|
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
|
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
|
||||||
// so secret values stay off the bash command line. Must run before respawn-pane.
|
// so secret values stay off the bash command line. Must run before respawn-pane.
|
||||||
@@ -2206,6 +2344,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
deepSeekConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
@@ -2236,6 +2375,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
deepSeekConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
effort,
|
effort,
|
||||||
sessionName: name,
|
sessionName: name,
|
||||||
@@ -2260,6 +2400,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
if (mode === 'gemini') {
|
if (mode === 'gemini') {
|
||||||
this._configureGemini(muxName);
|
this._configureGemini(muxName);
|
||||||
}
|
}
|
||||||
|
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
|
||||||
|
if (mode === 'deepseek') {
|
||||||
|
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
|
||||||
|
}
|
||||||
|
|
||||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||||
this.applyEnvOverrides(muxName, envOverrides);
|
this.applyEnvOverrides(muxName, envOverrides);
|
||||||
|
|||||||
@@ -1014,6 +1014,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string
|
|||||||
{ id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' },
|
{ id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' },
|
||||||
{ id: 'pi', label: 'pi', detail: 'pi.dev' },
|
{ id: 'pi', label: 'pi', detail: 'pi.dev' },
|
||||||
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' },
|
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' },
|
||||||
|
{ id: 'deepseek', label: 'deepseek', detail: 'DeepSeek Harness (dsh)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export type TuiAnswerResult =
|
|||||||
|
|
||||||
export interface TuiQuickStartOptions {
|
export interface TuiQuickStartOptions {
|
||||||
caseName: string;
|
caseName: string;
|
||||||
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
|
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek';
|
||||||
sessionName?: string;
|
sessionName?: string;
|
||||||
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
|
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
|
||||||
parentSessionId?: string;
|
parentSessionId?: string;
|
||||||
|
|||||||
+71
-4
@@ -8,7 +8,7 @@
|
|||||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||||
* - SessionOutput — captured stdout/stderr/exitCode
|
* - SessionOutput — captured stdout/stderr/exitCode
|
||||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' (which CLI backend)
|
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' (which CLI backend)
|
||||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||||
* - SessionColor — visual differentiation color
|
* - SessionColor — visual differentiation color
|
||||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||||
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
||||||
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
||||||
|
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
|
||||||
*
|
*
|
||||||
* Cross-domain relationships:
|
* Cross-domain relationships:
|
||||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
||||||
@@ -45,11 +46,20 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
|||||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
|
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
|
||||||
|
|
||||||
/** Session mode: which CLI backend a session runs */
|
/** Session mode: which CLI backend a session runs */
|
||||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
|
export type SessionMode =
|
||||||
|
| 'claude'
|
||||||
|
| 'shell'
|
||||||
|
| 'opencode'
|
||||||
|
| 'codex'
|
||||||
|
| 'gemini'
|
||||||
|
| 'antigravity'
|
||||||
|
| 'pi'
|
||||||
|
| 'grok'
|
||||||
|
| 'deepseek';
|
||||||
|
|
||||||
export type RemoteCommandMode = Extract<
|
export type RemoteCommandMode = Extract<
|
||||||
SessionMode,
|
SessionMode,
|
||||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
|
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,7 +168,7 @@ export interface RemoteSessionInfo {
|
|||||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||||
export type DockerCommandMode = Extract<
|
export type DockerCommandMode = Extract<
|
||||||
SessionMode,
|
SessionMode,
|
||||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
|
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||||
@@ -388,6 +398,61 @@ export interface GrokConfig {
|
|||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeepSeek Harness (`dsh`) session configuration.
|
||||||
|
*
|
||||||
|
* Two things make this config shaped unlike every sibling above it.
|
||||||
|
*
|
||||||
|
* **1. The agent is a PROFILE, not the binary.** `dsh` is a launcher: it boots
|
||||||
|
* `$DSH_HOME/profiles/<name>`, an ordered stack of plugin-bundle patch layers.
|
||||||
|
* DeepSeek ships only `web`, `headless` and `base`, so the interactive terminal
|
||||||
|
* agent is always a third-party profile the user installed. `profile` is
|
||||||
|
* therefore the primary knob, and an absent one resolves to the first
|
||||||
|
* pane-capable profile found (see resolveDefaultDeepSeekProfile).
|
||||||
|
*
|
||||||
|
* **2. Permissions are an ENV VAR, not a flag.** The harness has no
|
||||||
|
* `--dangerously-skip-permissions` equivalent; its sandbox and approval rows are
|
||||||
|
* config, driven by one documented input, `DSH_PERMISSION_MODE`, with three
|
||||||
|
* presets (measured from `dsh --dump-default-config`):
|
||||||
|
*
|
||||||
|
* read-only sandbox read-only, approval ask
|
||||||
|
* workspace-write sandbox workspace-write, approval ask <- default
|
||||||
|
* danger-full-access sandbox danger-full-access, approval never
|
||||||
|
*
|
||||||
|
* This is the one place a Codeman env export is the RIGHT mechanism rather than
|
||||||
|
* the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks
|
||||||
|
* in-session `/effort`), `DSH_PERMISSION_MODE` is read with `??` as a boot-time
|
||||||
|
* DEFAULT, so it stays a soft default the user can still change in-session. It
|
||||||
|
* is exported via `tmux setenv`, never on the spawn command line.
|
||||||
|
*/
|
||||||
|
export interface DeepSeekConfig {
|
||||||
|
/**
|
||||||
|
* Profile under `$DSH_HOME/profiles` to boot (`dsh --profile <name>`). Absent
|
||||||
|
* = the first pane-capable profile installed. A `web`/`headless` profile is
|
||||||
|
* refused at spawn time: neither can drive an interactive pane.
|
||||||
|
*/
|
||||||
|
profile?: string;
|
||||||
|
/**
|
||||||
|
* Sandbox + approval preset, exported as `DSH_PERMISSION_MODE`. Absent = the
|
||||||
|
* harness's own `workspace-write` default, which still ASKS — which is why the
|
||||||
|
* multi-user clamp only needs the only-if-sent branch here, like
|
||||||
|
* codex/antigravity/grok rather than pi.
|
||||||
|
*/
|
||||||
|
permissionMode?: 'read-only' | 'workspace-write' | 'danger-full-access';
|
||||||
|
/** Resume the most recent session for this workspace (`--resume`). */
|
||||||
|
resumeSession?: boolean;
|
||||||
|
/** Resume a specific session by ID (`--resume <id>`). Wins over resumeSession. */
|
||||||
|
resumeSessionId?: string;
|
||||||
|
/**
|
||||||
|
* Report idle/working/blocked back to Codeman through the Herdr-compatible
|
||||||
|
* status shim (see `deepseek-status-shim.ts`). Default ON: it upgrades this
|
||||||
|
* mode from output-stabilization guessing to definitive hook events. Only
|
||||||
|
* TUIs that implement the contract report; for one that does not, this is
|
||||||
|
* inert rather than harmful.
|
||||||
|
*/
|
||||||
|
statusReporting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for creating a new session
|
* Configuration for creating a new session
|
||||||
*/
|
*/
|
||||||
@@ -553,6 +618,8 @@ export interface SessionState {
|
|||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
/** Grok-specific configuration (only for mode === 'grok') */
|
/** Grok-specific configuration (only for mode === 'grok') */
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||||
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Resolve the DeepSeek Harness CLI (`dsh`) binary and its bootable profiles.
|
||||||
|
*
|
||||||
|
* Mirrors pi-cli-resolver.ts / grok-cli-resolver.ts, but the identity probe here
|
||||||
|
* is STRICTER than either, and deliberately so: `dsh` is not merely a short name
|
||||||
|
* with npm squatters, it is an EXISTING, widely packaged Unix program. Debian and
|
||||||
|
* Ubuntu ship `dsh` = "dancer's shell" / distributed shell (`apt install dsh`),
|
||||||
|
* which like nearly every Unix tool prints a version-shaped string of its own.
|
||||||
|
* A version-token probe alone (which is all pi and grok need) would
|
||||||
|
* therefore ACCEPT dancer's shell as the DeepSeek Harness and hand it to a spawn
|
||||||
|
* line, so every candidate must additionally prove its identity by printing the
|
||||||
|
* harness's own help banner.
|
||||||
|
*
|
||||||
|
* Two probes per candidate, both bounded and both cached behind the shared
|
||||||
|
* resolver's positive/negative caching:
|
||||||
|
* 1. `dsh --help` must match DEEPSEEK_IDENTITY_REGEX (`DeepSeek Harness`)
|
||||||
|
* 2. `dsh --version` must yield a version token (real output: `0.1.1-rc.2`)
|
||||||
|
* Order matters: identity is checked FIRST, so a foreign `dsh` is rejected on the
|
||||||
|
* cheaper, more discriminating signal and never contributes a version number.
|
||||||
|
*
|
||||||
|
* `dsh` is a profile LAUNCHER, not an agent: `dsh --profile <name>` boots an
|
||||||
|
* ordered stack of plugin-bundle patch layers, and DeepSeek ships only `web`
|
||||||
|
* (browser UI), `headless` (one-shot) and `base` (no app). The interactive
|
||||||
|
* terminal agent Codeman actually drives is a THIRD-PARTY profile the user
|
||||||
|
* installs. That is why this module resolves two independent things — a binary
|
||||||
|
* AND a profile inventory — and why "available" for the deepseek run mode means
|
||||||
|
* both (`isDeepSeekRunnable`, and `resolveDeepSeekLaunchError` in session-routes.ts
|
||||||
|
* for the actionable per-half message).
|
||||||
|
*
|
||||||
|
* @module utils/deepseek-cli-resolver
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
|
import {
|
||||||
|
createCliExecutableResolver,
|
||||||
|
formatCliNotFoundMessage,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from './cli-executable-resolver.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common directories where the `dsh` binary may be installed.
|
||||||
|
*
|
||||||
|
* `dsh` is an npm package (`@deepseek-ai/dsh`), so unlike grok there is no
|
||||||
|
* vendor-owned install dir to lead with: the global npm bin is wherever the
|
||||||
|
* user's prefix points. `~/.local/bin` heads the list because it is the default
|
||||||
|
* for a prefix-relocated npm (and is where this box's install landed).
|
||||||
|
*/
|
||||||
|
const DEEPSEEK_SEARCH_DIRS = [
|
||||||
|
join(homedir(), '.local', 'bin'),
|
||||||
|
'/usr/local/bin',
|
||||||
|
join(homedir(), '.npm-global', 'bin'),
|
||||||
|
join(homedir(), 'bin'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real `dsh --version` prints a bare `0.1.1-rc.2` (measured, 0.1.1-rc.2), so
|
||||||
|
* the prerelease suffix is part of the token — truncating it to `0.1.1` would
|
||||||
|
* misreport a release-candidate as a release in `codeman doctor`.
|
||||||
|
*
|
||||||
|
* Exported and SHARED with the `dsh` entry in `config/dependency-registry.ts`,
|
||||||
|
* so the doctor and the run mode cannot disagree about what counts as an
|
||||||
|
* installed dsh (the same single-source rule as PI_VERSION_REGEX /
|
||||||
|
* GROK_VERSION_REGEX). Shape is dictated by the doctor's `extractVersion()`
|
||||||
|
* (first capture group, whole-output scan): hence a capturing group and a
|
||||||
|
* leading boundary instead of `^`. No `g` flag, so there is no shared
|
||||||
|
* `lastIndex` to reset.
|
||||||
|
*/
|
||||||
|
export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity marker that separates DeepSeek's `dsh` from Debian's dancer's
|
||||||
|
* shell. The real launcher's `--help` banner reads:
|
||||||
|
*
|
||||||
|
* dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle …
|
||||||
|
*
|
||||||
|
* Matched case-insensitively against the help output. This is the check that
|
||||||
|
* makes the resolver safe to point a spawn line at; see the module header.
|
||||||
|
*/
|
||||||
|
export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i;
|
||||||
|
|
||||||
|
const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh';
|
||||||
|
|
||||||
|
/** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */
|
||||||
|
export function resolveDshHome(): string {
|
||||||
|
const fromEnv = process.env.DSH_HOME?.trim();
|
||||||
|
return fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a profile is FOR, inferred from the bundles it composes.
|
||||||
|
*
|
||||||
|
* `interactive` is the only kind a tmux pane can drive: `web` serves a browser
|
||||||
|
* UI and would occupy the pane with a logging server, `headless` answers one
|
||||||
|
* task and exits (which reads as an instantly-dead pane). `unknown` is treated
|
||||||
|
* as interactive-capable on purpose — the whole point of the harness is that
|
||||||
|
* anyone can publish an app bundle, so an unrecognized third-party profile must
|
||||||
|
* not be hidden from the picker just because this list has not heard of it.
|
||||||
|
*/
|
||||||
|
export type DeepSeekProfileKind = 'interactive' | 'web' | 'headless' | 'unknown';
|
||||||
|
|
||||||
|
export interface DeepSeekProfile {
|
||||||
|
/** Directory name under `$DSH_HOME/profiles`, i.e. the `--profile` argument. */
|
||||||
|
name: string;
|
||||||
|
/** Bundle package names composed by the profile, in order. */
|
||||||
|
bundles: string[];
|
||||||
|
kind: DeepSeekProfileKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bundles that positively identify a non-interactive profile. */
|
||||||
|
const WEB_BUNDLE_PATTERN = /dsh-web-app|dsh-web-frontend/i;
|
||||||
|
const HEADLESS_BUNDLE_PATTERN = /dsh-headless/i;
|
||||||
|
/**
|
||||||
|
* Bundles that positively identify a terminal app. Intentionally a loose
|
||||||
|
* community-wide pattern rather than one blessed package: the terminal front
|
||||||
|
* door is third-party by construction (DeepSeek ships none), and a dozen
|
||||||
|
* scoped `dsh-tui` packages from a dozen different authors compete. Anything
|
||||||
|
* matching is a TUI; anything unmatched is `unknown`, which still counts as
|
||||||
|
* launchable.
|
||||||
|
*/
|
||||||
|
const TUI_BUNDLE_PATTERN = /dsh-tui|dsh-terminal-app|tui/i;
|
||||||
|
|
||||||
|
/** Profile directory names that are not profiles. */
|
||||||
|
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
||||||
|
|
||||||
|
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
||||||
|
const haystack = [name, ...bundles].join(' ');
|
||||||
|
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
||||||
|
// web profile as far as a tmux pane is concerned, because the web app owns the
|
||||||
|
// process and blocks.
|
||||||
|
if (WEB_BUNDLE_PATTERN.test(haystack)) return 'web';
|
||||||
|
if (HEADLESS_BUNDLE_PATTERN.test(haystack)) return 'headless';
|
||||||
|
if (TUI_BUNDLE_PATTERN.test(haystack)) return 'interactive';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a single profile directory's `package.json` and return its bundle list.
|
||||||
|
* Returns null for anything that is not a readable dsh profile, so a stray
|
||||||
|
* directory under `profiles/` cannot break the inventory.
|
||||||
|
*/
|
||||||
|
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
||||||
|
const rawBundles = parsed?.dsh?.profile?.bundles;
|
||||||
|
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
||||||
|
return { name, bundles, kind: classifyProfile(name, bundles) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inventory the profiles installed under `$DSH_HOME/profiles`.
|
||||||
|
*
|
||||||
|
* Never throws: a missing DSH_HOME (dsh installed but never run) is an empty
|
||||||
|
* list, which the callers render as "no profile yet" rather than an error.
|
||||||
|
* Deliberately un-cached — a user can create a profile at any moment (including
|
||||||
|
* through Codeman's own bootstrap), and the directory scan is cheap next to the
|
||||||
|
* two process spawns the binary probe already costs.
|
||||||
|
*/
|
||||||
|
export function listDeepSeekProfiles(): DeepSeekProfile[] {
|
||||||
|
const profilesDir = join(resolveDshHome(), 'profiles');
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(profilesDir, { withFileTypes: true })
|
||||||
|
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
|
||||||
|
.map((e) => e.name);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.map((name) => readProfile(profilesDir, name))
|
||||||
|
.filter((p): p is DeepSeekProfile => p !== null)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile a session should boot when the user picked none.
|
||||||
|
*
|
||||||
|
* Prefers a positively-identified terminal profile, then an unrecognized one
|
||||||
|
* (third-party by construction — see TUI_BUNDLE_PATTERN), and refuses to fall
|
||||||
|
* back to `web`/`headless`, which cannot drive a pane. Returns null when nothing
|
||||||
|
* launchable is installed, which is what makes the mode report unavailable
|
||||||
|
* instead of spawning a pane that dies on arrival.
|
||||||
|
*/
|
||||||
|
export function resolveDefaultDeepSeekProfile(profiles: DeepSeekProfile[] = listDeepSeekProfiles()): string | null {
|
||||||
|
return (
|
||||||
|
profiles.find((p) => p.kind === 'interactive')?.name ?? profiles.find((p) => p.kind === 'unknown')?.name ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the profile can occupy a tmux pane as an interactive agent. */
|
||||||
|
export function isLaunchableProfile(profile: DeepSeekProfile): boolean {
|
||||||
|
return profile.kind === 'interactive' || profile.kind === 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the two-stage identity+version probe on a candidate path.
|
||||||
|
*
|
||||||
|
* Returns the version token only when the binary proves it is the DeepSeek
|
||||||
|
* Harness launcher. Returns null for anything else: a missing binary, a
|
||||||
|
* non-zero exit, a hang (timeout), a help banner without the harness marker
|
||||||
|
* (this is the dancer's-shell rejection), or output with no version-shaped
|
||||||
|
* token.
|
||||||
|
*
|
||||||
|
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||||
|
* whether the dev box happens to have dsh installed — and since `dsh` names a
|
||||||
|
* real Debian program, this probe would EXECUTE whatever binary of that name the
|
||||||
|
* machine carries. The shared resolver host is already inert under vitest, so
|
||||||
|
* this gate is defense in depth for any opted-in host that still carries the
|
||||||
|
* default probe; tests drive resolution via `createDeepSeekResolverForTest`,
|
||||||
|
* whose injected probe bypasses it. Pinned by test/deepseek-cli-resolver.test.ts.
|
||||||
|
*/
|
||||||
|
function probeDeepSeekVersion(binPath: string): string | null {
|
||||||
|
if (process.env.VITEST) return null;
|
||||||
|
const run = (args: string[]): string | null => {
|
||||||
|
try {
|
||||||
|
return execFileSync(binPath, args, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
// A stuck or hostile `dsh` that ignores SIGTERM would survive the timeout
|
||||||
|
// and block the server (execFileSync keeps waiting after the signal).
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
|
}).trim();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[DeepSeekResolver] Ignoring ${binPath}: "dsh ${args.join(' ')}" failed (${(err as Error).message})`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Identity first — the discriminating signal, and the one that keeps Debian's
|
||||||
|
// dancer's shell out of a spawn line.
|
||||||
|
const help = run(['--help']);
|
||||||
|
if (help === null) return null;
|
||||||
|
if (!DEEPSEEK_IDENTITY_REGEX.test(help)) {
|
||||||
|
console.warn(
|
||||||
|
`[DeepSeekResolver] Ignoring ${binPath}: "dsh --help" is not the DeepSeek Harness launcher ` +
|
||||||
|
`(printed ${JSON.stringify(help.slice(0, 80))}). A different program named "dsh" (e.g. Debian's ` +
|
||||||
|
`dancer's shell) is earlier on PATH.`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = run(['--version']);
|
||||||
|
if (out === null) return null;
|
||||||
|
const candidate = DEEPSEEK_VERSION_REGEX.exec(out)?.[1];
|
||||||
|
if (candidate) return candidate;
|
||||||
|
console.warn(`[DeepSeekResolver] Ignoring ${binPath}: "dsh --version" printed ${JSON.stringify(out.slice(0, 80))}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeepSeekVersionProbe = (binPath: string) => string | null;
|
||||||
|
|
||||||
|
function createDeepSeekResolver(
|
||||||
|
host?: CliResolverHost,
|
||||||
|
versionProbe: DeepSeekVersionProbe = probeDeepSeekVersion,
|
||||||
|
now?: () => number
|
||||||
|
) {
|
||||||
|
return createCliExecutableResolver<string>(
|
||||||
|
{
|
||||||
|
binary: 'dsh',
|
||||||
|
searchDirs: DEEPSEEK_SEARCH_DIRS,
|
||||||
|
validateCandidate: (binPath) => {
|
||||||
|
const version = versionProbe(binPath);
|
||||||
|
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
},
|
||||||
|
host
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an isolated DeepSeek wrapper around an injected host, version probe
|
||||||
|
* and clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe,
|
||||||
|
* which is exactly what the hermeticity test exercises.
|
||||||
|
*/
|
||||||
|
export function createDeepSeekResolverForTest(
|
||||||
|
host: CliResolverHost,
|
||||||
|
versionProbe?: DeepSeekVersionProbe,
|
||||||
|
now?: () => number
|
||||||
|
) {
|
||||||
|
return createDeepSeekResolver(host, versionProbe ?? probeDeepSeekVersion, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deepSeekResolver = createDeepSeekResolver();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the directory containing a verified `dsh` binary.
|
||||||
|
* Checks the server PATH first, then the common install locations. Every
|
||||||
|
* candidate must pass the identity+version probe before it is accepted.
|
||||||
|
*
|
||||||
|
* @returns Directory path, or null if not found
|
||||||
|
*/
|
||||||
|
export function resolveDeepSeekDir(): string | null {
|
||||||
|
return deepSeekResolver.resolve()?.directory ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the `dsh` BINARY is installed. Note this is deliberately weaker than
|
||||||
|
* what the run mode needs: a dsh with no launchable profile cannot start a
|
||||||
|
* session. Callers gating the Run button want `isDeepSeekRunnable()`.
|
||||||
|
*/
|
||||||
|
export function isDeepSeekAvailable(): boolean {
|
||||||
|
return resolveDeepSeekDir() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binary present AND at least one profile that can occupy a pane. */
|
||||||
|
export function isDeepSeekRunnable(): boolean {
|
||||||
|
return isDeepSeekAvailable() && resolveDefaultDeepSeekProfile() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeepSeekNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(DEEPSEEK_NOT_FOUND, deepSeekResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version reported by the resolved `dsh` binary, or null when dsh is
|
||||||
|
* unavailable. Surfaced through `GET /api/deepseek/status` so a misresolution
|
||||||
|
* is diagnosable from the UI.
|
||||||
|
*/
|
||||||
|
export function getDeepSeekCliVersion(): string | null {
|
||||||
|
return deepSeekResolver.resolve()?.metadata ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does the named profile exist and can it drive a pane? */
|
||||||
|
export function profileExists(name: string): boolean {
|
||||||
|
return existsSync(join(resolveDshHome(), 'profiles', name, 'package.json'));
|
||||||
|
}
|
||||||
@@ -46,5 +46,18 @@ export {
|
|||||||
} from './antigravity-cli-resolver.js';
|
} from './antigravity-cli-resolver.js';
|
||||||
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
|
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
|
||||||
export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-cli-resolver.js';
|
export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-cli-resolver.js';
|
||||||
|
export {
|
||||||
|
resolveDeepSeekDir,
|
||||||
|
isDeepSeekAvailable,
|
||||||
|
isDeepSeekRunnable,
|
||||||
|
getDeepSeekCliVersion,
|
||||||
|
getDeepSeekNotFoundMessage,
|
||||||
|
listDeepSeekProfiles,
|
||||||
|
resolveDefaultDeepSeekProfile,
|
||||||
|
isLaunchableProfile,
|
||||||
|
resolveDshHome,
|
||||||
|
profileExists,
|
||||||
|
} from './deepseek-cli-resolver.js';
|
||||||
|
export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js';
|
||||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||||
export type { FileQueryMatcher } from './file-query.js';
|
export type { FileQueryMatcher } from './file-query.js';
|
||||||
|
|||||||
+10
-5
@@ -240,6 +240,7 @@ const _SSE_HANDLER_MAP = [
|
|||||||
[SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
|
[SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
|
||||||
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
|
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
|
||||||
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
|
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
|
||||||
|
[SSE_EVENTS.HOOK_AGENT_WORKING, '_onHookAgentWorking'],
|
||||||
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
|
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
|
||||||
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
|
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
|
||||||
|
|
||||||
@@ -2252,9 +2253,11 @@ class CodemanApp {
|
|||||||
? 'Pi'
|
? 'Pi'
|
||||||
: mode === 'grok'
|
: mode === 'grok'
|
||||||
? 'Grok'
|
? 'Grok'
|
||||||
: mode === 'opencode'
|
: mode === 'deepseek'
|
||||||
? 'OpenCode'
|
? 'DeepSeek'
|
||||||
: 'Claude';
|
: mode === 'opencode'
|
||||||
|
? 'OpenCode'
|
||||||
|
: 'Claude';
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleResponseViewer() {
|
async toggleResponseViewer() {
|
||||||
@@ -4805,7 +4808,7 @@ class CodemanApp {
|
|||||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||||
<span class="tab-info">
|
<span class="tab-info">
|
||||||
<span class="tab-name-row">
|
<span class="tab-name-row">
|
||||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : ''}
|
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : ''}
|
||||||
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
||||||
${inlineSessionActions ? tabActionsHtml : ''}
|
${inlineSessionActions ? tabActionsHtml : ''}
|
||||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||||
@@ -6238,7 +6241,9 @@ class CodemanApp {
|
|||||||
? 'Kill Tmux & Pi'
|
? 'Kill Tmux & Pi'
|
||||||
: session.mode === 'grok'
|
: session.mode === 'grok'
|
||||||
? 'Kill Tmux & Grok'
|
? 'Kill Tmux & Grok'
|
||||||
: 'Kill Tmux & Claude Code';
|
: session.mode === 'deepseek'
|
||||||
|
? 'Kill Tmux & DeepSeek'
|
||||||
|
: 'Kill Tmux & Claude Code';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('closeConfirmModal').classList.add('active');
|
document.getElementById('closeConfirmModal').classList.add('active');
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notificati
|
|||||||
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
|
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
|
||||||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||||
|
/**
|
||||||
|
* Port the DeepSeek Harness browser UI is started on by the run-menu shortcut.
|
||||||
|
* dsh's own default, so a hand-started `dsh web` and the shortcut land on the
|
||||||
|
* same place and share one saved tab.
|
||||||
|
*/
|
||||||
|
const DEEPSEEK_WEB_PORT = 3080;
|
||||||
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
|
||||||
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
|
||||||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||||
@@ -949,6 +955,7 @@ const SSE_EVENTS = {
|
|||||||
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
|
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
|
||||||
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
|
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
|
||||||
HOOK_STOP: 'hook:stop',
|
HOOK_STOP: 'hook:stop',
|
||||||
|
HOOK_AGENT_WORKING: 'hook:agent_working',
|
||||||
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
||||||
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const HOME_SESSIONS_MODE_BADGE = {
|
|||||||
antigravity: 'ag',
|
antigravity: 'ag',
|
||||||
pi: 'pi',
|
pi: 'pi',
|
||||||
grok: 'gk',
|
grok: 'gk',
|
||||||
|
deepseek: 'ds',
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@
|
|||||||
'Run Antigravity': '运行 Antigravity',
|
'Run Antigravity': '运行 Antigravity',
|
||||||
'Run Pi': '运行 Pi',
|
'Run Pi': '运行 Pi',
|
||||||
'Run Grok': '运行 Grok',
|
'Run Grok': '运行 Grok',
|
||||||
|
'Run DeepSeek': '运行 DeepSeek',
|
||||||
'Run Shell': '运行 Shell',
|
'Run Shell': '运行 Shell',
|
||||||
'Select AI backend': '选择 AI 后端',
|
'Select AI backend': '选择 AI 后端',
|
||||||
'Create New Case': '新建案例',
|
'Create New Case': '新建案例',
|
||||||
|
|||||||
@@ -448,6 +448,10 @@
|
|||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
Run Grok
|
Run Grok
|
||||||
</button>
|
</button>
|
||||||
|
<button class="welcome-btn welcome-btn-deepseek" id="welcomeDeepSeekBtn" style="display: none;" onclick="app.setRunMode('deepseek'); app.runDeepSeek()">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
|
Run DeepSeek
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||||
@@ -630,6 +634,15 @@
|
|||||||
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
|
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
|
||||||
<span class="run-mode-dot grok"></span>Grok
|
<span class="run-mode-dot grok"></span>Grok
|
||||||
</button>
|
</button>
|
||||||
|
<button class="run-mode-option" data-mode="deepseek" onclick="app.setRunMode('deepseek')">
|
||||||
|
<span class="run-mode-dot deepseek"></span>DeepSeek
|
||||||
|
</button>
|
||||||
|
<!-- Shown only when `dsh` is installed but no pane-capable profile is:
|
||||||
|
DeepSeek ships no terminal front door, so the fix is an install,
|
||||||
|
not a greyed-out entry the user cannot act on. -->
|
||||||
|
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
|
||||||
|
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
|
||||||
|
</button>
|
||||||
<div class="run-mode-sep"></div>
|
<div class="run-mode-sep"></div>
|
||||||
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
|
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
|
||||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||||
@@ -642,6 +655,13 @@
|
|||||||
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
|
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
|
||||||
<span class="run-mode-dot web"></span>Add URL…
|
<span class="run-mode-dot web"></span>Add URL…
|
||||||
</button>
|
</button>
|
||||||
|
<!-- The DeepSeek Harness browser UI is the vendor's OWN interactive
|
||||||
|
surface (the terminal one is third-party), so it gets a shortcut:
|
||||||
|
this starts `dsh web` in a shell session and opens it as a tab.
|
||||||
|
Shown only when dsh is installed. -->
|
||||||
|
<button class="run-mode-option run-mode-option--web" id="runModeDeepSeekWeb" style="display: none;" onclick="app.runDeepSeekWeb()">
|
||||||
|
<span class="run-mode-dot deepseek"></span>DeepSeek web UI…
|
||||||
|
</button>
|
||||||
<div class="run-mode-sep"></div>
|
<div class="run-mode-sep"></div>
|
||||||
<div class="run-mode-header">Recent Sessions</div>
|
<div class="run-mode-header">Recent Sessions</div>
|
||||||
<div class="run-mode-history" id="runModeHistory"></div>
|
<div class="run-mode-history" id="runModeHistory"></div>
|
||||||
@@ -908,6 +928,7 @@
|
|||||||
<option value="antigravity">Antigravity</option>
|
<option value="antigravity">Antigravity</option>
|
||||||
<option value="pi">Pi</option>
|
<option value="pi">Pi</option>
|
||||||
<option value="grok">Grok</option>
|
<option value="grok">Grok</option>
|
||||||
|
<option value="deepseek">DeepSeek</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||||
@@ -2676,6 +2697,7 @@
|
|||||||
<option value="antigravity" data-cli="antigravity">Antigravity</option>
|
<option value="antigravity" data-cli="antigravity">Antigravity</option>
|
||||||
<option value="pi" data-cli="pi">Pi</option>
|
<option value="pi" data-cli="pi">Pi</option>
|
||||||
<option value="grok" data-cli="grok">Grok</option>
|
<option value="grok" data-cli="grok">Grok</option>
|
||||||
|
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
|
||||||
<option value="shell">Shell (no agent)</option>
|
<option value="shell">Shell (no agent)</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
|
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
|
||||||
@@ -2816,7 +2838,7 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Image</label>
|
<label>Image</label>
|
||||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok + tmux.</span>
|
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Network</label>
|
<label>Network</label>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
|||||||
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
|
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
|
||||||
{ mode: 'pi', label: 'Pi', short: 'Pi' },
|
{ mode: 'pi', label: 'Pi', short: 'Pi' },
|
||||||
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
||||||
|
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
||||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -986,6 +986,23 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-color: rgba(212, 212, 216, 0.5) !important;
|
border-color: rgba(212, 212, 216, 0.5) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DeepSeek mode colors on mobile. Same `!important` rationale as the pi and
|
||||||
|
grok blocks above: styles.css nests its skin rules inside
|
||||||
|
`html:not([data-skin="og"])`, so a bare `.btn-toolbar` rule there outranks a
|
||||||
|
`.btn-toolbar.btn-x` rule here regardless of load order. */
|
||||||
|
.btn-toolbar.btn-run.mode-deepseek,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-deepseek {
|
||||||
|
background: #16225f !important;
|
||||||
|
border-color: rgba(124, 147, 255, 0.35) !important;
|
||||||
|
color: #eef2ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toolbar.btn-run.mode-deepseek:active,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-deepseek:active {
|
||||||
|
background: #3350e6 !important;
|
||||||
|
border-color: rgba(150, 170, 255, 0.55) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||||
.run-mode-menu {
|
.run-mode-menu {
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
@@ -3075,6 +3092,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-deepseek, .btn-toolbar.btn-run-gear.mode-deepseek) {
|
||||||
|
background: linear-gradient(135deg, #2740c4, #4d6bfe);
|
||||||
|
border-color: #1b2a8f;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
|
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
|
||||||
border-left-color: var(--control-border-hover) !important;
|
border-left-color: var(--control-border-hover) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
_buildCommandPaletteNewSessionItem(query = '') {
|
_buildCommandPaletteNewSessionItem(query = '') {
|
||||||
const mode = this.runMode || this._runMode || 'claude';
|
const mode = this.runMode || this._runMode || 'claude';
|
||||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok' };
|
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek' };
|
||||||
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
||||||
return {
|
return {
|
||||||
id: 'new-session',
|
id: 'new-session',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok),
|
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok/DeepSeek),
|
||||||
* session options modal (per-session settings, color picker, rename),
|
* session options modal (per-session settings, color picker, rename),
|
||||||
* session options tabs (Ralph config tab), case settings (CRUD, links),
|
* session options tabs (Ralph config tab), case settings (CRUD, links),
|
||||||
* create case modal, and mobile case picker.
|
* create case modal, and mobile case picker.
|
||||||
@@ -406,6 +406,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (mode === 'grok') {
|
if (mode === 'grok') {
|
||||||
return await this.runGrok();
|
return await this.runGrok();
|
||||||
}
|
}
|
||||||
|
if (mode === 'deepseek') {
|
||||||
|
return await this.runDeepSeek();
|
||||||
|
}
|
||||||
if (mode === 'shell') {
|
if (mode === 'shell') {
|
||||||
return await this.runShell();
|
return await this.runShell();
|
||||||
}
|
}
|
||||||
@@ -471,10 +474,121 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
|
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
|
||||||
*/
|
*/
|
||||||
_refreshRunModeAvailability(menu) {
|
_refreshRunModeAvailability(menu) {
|
||||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']) {
|
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) {
|
||||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||||
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
|
||||||
|
// perfectly installed while no pane-capable profile exists, because DeepSeek
|
||||||
|
// ships no terminal front door. In that state the honest offer is "add one",
|
||||||
|
// not a hidden entry with no explanation anywhere.
|
||||||
|
const avail = window.__codemanCliAvailable || {};
|
||||||
|
const dsInstall = menu.querySelector('#runModeDeepSeekInstall');
|
||||||
|
if (dsInstall) {
|
||||||
|
dsInstall.style.display = !avail.deepseek && avail.deepseekBinary ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
// The web UI needs only the BINARY: it is the one interactive surface
|
||||||
|
// DeepSeek ships itself, so it works on a box with no terminal profile at
|
||||||
|
// all (and is the honest thing to offer there).
|
||||||
|
const dsWeb = menu.querySelector('#runModeDeepSeekWeb');
|
||||||
|
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the DeepSeek Harness browser UI and open it as a Codeman web tab.
|
||||||
|
*
|
||||||
|
* Deliberately built from parts that already exist rather than a new process
|
||||||
|
* manager: the server runs in an ordinary SHELL session, so it is visible,
|
||||||
|
* scrollable, killable and dies with its tab like anything else, and the UI
|
||||||
|
* itself is an ordinary web tab. Nothing here needs to know how to supervise a
|
||||||
|
* long-lived HTTP server, because Codeman already does.
|
||||||
|
*
|
||||||
|
* `--trusted-host` is the load-bearing flag: dsh fences its `/api` behind a
|
||||||
|
* browser-trust check on the request authority, and a Codeman web tab reaches
|
||||||
|
* it through Codeman's own origin via the webview proxy, not directly. Without
|
||||||
|
* passing Codeman's authority the page renders and every API call fails.
|
||||||
|
*/
|
||||||
|
async runDeepSeekWeb() {
|
||||||
|
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||||
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||||
|
const port = DEEPSEEK_WEB_PORT;
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting the DeepSeek web UI in ${caseName}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/quick-start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
caseName,
|
||||||
|
mode: 'shell',
|
||||||
|
sessionName: `dsh-web-${caseName}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || 'Failed to start the shell session');
|
||||||
|
const sessionId = data.data.sessionId;
|
||||||
|
await this._ensureCreatedSessionVisible(sessionId, data.data.session);
|
||||||
|
|
||||||
|
// The shell needs a moment to reach its prompt before it will accept a
|
||||||
|
// command; the same settle the other shell-driven flows use.
|
||||||
|
await new Promise((r) => setTimeout(r, 1200));
|
||||||
|
const cmd = `dsh web --no-open --host 127.0.0.1 --port ${port} --trusted-host ${location.host}`;
|
||||||
|
await fetch(`/api/sessions/${sessionId}/input`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ input: `${cmd}\r` }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reuse a saved tab for the same URL rather than stacking duplicates every
|
||||||
|
// time the server is restarted.
|
||||||
|
let webview = [...(this.webviews?.values() || [])].find((w) => w.url === url);
|
||||||
|
if (!webview) {
|
||||||
|
const wvRes = await fetch('/api/webviews', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'DeepSeek Harness', url, icon: '🐳' }),
|
||||||
|
});
|
||||||
|
const wvData = await wvRes.json();
|
||||||
|
if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab');
|
||||||
|
webview = wvData.data.webview || wvData.data;
|
||||||
|
await this.loadWebviews?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} — opening it as a tab.`);
|
||||||
|
if (webview?.id) await this.openWebview(webview.id);
|
||||||
|
} catch (err) {
|
||||||
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install a DeepSeek Harness terminal profile from the run menu.
|
||||||
|
*
|
||||||
|
* Held open for as long as the package manager takes (the endpoint bounds it),
|
||||||
|
* so the button reports progress rather than appearing to do nothing. On
|
||||||
|
* success the availability map is patched in place, which is what makes the
|
||||||
|
* real DeepSeek entry appear without a reload.
|
||||||
|
*/
|
||||||
|
async installDeepSeekProfile() {
|
||||||
|
const label = 'Installing a DeepSeek terminal profile (this can take a minute)...';
|
||||||
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(label);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/deepseek/install-profile', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || 'Failed to install the profile');
|
||||||
|
window.__codemanCliAvailable = { ...(window.__codemanCliAvailable || {}), deepseek: !!data.data.runnable };
|
||||||
|
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Installed ${data.data.package} into profile "${data.data.profile}".`);
|
||||||
|
this.showToast?.(`DeepSeek profile "${data.data.profile}" installed`, 'success');
|
||||||
|
const menu = document.getElementById('runModeMenu');
|
||||||
|
if (menu) this._refreshRunModeAvailability(menu);
|
||||||
|
} catch (err) {
|
||||||
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async _loadRunModeHistory() {
|
async _loadRunModeHistory() {
|
||||||
@@ -568,7 +682,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||||
}
|
}
|
||||||
if (label) {
|
if (label) {
|
||||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'shell' ? 'Run SH' : 'Run';
|
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1341,6 +1455,84 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a DeepSeek Harness (`dsh`) session.
|
||||||
|
*
|
||||||
|
* Sends `permissionMode: 'danger-full-access'` for the same reason every
|
||||||
|
* sibling Run button sends its bypass switch: Codeman sessions exist for
|
||||||
|
* autonomous work. The harness has no bypass FLAG, so this rides the
|
||||||
|
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
|
||||||
|
* back down to `workspace-write` for non-granted owners server-side.
|
||||||
|
*
|
||||||
|
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
|
||||||
|
* output-stabilization guessing to definitive idle/blocked hook events.
|
||||||
|
*
|
||||||
|
* The two-part availability check is deliberate. `dsh` being installed is not
|
||||||
|
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
|
||||||
|
* binary and nothing a pane can run. Reporting that precisely, with the exact
|
||||||
|
* command that fixes it, is the difference between "the Run button is broken"
|
||||||
|
* and a 30-second fix.
|
||||||
|
*/
|
||||||
|
async runDeepSeek() {
|
||||||
|
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||||
|
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
|
||||||
|
// local-only config/env below (quick-start rejects them for remote cases).
|
||||||
|
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||||
|
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||||
|
|
||||||
|
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting DeepSeek session in ${caseName}...`);
|
||||||
|
this.terminal.focus();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isRemote) {
|
||||||
|
const statusRes = await fetch('/api/deepseek/status');
|
||||||
|
const status = (await statusRes.json()).data;
|
||||||
|
if (!status.available) {
|
||||||
|
this._reportSessionLaunchError(
|
||||||
|
ownsLaunchTerminal,
|
||||||
|
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!status.runnable) {
|
||||||
|
this._reportSessionLaunchError(
|
||||||
|
ownsLaunchTerminal,
|
||||||
|
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
||||||
|
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
||||||
|
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||||
|
const res = await fetch('/api/quick-start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
caseName,
|
||||||
|
mode: 'deepseek',
|
||||||
|
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||||
|
...(isRemote ? {} : {
|
||||||
|
deepSeekConfig: { permissionMode: 'danger-full-access' },
|
||||||
|
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || 'Failed to start DeepSeek');
|
||||||
|
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||||
|
|
||||||
|
if (data.data.sessionId) {
|
||||||
|
await this.selectSession(data.data.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.terminal.focus();
|
||||||
|
} catch (err) {
|
||||||
|
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Session Options Modal
|
// Session Options Modal
|
||||||
@@ -1406,7 +1598,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||||
|
|
||||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok';
|
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
|
||||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||||
|
|
||||||
// Update respawn status display and buttons
|
// Update respawn status display and buttons
|
||||||
@@ -1436,7 +1628,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hide Claude-specific options for external CLI sessions
|
// Hide Claude-specific options for external CLI sessions
|
||||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok';
|
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
|
||||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||||
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||||
|
|
||||||
@@ -3217,7 +3409,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
|||||||
},
|
},
|
||||||
set(mode) {
|
set(mode) {
|
||||||
this._runMode =
|
this._runMode =
|
||||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'claude'
|
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'claude'
|
||||||
? mode
|
? mode
|
||||||
: 'claude';
|
: 'claude';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding');
|
this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_onHookAgentWorking(data) {
|
||||||
|
// The agent started a turn, so whatever it was blocked on is gone. Reported
|
||||||
|
// by the DeepSeek status bridge; a harness turn cannot run while one of its
|
||||||
|
// own modal approvals is on screen, so this means the dialog was answered in
|
||||||
|
// the terminal. Same clearing as _onHookElicitationComplete, and notably NOT
|
||||||
|
// a notification: a turn STARTING is not news.
|
||||||
|
if (data.sessionId) {
|
||||||
|
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
|
||||||
|
this.clearPendingHooks(data.sessionId, 'permission_prompt');
|
||||||
|
this.clearPendingHooks(data.sessionId, 'idle_prompt');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_onHookTeammateIdle(data) {
|
_onHookTeammateIdle(data) {
|
||||||
const session = this.sessions.get(data.sessionId);
|
const session = this.sessions.get(data.sessionId);
|
||||||
this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`);
|
this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`);
|
||||||
@@ -1211,6 +1224,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
['welcomeGeminiBtn', 'gemini'],
|
['welcomeGeminiBtn', 'gemini'],
|
||||||
['welcomePiBtn', 'pi'],
|
['welcomePiBtn', 'pi'],
|
||||||
['welcomeGrokBtn', 'grok'],
|
['welcomeGrokBtn', 'grok'],
|
||||||
|
['welcomeDeepSeekBtn', 'deepseek'],
|
||||||
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
|
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
|
||||||
// without cloudflared can only ever produce "cloudflared not found".
|
// without cloudflared can only ever produce "cloudflared not found".
|
||||||
['welcomeTunnelBtn', 'cloudflared'],
|
['welcomeTunnelBtn', 'cloudflared'],
|
||||||
|
|||||||
@@ -2501,6 +2501,16 @@ body.solo-mode .btn-lifecycle-log {
|
|||||||
color: #d4d4d8;
|
color: #d4d4d8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DeepSeek: the vendor's own brand blue. Deliberately NOT added to the
|
||||||
|
light-skin `--accent-d` override list above (which rescues gemini/antigravity/
|
||||||
|
pi/grok, whose pastels wash out on paper backgrounds) — this indigo already
|
||||||
|
carries enough contrast on the light skins, and overriding it would throw away
|
||||||
|
the one cue that separates a dsh tab from its neighbours. */
|
||||||
|
.session-tab .tab-mode.deepseek {
|
||||||
|
background: rgba(77, 107, 254, 0.18);
|
||||||
|
color: #7c93ff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Timer Banner - Compact */
|
/* Timer Banner - Compact */
|
||||||
.timer-banner {
|
.timer-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4975,6 +4985,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
color: #fafafa;
|
color: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DeepSeek mode colors. Same cascade note as pi/grok above: this base-sheet pair
|
||||||
|
only renders on the `og` skin — the nested `html:not([data-skin="og"])` block
|
||||||
|
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so deepseek also
|
||||||
|
carries a rule inside that block (search `.btn-toolbar.btn-run.mode-deepseek`). */
|
||||||
|
.btn-toolbar.btn-run.mode-deepseek,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-deepseek {
|
||||||
|
background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%);
|
||||||
|
border-color: rgba(124, 147, 255, 0.55);
|
||||||
|
color: #eef2ff;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-run.mode-deepseek:hover,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-deepseek:hover {
|
||||||
|
background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%);
|
||||||
|
box-shadow: 0 0 12px rgba(77, 107, 254, 0.35), 0 2px 8px rgba(39, 64, 196, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(150, 170, 255, 0.65);
|
||||||
|
color: #f8faff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dropdown menu */
|
/* Dropdown menu */
|
||||||
.run-mode-menu {
|
.run-mode-menu {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -5059,6 +5088,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
.run-mode-dot.antigravity { background: #22d3ee; }
|
.run-mode-dot.antigravity { background: #22d3ee; }
|
||||||
.run-mode-dot.pi { background: #f472b6; }
|
.run-mode-dot.pi { background: #f472b6; }
|
||||||
.run-mode-dot.grok { background: #a1a1aa; }
|
.run-mode-dot.grok { background: #a1a1aa; }
|
||||||
|
.run-mode-dot.deepseek { background: #4d6bfe; }
|
||||||
.run-mode-dot.shell { background: #94a3b8; }
|
.run-mode-dot.shell { background: #94a3b8; }
|
||||||
|
|
||||||
/* Phone-only Enter button (see index.html). Hidden by default at every width;
|
/* Phone-only Enter button (see index.html). Hidden by default at every width;
|
||||||
@@ -14403,6 +14433,16 @@ html:not([data-skin="og"]) {
|
|||||||
color: #fafafa;
|
color: #fafafa;
|
||||||
}
|
}
|
||||||
.btn-toolbar.btn-run.mode-grok:hover { box-shadow: 0 0 14px -2px rgba(161, 161, 170, 0.5); }
|
.btn-toolbar.btn-run.mode-grok:hover { box-shadow: 0 0 14px -2px rgba(161, 161, 170, 0.5); }
|
||||||
|
/* DeepSeek keeps its indigo on the non-og skins — same specificity trap as pi
|
||||||
|
and grok above: without this rule the generic `.btn-toolbar.btn-run` in this
|
||||||
|
nested block wins and deepseek renders as generic claude blue, which is the
|
||||||
|
one colour it must not be mistaken for. */
|
||||||
|
.btn-toolbar.btn-run.mode-deepseek {
|
||||||
|
background: linear-gradient(135deg, #2740c4, #4d6bfe);
|
||||||
|
border-color: #1b2a8f;
|
||||||
|
color: #f8faff;
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-run.mode-deepseek:hover { box-shadow: 0 0 14px -2px rgba(77, 107, 254, 0.55); }
|
||||||
.btn-toolbar.btn-run-gear {
|
.btn-toolbar.btn-run-gear {
|
||||||
background: var(--accent-d);
|
background: var(--accent-d);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
|||||||
@@ -2214,7 +2214,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
|
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
|
||||||
|
|
||||||
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/shell) + a LIVE pill.
|
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/deepseek/shell) + a LIVE pill.
|
||||||
const badgeRow = document.createElement('div');
|
const badgeRow = document.createElement('div');
|
||||||
badgeRow.className = 'history-item-badges';
|
badgeRow.className = 'history-item-badges';
|
||||||
if (s.mode) {
|
if (s.mode) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface ResponseViewerTranscriptBlock {
|
|||||||
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
|
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
|
||||||
// would drag node-pty and the whole session layer into this pure module, so the
|
// would drag node-pty and the whole session layer into this pure module, so the
|
||||||
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
|
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
|
||||||
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok']);
|
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']);
|
||||||
|
|
||||||
function isPromptLine(line: string): boolean {
|
function isPromptLine(line: string): boolean {
|
||||||
return /^\s*›\s*/.test(line);
|
return /^\s*›\s*/.test(line);
|
||||||
|
|||||||
@@ -24,8 +24,17 @@ const APPROVAL_KIND_BY_EVENT: Record<string, ApprovalKind> = {
|
|||||||
idle_prompt: 'idle',
|
idle_prompt: 'idle',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Hook events that close a session's pending item without an inbox answer. */
|
/**
|
||||||
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response']);
|
* Hook events that close a session's pending item without an inbox answer.
|
||||||
|
*
|
||||||
|
* `agent_working` is here because it is the DeepSeek status bridge's report that
|
||||||
|
* a turn STARTED, and a harness turn cannot be running while one of its own
|
||||||
|
* modal approvals is on screen — so the agent moving means the dialog was
|
||||||
|
* answered, in the terminal, by the user. That is the same conclusion the claude
|
||||||
|
* path reaches through pane capture, which cannot help here because its frame
|
||||||
|
* parser is Claude-dialog-shaped.
|
||||||
|
*/
|
||||||
|
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response', 'agent_working']);
|
||||||
|
|
||||||
export function registerHookEventRoutes(
|
export function registerHookEventRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type AntigravityConfig,
|
type AntigravityConfig,
|
||||||
type PiConfig,
|
type PiConfig,
|
||||||
type GrokConfig,
|
type GrokConfig,
|
||||||
|
type DeepSeekConfig,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
@@ -337,6 +338,14 @@ export function _resetPasteRateBuckets(): void {
|
|||||||
* Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove`
|
* Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove`
|
||||||
* (`--always-approve`), and an ABSENT config already spawns in grok's own
|
* (`--always-approve`), and an ABSENT config already spawns in grok's own
|
||||||
* ask-mode default, so only a sent config needs the flag forced off.
|
* ask-mode default, so only a sent config needs the flag forced off.
|
||||||
|
*
|
||||||
|
* DeepSeek joins the same only-if-sent branch, but its switch is not a flag: the
|
||||||
|
* harness has no command-line permission option, and its sandbox/approval rows
|
||||||
|
* read `DSH_PERMISSION_MODE`. Omitting that export leaves the harness on its own
|
||||||
|
* `workspace-write` preset, which still asks, so an absent config is already
|
||||||
|
* safe; a sent one is forced down to `workspace-write` rather than to
|
||||||
|
* `read-only`, because the clamp exists to remove PRIVILEGE, not to break a
|
||||||
|
* session's ability to edit its own workspace.
|
||||||
*/
|
*/
|
||||||
async function clampExternalCliBypassForOwner(
|
async function clampExternalCliBypassForOwner(
|
||||||
owner: string | undefined,
|
owner: string | undefined,
|
||||||
@@ -344,16 +353,18 @@ async function clampExternalCliBypassForOwner(
|
|||||||
geminiConfig: GeminiConfig | undefined,
|
geminiConfig: GeminiConfig | undefined,
|
||||||
antigravityConfig: AntigravityConfig | undefined,
|
antigravityConfig: AntigravityConfig | undefined,
|
||||||
piConfig: PiConfig | undefined,
|
piConfig: PiConfig | undefined,
|
||||||
grokConfig: GrokConfig | undefined
|
grokConfig: GrokConfig | undefined,
|
||||||
|
deepSeekConfig: DeepSeekConfig | undefined
|
||||||
): Promise<{
|
): Promise<{
|
||||||
codexConfig: CodexConfig | undefined;
|
codexConfig: CodexConfig | undefined;
|
||||||
geminiConfig: GeminiConfig | undefined;
|
geminiConfig: GeminiConfig | undefined;
|
||||||
antigravityConfig: AntigravityConfig | undefined;
|
antigravityConfig: AntigravityConfig | undefined;
|
||||||
piConfig: PiConfig | undefined;
|
piConfig: PiConfig | undefined;
|
||||||
grokConfig: GrokConfig | undefined;
|
grokConfig: GrokConfig | undefined;
|
||||||
|
deepSeekConfig: DeepSeekConfig | undefined;
|
||||||
}> {
|
}> {
|
||||||
const granted = await canUsernameRunPrivilegedCommands(owner);
|
const granted = await canUsernameRunPrivilegedCommands(owner);
|
||||||
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig };
|
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig };
|
||||||
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
|
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
|
||||||
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
|
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
|
||||||
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
|
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
|
||||||
@@ -364,18 +375,63 @@ async function clampExternalCliBypassForOwner(
|
|||||||
: antigravityConfig;
|
: antigravityConfig;
|
||||||
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
|
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
|
||||||
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
|
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
|
||||||
|
const clampedDeepSeek = deepSeekConfig
|
||||||
|
? { ...deepSeekConfig, permissionMode: 'workspace-write' as const }
|
||||||
|
: deepSeekConfig;
|
||||||
return {
|
return {
|
||||||
codexConfig: clampedCodex,
|
codexConfig: clampedCodex,
|
||||||
geminiConfig: clampedGemini,
|
geminiConfig: clampedGemini,
|
||||||
antigravityConfig: clampedAntigravity,
|
antigravityConfig: clampedAntigravity,
|
||||||
piConfig: clampedPi,
|
piConfig: clampedPi,
|
||||||
grokConfig: clampedGrok,
|
grokConfig: clampedGrok,
|
||||||
|
deepSeekConfig: clampedDeepSeek,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
|
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
|
||||||
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a DeepSeek session cannot start, or null when it can.
|
||||||
|
*
|
||||||
|
* Availability for this mode is TWO questions, not one, because `dsh` is a
|
||||||
|
* profile launcher rather than an agent: the binary must resolve (and prove it
|
||||||
|
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
|
||||||
|
* a pane must exist. Reporting only the first would let the Run button spawn a
|
||||||
|
* pane that dies instantly, which is the single most confusing failure this mode
|
||||||
|
* can produce, so each half gets its own actionable message.
|
||||||
|
*
|
||||||
|
* A profile named EXPLICITLY is checked on both counts: existence, and whether
|
||||||
|
* it is pane-capable — `web` serves a browser UI and `headless` answers one task
|
||||||
|
* and exits, so both would present as "the tab immediately died".
|
||||||
|
*/
|
||||||
|
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
|
||||||
|
const { isDeepSeekAvailable, getDeepSeekNotFoundMessage, listDeepSeekProfiles, resolveDefaultDeepSeekProfile } =
|
||||||
|
await import('../../utils/deepseek-cli-resolver.js');
|
||||||
|
if (!isDeepSeekAvailable()) return getDeepSeekNotFoundMessage();
|
||||||
|
|
||||||
|
const profiles = listDeepSeekProfiles();
|
||||||
|
if (requestedProfile) {
|
||||||
|
const match = profiles.find((p) => p.name === requestedProfile);
|
||||||
|
if (!match) {
|
||||||
|
return `DeepSeek Harness profile "${requestedProfile}" does not exist. Create it with: dsh plugin --profile ${requestedProfile} add <package>`;
|
||||||
|
}
|
||||||
|
if (match.kind === 'web' || match.kind === 'headless') {
|
||||||
|
return `DeepSeek Harness profile "${requestedProfile}" is a ${match.kind} profile and cannot run in a terminal session. Pick an interactive profile, or open the web profile as a Codeman web tab.`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolveDefaultDeepSeekProfile()) {
|
||||||
|
return (
|
||||||
|
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only the web and headless ' +
|
||||||
|
'profiles, so the terminal agent comes from a plugin — install one with: ' +
|
||||||
|
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
|
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -770,6 +826,7 @@ export function registerSessionRoutes(
|
|||||||
body.mode !== 'antigravity' &&
|
body.mode !== 'antigravity' &&
|
||||||
body.mode !== 'pi' &&
|
body.mode !== 'pi' &&
|
||||||
body.mode !== 'grok' &&
|
body.mode !== 'grok' &&
|
||||||
|
body.mode !== 'deepseek' &&
|
||||||
body.envOverrides &&
|
body.envOverrides &&
|
||||||
Object.keys(body.envOverrides).length > 0 &&
|
Object.keys(body.envOverrides).length > 0 &&
|
||||||
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||||||
@@ -859,6 +916,10 @@ export function registerSessionRoutes(
|
|||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (body.mode === 'deepseek') {
|
||||||
|
const err = await resolveDeepSeekLaunchError(body.deepSeekConfig?.profile);
|
||||||
|
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
|
||||||
|
}
|
||||||
if (body.mode === 'grok') {
|
if (body.mode === 'grok') {
|
||||||
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
|
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
|
||||||
if (!isGrokAvailable()) {
|
if (!isGrokAvailable()) {
|
||||||
@@ -912,7 +973,10 @@ export function registerSessionRoutes(
|
|||||||
? body.piConfig?.model
|
? body.piConfig?.model
|
||||||
: mode === 'grok'
|
: mode === 'grok'
|
||||||
? body.grokConfig?.model
|
? body.grokConfig?.model
|
||||||
: mode !== 'shell'
|
: // DeepSeek's model is a composition entry in the profile's config
|
||||||
|
// tree, not a session flag, so there is deliberately nothing to
|
||||||
|
// read here (see docs/deepseek-integration.md).
|
||||||
|
mode !== 'shell' && mode !== 'deepseek'
|
||||||
? modelConfig?.defaultModel || undefined
|
? modelConfig?.defaultModel || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
@@ -925,13 +989,15 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig: gatedAntigravityConfig,
|
antigravityConfig: gatedAntigravityConfig,
|
||||||
piConfig: gatedPiConfig,
|
piConfig: gatedPiConfig,
|
||||||
grokConfig: gatedGrokConfig,
|
grokConfig: gatedGrokConfig,
|
||||||
|
deepSeekConfig: gatedDeepSeekConfig,
|
||||||
} = await clampExternalCliBypassForOwner(
|
} = await clampExternalCliBypassForOwner(
|
||||||
owner,
|
owner,
|
||||||
body.codexConfig,
|
body.codexConfig,
|
||||||
body.geminiConfig,
|
body.geminiConfig,
|
||||||
body.antigravityConfig,
|
body.antigravityConfig,
|
||||||
body.piConfig,
|
body.piConfig,
|
||||||
body.grokConfig
|
body.grokConfig,
|
||||||
|
body.deepSeekConfig
|
||||||
);
|
);
|
||||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
@@ -950,6 +1016,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
||||||
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
||||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||||
|
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
|
||||||
resumeSessionId: validatedResumeId,
|
resumeSessionId: validatedResumeId,
|
||||||
envOverrides: body.envOverrides,
|
envOverrides: body.envOverrides,
|
||||||
effort: body.effort,
|
effort: body.effort,
|
||||||
@@ -2717,6 +2784,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
deepSeekConfig,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
parentSessionId,
|
parentSessionId,
|
||||||
@@ -2766,6 +2834,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig ||
|
antigravityConfig ||
|
||||||
piConfig ||
|
piConfig ||
|
||||||
grokConfig ||
|
grokConfig ||
|
||||||
|
deepSeekConfig ||
|
||||||
openCodeConfig
|
openCodeConfig
|
||||||
) {
|
) {
|
||||||
return createErrorResponse(
|
return createErrorResponse(
|
||||||
@@ -2909,6 +2978,12 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check DeepSeek Harness availability if requested (binary AND a pane-capable profile).
|
||||||
|
if (mode === 'deepseek') {
|
||||||
|
const err = await resolveDeepSeekLaunchError(deepSeekConfig?.profile);
|
||||||
|
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||||
// external project directories are honoured by quick-start just like regular case routes.
|
// external project directories are honoured by quick-start just like regular case routes.
|
||||||
@@ -3036,6 +3111,7 @@ export function registerSessionRoutes(
|
|||||||
mode !== 'antigravity' &&
|
mode !== 'antigravity' &&
|
||||||
mode !== 'pi' &&
|
mode !== 'pi' &&
|
||||||
mode !== 'grok' &&
|
mode !== 'grok' &&
|
||||||
|
mode !== 'deepseek' &&
|
||||||
!remote &&
|
!remote &&
|
||||||
envOverrides &&
|
envOverrides &&
|
||||||
Object.keys(envOverrides).length > 0
|
Object.keys(envOverrides).length > 0
|
||||||
@@ -3060,7 +3136,8 @@ export function registerSessionRoutes(
|
|||||||
? piConfig?.model
|
? piConfig?.model
|
||||||
: mode === 'grok'
|
: mode === 'grok'
|
||||||
? grokConfig?.model
|
? grokConfig?.model
|
||||||
: mode !== 'shell'
|
: // DeepSeek's model lives in the profile's config tree, not here.
|
||||||
|
mode !== 'shell' && mode !== 'deepseek'
|
||||||
? qsModelConfig?.defaultModel || undefined
|
? qsModelConfig?.defaultModel || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||||
@@ -3072,7 +3149,16 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig: qsGatedAntigravityConfig,
|
antigravityConfig: qsGatedAntigravityConfig,
|
||||||
piConfig: qsGatedPiConfig,
|
piConfig: qsGatedPiConfig,
|
||||||
grokConfig: qsGatedGrokConfig,
|
grokConfig: qsGatedGrokConfig,
|
||||||
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig);
|
deepSeekConfig: qsGatedDeepSeekConfig,
|
||||||
|
} = await clampExternalCliBypassForOwner(
|
||||||
|
owner,
|
||||||
|
codexConfig,
|
||||||
|
geminiConfig,
|
||||||
|
antigravityConfig,
|
||||||
|
piConfig,
|
||||||
|
grokConfig,
|
||||||
|
deepSeekConfig
|
||||||
|
);
|
||||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: resolvedCasePath,
|
workingDir: resolvedCasePath,
|
||||||
@@ -3091,6 +3177,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
||||||
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
||||||
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
||||||
|
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
remote,
|
remote,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { dataPath } from '../../config/instance.js';
|
|||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||||
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
|
||||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||||
import { findUser } from '../../user-store.js';
|
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
|
||||||
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
|
||||||
import {
|
import {
|
||||||
ConfigUpdateSchema,
|
ConfigUpdateSchema,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
SubagentWindowStatesSchema,
|
SubagentWindowStatesSchema,
|
||||||
SubagentParentMapSchema,
|
SubagentParentMapSchema,
|
||||||
RevokeSessionSchema,
|
RevokeSessionSchema,
|
||||||
|
DeepSeekInstallProfileSchema,
|
||||||
} from '../schemas.js';
|
} from '../schemas.js';
|
||||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||||
import { imageWatcher } from '../../image-watcher.js';
|
import { imageWatcher } from '../../image-watcher.js';
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
} from '../route-helpers.js';
|
} from '../route-helpers.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
||||||
|
|
||||||
import { getRepositoryStatus } from '../repo-status.js';
|
import { getRepositoryStatus } from '../repo-status.js';
|
||||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
|
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
|
||||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||||
@@ -55,6 +57,20 @@ import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
|||||||
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
||||||
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
|
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defaults for `POST /api/deepseek/install-profile`.
|
||||||
|
*
|
||||||
|
* The package is the community terminal front door with by far the widest use
|
||||||
|
* (~27.5k weekly downloads at time of writing, roughly 4x the next), MIT, and
|
||||||
|
* the one whose supervisor-reporting contract Codeman's status bridge speaks.
|
||||||
|
* It is a DEFAULT, not a hardcoding: the endpoint accepts any npm name, and the
|
||||||
|
* resolver never assumes this profile exists.
|
||||||
|
*/
|
||||||
|
const DEEPSEEK_DEFAULT_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
|
||||||
|
const DEEPSEEK_DEFAULT_PROFILE = 'dsh-tui';
|
||||||
|
/** A plugin install compiles and links a dependency tree; npm-scale, not curl-scale. */
|
||||||
|
const DEEPSEEK_INSTALL_TIMEOUT_MS = 300_000;
|
||||||
|
|
||||||
// Maximum screenshot upload size (10MB)
|
// Maximum screenshot upload size (10MB)
|
||||||
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
||||||
// Screenshots directory
|
// Screenshots directory
|
||||||
@@ -461,6 +477,111 @@ export function registerSystemRoutes(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== DeepSeek Harness ==========
|
||||||
|
|
||||||
|
// The widest of the per-CLI status shapes, because this mode has the widest
|
||||||
|
// failure surface. Three fields beyond the sibling `available`/`path`:
|
||||||
|
//
|
||||||
|
// - `version`, like pi/grok, so a misresolution is diagnosable — and here the
|
||||||
|
// stakes are higher, since `dsh` is also an existing Debian program
|
||||||
|
// (dancer's shell) rather than merely a squattable npm name.
|
||||||
|
// - `profiles`, because `dsh` is a LAUNCHER: a perfectly installed binary with
|
||||||
|
// no pane-capable profile cannot start a session, and the UI has to be able
|
||||||
|
// to say which of the two halves is missing.
|
||||||
|
// - `runnable` + `defaultProfile`, the answer the Run button actually needs,
|
||||||
|
// so no caller has to re-derive it from the parts and get it subtly wrong.
|
||||||
|
app.get('/api/deepseek/status', async () => {
|
||||||
|
const {
|
||||||
|
isDeepSeekAvailable,
|
||||||
|
isDeepSeekRunnable,
|
||||||
|
resolveDeepSeekDir,
|
||||||
|
getDeepSeekCliVersion,
|
||||||
|
listDeepSeekProfiles,
|
||||||
|
resolveDefaultDeepSeekProfile,
|
||||||
|
resolveDshHome,
|
||||||
|
} = await import('../../utils/deepseek-cli-resolver.js');
|
||||||
|
return {
|
||||||
|
available: isDeepSeekAvailable(),
|
||||||
|
runnable: isDeepSeekRunnable(),
|
||||||
|
path: resolveDeepSeekDir(),
|
||||||
|
version: getDeepSeekCliVersion(),
|
||||||
|
dshHome: resolveDshHome(),
|
||||||
|
defaultProfile: resolveDefaultDeepSeekProfile(),
|
||||||
|
profiles: listDeepSeekProfiles(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bootstrap an interactive profile so the mode becomes usable.
|
||||||
|
//
|
||||||
|
// This exists because DeepSeek ships NO terminal front door: `dsh` on its own
|
||||||
|
// can only serve a browser UI or answer one headless task, and the agent a
|
||||||
|
// Codeman pane runs is always a plugin the user installed. Without this the
|
||||||
|
// mode's first-run experience is a dead Run button and a paragraph of shell
|
||||||
|
// instructions.
|
||||||
|
//
|
||||||
|
// It is the only endpoint in Codeman that installs third-party code, so it is
|
||||||
|
// fenced accordingly:
|
||||||
|
// - the privileged grant is required in multi-user mode (same bar as a
|
||||||
|
// `shell` session, which can already do strictly more);
|
||||||
|
// - the specifier is regex-confined to an npm name at the schema boundary —
|
||||||
|
// no path, URL, git spec, or leading dash;
|
||||||
|
// - the spawn is an argv ARRAY through the resolved `dsh`, never a shell
|
||||||
|
// string, so even a specifier that slipped the regex could not become a
|
||||||
|
// second command;
|
||||||
|
// - the request is held open with a bounded timeout, mirroring the
|
||||||
|
// synchronous-clone precedent in `POST /api/cases/clone` rather than
|
||||||
|
// introducing a job store for a once-per-install action.
|
||||||
|
app.post('/api/deepseek/install-profile', async (req) => {
|
||||||
|
const body = parseBody(DeepSeekInstallProfileSchema, req.body);
|
||||||
|
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.FORBIDDEN,
|
||||||
|
'Installing a DeepSeek Harness profile requires the can-bypass-permissions grant'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resolveDeepSeekDir, getDeepSeekNotFoundMessage } = await import('../../utils/deepseek-cli-resolver.js');
|
||||||
|
const dir = resolveDeepSeekDir();
|
||||||
|
if (!dir) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getDeepSeekNotFoundMessage());
|
||||||
|
|
||||||
|
const profile = body.profile || DEEPSEEK_DEFAULT_PROFILE;
|
||||||
|
const pkg = body.package || DEEPSEEK_DEFAULT_TUI_PACKAGE;
|
||||||
|
const result = await new Promise<{ code: number | null; output: string }>((resolve) => {
|
||||||
|
const child = spawn(join(dir, 'dsh'), ['plugin', '--profile', profile, 'add', pkg], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: DEEPSEEK_INSTALL_TIMEOUT_MS,
|
||||||
|
// dsh bundles its own package manager, so no system pnpm is required —
|
||||||
|
// but it still needs a HOME to resolve $DSH_HOME against.
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
let output = '';
|
||||||
|
const capture = (chunk: Buffer) => {
|
||||||
|
// Bounded: a package manager can emit megabytes of progress.
|
||||||
|
if (output.length < 16_384) output += chunk.toString('utf-8');
|
||||||
|
};
|
||||||
|
child.stdout?.on('data', capture);
|
||||||
|
child.stderr?.on('data', capture);
|
||||||
|
child.on('error', (err) => resolve({ code: null, output: `${output}\n${err.message}` }));
|
||||||
|
child.on('close', (code) => resolve({ code, output }));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
return createErrorResponse(
|
||||||
|
ApiErrorCode.OPERATION_FAILED,
|
||||||
|
`Installing ${pkg} into profile "${profile}" failed: ${result.output.slice(-1000).trim() || 'no output'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } =
|
||||||
|
await import('../../utils/deepseek-cli-resolver.js');
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
package: pkg,
|
||||||
|
runnable: isDeepSeekRunnable(),
|
||||||
|
defaultProfile: resolveDefaultDeepSeekProfile(),
|
||||||
|
profiles: listDeepSeekProfiles(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+83
-4
@@ -132,6 +132,15 @@ const ALLOWED_ENV_PREFIXES = [
|
|||||||
'PI_',
|
'PI_',
|
||||||
'GROK_',
|
'GROK_',
|
||||||
'XAI_',
|
'XAI_',
|
||||||
|
// DeepSeek Harness: `DSH_*` carries the launcher's own documented inputs
|
||||||
|
// (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, and the DSH_TUI_* knobs
|
||||||
|
// the terminal front door reads); `DEEPSEEK_*` is the vendor namespace holding
|
||||||
|
// DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL, the same narrow-vendor reasoning that
|
||||||
|
// admitted XAI_* for grok. Foreign provider keys stay out: a dsh settings.yaml
|
||||||
|
// can name ANY env var as a provider credential (apiKeyEnv), which is pi's
|
||||||
|
// 34-provider-key problem in a new shape, and the answer is the same one.
|
||||||
|
'DSH_',
|
||||||
|
'DEEPSEEK_',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,7 +180,7 @@ const safeEnvOverridesSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -336,6 +345,68 @@ const GrokConfigSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for DeepSeek Harness (`dsh`)-specific configuration.
|
||||||
|
*
|
||||||
|
* `permissionMode` maps to the `DSH_PERMISSION_MODE` env export, NOT to a flag —
|
||||||
|
* the harness has no command-line permission switch. An ABSENT config spawns the
|
||||||
|
* profile under the harness's own `workspace-write` default, which still asks
|
||||||
|
* for approval, so the multi-user clamp only needs the only-if-sent branch (like
|
||||||
|
* codex/antigravity/grok).
|
||||||
|
*
|
||||||
|
* `profile` is a directory name under `$DSH_HOME/profiles`, so it is constrained
|
||||||
|
* to a single path SEGMENT: no separators, no dots-only names. It is interpolated
|
||||||
|
* into the `bash -c "…"` spawn line and joined into a filesystem path, and this
|
||||||
|
* regex is what keeps both safe.
|
||||||
|
*/
|
||||||
|
const DeepSeekConfigSchema = z
|
||||||
|
.object({
|
||||||
|
profile: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
|
||||||
|
.optional(),
|
||||||
|
permissionMode: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
|
||||||
|
resumeSession: z.boolean().optional(),
|
||||||
|
resumeSessionId: z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9._-]+$/)
|
||||||
|
.optional(),
|
||||||
|
statusReporting: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of POST /api/deepseek/install-profile.
|
||||||
|
*
|
||||||
|
* `package` is a package SPECIFIER handed to `dsh plugin … add`, which runs a
|
||||||
|
* real package-manager install, so it is the security-relevant field. Two things
|
||||||
|
* contain it: this regex (an npm name, optionally scoped, optionally with an
|
||||||
|
* `@version` tail, and NOTHING else — no path, no URL, no git spec, no leading
|
||||||
|
* dash that could be read as a flag), and the route, which spawns an argv ARRAY
|
||||||
|
* with no shell. The route additionally requires the privileged grant in
|
||||||
|
* multi-user mode: installing a plugin is arbitrary code execution on the host,
|
||||||
|
* the same bar as a `shell` session.
|
||||||
|
*/
|
||||||
|
export const DeepSeekInstallProfileSchema = z
|
||||||
|
.object({
|
||||||
|
profile: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
|
||||||
|
.optional(),
|
||||||
|
package: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(214)
|
||||||
|
.regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[a-zA-Z0-9][a-zA-Z0-9._-]*)?$/)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The session that spawned the one being created — pure UI decoration, drawn as a
|
* The session that spawned the one being created — pure UI decoration, drawn as a
|
||||||
* lineage line between the two tabs. Accepted here and, equivalently, as the
|
* lineage line between the two tabs. Accepted here and, equivalently, as the
|
||||||
@@ -349,7 +420,7 @@ const parentSessionIdSchema = z.string().max(100).optional();
|
|||||||
|
|
||||||
export const CreateSessionSchema = z.object({
|
export const CreateSessionSchema = z.object({
|
||||||
workingDir: safePathSchema.optional(),
|
workingDir: safePathSchema.optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
|
||||||
name: z.string().max(100).optional(),
|
name: z.string().max(100).optional(),
|
||||||
/** Session that spawned this one — see parentSessionIdSchema. */
|
/** Session that spawned this one — see parentSessionIdSchema. */
|
||||||
parentSessionId: parentSessionIdSchema,
|
parentSessionId: parentSessionIdSchema,
|
||||||
@@ -366,6 +437,7 @@ export const CreateSessionSchema = z.object({
|
|||||||
antigravityConfig: AntigravityConfigSchema,
|
antigravityConfig: AntigravityConfigSchema,
|
||||||
piConfig: PiConfigSchema,
|
piConfig: PiConfigSchema,
|
||||||
grokConfig: GrokConfigSchema,
|
grokConfig: GrokConfigSchema,
|
||||||
|
deepSeekConfig: DeepSeekConfigSchema,
|
||||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||||
resumeSessionId: z
|
resumeSessionId: z
|
||||||
.string()
|
.string()
|
||||||
@@ -502,6 +574,7 @@ const RemoteCommandOverridesSchema = z
|
|||||||
antigravity: z.string().min(1).max(300).optional(),
|
antigravity: z.string().min(1).max(300).optional(),
|
||||||
pi: z.string().min(1).max(300).optional(),
|
pi: z.string().min(1).max(300).optional(),
|
||||||
grok: z.string().min(1).max(300).optional(),
|
grok: z.string().min(1).max(300).optional(),
|
||||||
|
deepseek: z.string().min(1).max(300).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
@@ -776,13 +849,14 @@ export const QuickStartSchema = z.object({
|
|||||||
* a real host dir, so the settings file crosses the bind mount); rejected for
|
* a real host dir, so the settings file crosses the bind mount); rejected for
|
||||||
* remote cases (the file would be written on the WRONG machine). */
|
* remote cases (the file would be written on the WRONG machine). */
|
||||||
modelOverride: z.string().max(50).optional(),
|
modelOverride: z.string().max(50).optional(),
|
||||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(),
|
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
|
||||||
openCodeConfig: OpenCodeConfigSchema,
|
openCodeConfig: OpenCodeConfigSchema,
|
||||||
codexConfig: CodexConfigSchema,
|
codexConfig: CodexConfigSchema,
|
||||||
geminiConfig: GeminiConfigSchema,
|
geminiConfig: GeminiConfigSchema,
|
||||||
antigravityConfig: AntigravityConfigSchema,
|
antigravityConfig: AntigravityConfigSchema,
|
||||||
piConfig: PiConfigSchema,
|
piConfig: PiConfigSchema,
|
||||||
grokConfig: GrokConfigSchema,
|
grokConfig: GrokConfigSchema,
|
||||||
|
deepSeekConfig: DeepSeekConfigSchema,
|
||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||||
effort: effortLevelSchema,
|
effort: effortLevelSchema,
|
||||||
@@ -804,6 +878,11 @@ export const HookEventSchema = z.object({
|
|||||||
'stop',
|
'stop',
|
||||||
'teammate_idle',
|
'teammate_idle',
|
||||||
'task_completed',
|
'task_completed',
|
||||||
|
// A turn STARTED. Unlike the others this one has no Claude Code hook behind
|
||||||
|
// it: it is reported by the DeepSeek Harness status shim, and exists so a
|
||||||
|
// dialog answered in the terminal resolves its Approvals Inbox item at once
|
||||||
|
// instead of lingering red until the next `stop`.
|
||||||
|
'agent_working',
|
||||||
]),
|
]),
|
||||||
sessionId: z.string().min(1),
|
sessionId: z.string().min(1),
|
||||||
data: z.record(z.string(), z.unknown()).nullable().optional(),
|
data: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
@@ -1310,7 +1389,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
|
|||||||
/** Shared field shape for creating/updating a scheduled job. */
|
/** Shared field shape for creating/updating a scheduled job. */
|
||||||
const CronJobBaseSchema = z.object({
|
const CronJobBaseSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']),
|
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']),
|
||||||
workingDir: safePathSchema,
|
workingDir: safePathSchema,
|
||||||
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
|
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
|
||||||
promptMode: z.enum(['inline_text', 'prompt_file_path']),
|
promptMode: z.enum(['inline_text', 'prompt_file_path']),
|
||||||
|
|||||||
@@ -1436,6 +1436,7 @@ export class WebServer extends EventEmitter {
|
|||||||
{ isAntigravityAvailable },
|
{ isAntigravityAvailable },
|
||||||
{ isPiAvailable },
|
{ isPiAvailable },
|
||||||
{ isGrokAvailable },
|
{ isGrokAvailable },
|
||||||
|
{ isDeepSeekRunnable, isDeepSeekAvailable },
|
||||||
{ isCloudflaredAvailable },
|
{ isCloudflaredAvailable },
|
||||||
{ isGitAvailable },
|
{ isGitAvailable },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
@@ -1446,6 +1447,7 @@ export class WebServer extends EventEmitter {
|
|||||||
import('../utils/antigravity-cli-resolver.js'),
|
import('../utils/antigravity-cli-resolver.js'),
|
||||||
import('../utils/pi-cli-resolver.js'),
|
import('../utils/pi-cli-resolver.js'),
|
||||||
import('../utils/grok-cli-resolver.js'),
|
import('../utils/grok-cli-resolver.js'),
|
||||||
|
import('../utils/deepseek-cli-resolver.js'),
|
||||||
import('../utils/cloudflared-resolver.js'),
|
import('../utils/cloudflared-resolver.js'),
|
||||||
import('../git-clone.js'),
|
import('../git-clone.js'),
|
||||||
]);
|
]);
|
||||||
@@ -1457,6 +1459,13 @@ export class WebServer extends EventEmitter {
|
|||||||
antigravity: isAntigravityAvailable(),
|
antigravity: isAntigravityAvailable(),
|
||||||
pi: isPiAvailable(),
|
pi: isPiAvailable(),
|
||||||
grok: isGrokAvailable(),
|
grok: isGrokAvailable(),
|
||||||
|
// RUNNABLE, not merely installed: `dsh` is a profile launcher, and a dsh
|
||||||
|
// with no pane-capable profile would offer a Run button that spawns a
|
||||||
|
// pane which dies on arrival. The Add-Profile affordance in the run menu
|
||||||
|
// keys off `deepseekBinary` instead, so a user who has the binary but no
|
||||||
|
// profile is offered the fix rather than a greyed-out entry.
|
||||||
|
deepseek: isDeepSeekRunnable(),
|
||||||
|
deepseekBinary: isDeepSeekAvailable(),
|
||||||
cloudflared: isCloudflaredAvailable(),
|
cloudflared: isCloudflaredAvailable(),
|
||||||
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
|
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
|
||||||
// keep without git (issue #236), same reasoning as cloudflared above.
|
// keep without git (issue #236), same reasoning as cloudflared above.
|
||||||
@@ -2738,6 +2747,7 @@ export class WebServer extends EventEmitter {
|
|||||||
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
|
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
|
||||||
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
|
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
|
||||||
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
|
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
|
||||||
|
deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined,
|
||||||
envOverrides: savedEnvOverrides,
|
envOverrides: savedEnvOverrides,
|
||||||
effort: savedState?.effort,
|
effort: savedState?.effort,
|
||||||
attachmentHistory: savedAttachmentHistory,
|
attachmentHistory: savedAttachmentHistory,
|
||||||
|
|||||||
@@ -182,7 +182,13 @@ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
|
|||||||
* infinite-wait-dressed-as-a-timeout this guard exists to prevent.
|
* infinite-wait-dressed-as-a-timeout this guard exists to prevent.
|
||||||
*/
|
*/
|
||||||
export function hooksAvailableForMode(mode: SessionMode): boolean {
|
export function hooksAvailableForMode(mode: SessionMode): boolean {
|
||||||
return mode === 'claude';
|
// `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE
|
||||||
|
// signals rather than having them inferred. The DeepSeek Harness terminal
|
||||||
|
// front door reports idle/working/blocked to its supervisor, and Codeman is
|
||||||
|
// that supervisor (see deepseek-status-shim.ts), so a dsh session really can
|
||||||
|
// deliver `stop` and `blocked`. Every other mode is output-stabilization
|
||||||
|
// guesswork and must keep failing the ask.
|
||||||
|
return mode === 'claude' || mode === 'deepseek';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
|
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
|
||||||
|
|||||||
+11
-2
@@ -5,7 +5,7 @@
|
|||||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||||
* Both files MUST be kept in sync.
|
* Both files MUST be kept in sync.
|
||||||
*
|
*
|
||||||
* 156 event constants organized by category:
|
* 157 event constants organized by category:
|
||||||
* - **Core** (1): init
|
* - **Core** (1): init
|
||||||
* - **Transport** (1): sse:heartbeat
|
* - **Transport** (1): sse:heartbeat
|
||||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
|
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
|
||||||
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
||||||
* - **Image / attachments** (2): image:detected, attachment:detected
|
* - **Image / attachments** (2): image:detected, attachment:detected
|
||||||
* - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed
|
* - **Hooks** (9): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed
|
||||||
|
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
|
||||||
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
|
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
|
||||||
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
||||||
* - **Clipboard** (1): write
|
* - **Clipboard** (1): write
|
||||||
@@ -360,6 +361,13 @@ export const HookElicitationComplete = 'hook:elicitation_complete' as const;
|
|||||||
export const HookElicitationResponse = 'hook:elicitation_response' as const;
|
export const HookElicitationResponse = 'hook:elicitation_response' as const;
|
||||||
/** Claude Code hook: response complete. */
|
/** Claude Code hook: response complete. */
|
||||||
export const HookStop = 'hook:stop' as const;
|
export const HookStop = 'hook:stop' as const;
|
||||||
|
/**
|
||||||
|
* Agent started a turn. NOT a Claude Code hook: this one is reported by the
|
||||||
|
* DeepSeek Harness status bridge, which is why the name is agent-generic. It
|
||||||
|
* exists so a dialog answered in the terminal clears its alert immediately
|
||||||
|
* instead of waiting for the turn to end.
|
||||||
|
*/
|
||||||
|
export const HookAgentWorking = 'hook:agent_working' as const;
|
||||||
/** Claude Code hook: teammate went idle. */
|
/** Claude Code hook: teammate went idle. */
|
||||||
export const HookTeammateIdle = 'hook:teammate_idle' as const;
|
export const HookTeammateIdle = 'hook:teammate_idle' as const;
|
||||||
/** Claude Code hook: teammate task completed. */
|
/** Claude Code hook: teammate task completed. */
|
||||||
@@ -619,6 +627,7 @@ export const SseEvent = {
|
|||||||
HookElicitationComplete,
|
HookElicitationComplete,
|
||||||
HookElicitationResponse,
|
HookElicitationResponse,
|
||||||
HookStop,
|
HookStop,
|
||||||
|
HookAgentWorking,
|
||||||
HookTeammateIdle,
|
HookTeammateIdle,
|
||||||
HookTaskCompleted,
|
HookTaskCompleted,
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the DeepSeek Harness (`dsh`) resolver and profile inventory.
|
||||||
|
*
|
||||||
|
* `dsh` needs the strictest identity probe of any CLI Codeman resolves. pi and
|
||||||
|
* grok are short names with npm squatters; `dsh` is worse — it is an EXISTING,
|
||||||
|
* widely packaged Unix program (Debian's dancer's shell, `apt install dsh`),
|
||||||
|
* which would sail through a version-token probe and then be handed a spawn
|
||||||
|
* line. So the resolver demands the harness's own help banner first, and the
|
||||||
|
* headline test below is the one that pins that rejection.
|
||||||
|
*
|
||||||
|
* The second half covers something no sibling resolver has: a profile
|
||||||
|
* inventory. `dsh` is a launcher, so "is it installed" and "can it run a
|
||||||
|
* session" are different questions, and the availability gate needs both.
|
||||||
|
*/
|
||||||
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
createDeepSeekResolverForTest,
|
||||||
|
DEEPSEEK_VERSION_REGEX,
|
||||||
|
DEEPSEEK_IDENTITY_REGEX,
|
||||||
|
listDeepSeekProfiles,
|
||||||
|
resolveDefaultDeepSeekProfile,
|
||||||
|
isLaunchableProfile,
|
||||||
|
resolveDshHome,
|
||||||
|
} from '../src/utils/deepseek-cli-resolver.js';
|
||||||
|
import {
|
||||||
|
cliResolveRetryDelayMs,
|
||||||
|
createProductionCliResolverHost,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from '../src/utils/cli-executable-resolver.js';
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createHost(
|
||||||
|
options: {
|
||||||
|
processPathResult?: string | null;
|
||||||
|
loginShellResults?: Array<string | null>;
|
||||||
|
existingPaths?: string[];
|
||||||
|
} = {}
|
||||||
|
): CliResolverHost {
|
||||||
|
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||||
|
const existingPaths = new Set(options.existingPaths ?? []);
|
||||||
|
return {
|
||||||
|
processPath: '/service/bin',
|
||||||
|
shellPath: '/bin/zsh',
|
||||||
|
shellArgs: ['-l'],
|
||||||
|
findOnProcessPath: () => options.processPathResult ?? null,
|
||||||
|
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||||
|
exists: (path) => existingPaths.has(path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeepSeek CLI resolver', () => {
|
||||||
|
it('accepts a candidate the probe verifies and carries the version as metadata', () => {
|
||||||
|
const binaryPath = '/service/bin/dsh';
|
||||||
|
const probe = vi.fn(() => '0.1.1-rc.2');
|
||||||
|
const resolver = createDeepSeekResolverForTest(
|
||||||
|
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
|
||||||
|
probe
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({
|
||||||
|
binaryPath,
|
||||||
|
directory: '/service/bin',
|
||||||
|
source: 'process-path',
|
||||||
|
metadata: '0.1.1-rc.2',
|
||||||
|
});
|
||||||
|
expect(probe).toHaveBeenCalledWith(binaryPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a foreign `dsh` earlier on PATH mask the real one', () => {
|
||||||
|
// The dancer's-shell case, at resolver level: a `dsh` that is a real program
|
||||||
|
// and answers --version must still be refused, and must not stop the search.
|
||||||
|
const impostor = '/usr/bin/dsh';
|
||||||
|
const genuine = '/login-shell/bin/dsh';
|
||||||
|
const probe = vi.fn((binPath: string) => (binPath === genuine ? '0.1.1-rc.2' : null));
|
||||||
|
const resolver = createDeepSeekResolverForTest(
|
||||||
|
createHost({
|
||||||
|
processPathResult: impostor,
|
||||||
|
loginShellResults: [genuine],
|
||||||
|
existingPaths: [impostor, genuine],
|
||||||
|
}),
|
||||||
|
probe
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('negative-caches a miss and retries only after the backoff elapses', () => {
|
||||||
|
const binaryPath = '/late/bin/dsh';
|
||||||
|
let now = 0;
|
||||||
|
const probe = vi.fn(() => '0.1.1-rc.2');
|
||||||
|
const resolver = createDeepSeekResolverForTest(
|
||||||
|
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||||
|
probe,
|
||||||
|
() => now
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
now = cliResolveRetryDelayMs(1);
|
||||||
|
expect(resolver.resolve()?.metadata).toBe('0.1.1-rc.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts the version from the real output shape (a bare `0.1.1-rc.2`)', () => {
|
||||||
|
// Shared with the dependency registry (doctor), so the accepted shape is
|
||||||
|
// contract. The prerelease tail is part of the token on purpose: dropping it
|
||||||
|
// would report a release candidate as a release.
|
||||||
|
expect(DEEPSEEK_VERSION_REGEX.exec('0.1.1-rc.2')?.[1]).toBe('0.1.1-rc.2');
|
||||||
|
expect(DEEPSEEK_VERSION_REGEX.exec('dsh 1.2.3')?.[1]).toBe('1.2.3');
|
||||||
|
expect(DEEPSEEK_VERSION_REGEX.exec('not a version')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies the harness by its help banner and rejects a foreign dsh', () => {
|
||||||
|
expect(DEEPSEEK_IDENTITY_REGEX.test('dsh: boot a DeepSeek Harness profile — an ordered stack')).toBe(true);
|
||||||
|
// Debian's dancer's shell: a real program, a real version, not our agent.
|
||||||
|
expect(DEEPSEEK_IDENTITY_REGEX.test('Usage: dsh [options] [command] ...\nDistributed shell')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never executes a dsh candidate under vitest (the ambient probe is VITEST-gated)', () => {
|
||||||
|
// A REAL executable fixture that answers BOTH probes convincingly. If the
|
||||||
|
// guard in probeDeepSeekVersion is ever removed, this script runs, the
|
||||||
|
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
|
||||||
|
// behavior rather than by source text. That matters more here than for any
|
||||||
|
// sibling: `dsh` is a name real machines genuinely carry.
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-dsh-vitest-gate-'));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
const binaryPath = join(root, 'dsh');
|
||||||
|
writeFileSync(
|
||||||
|
binaryPath,
|
||||||
|
'#!/bin/sh\ncase "$1" in --help) echo "dsh: boot a DeepSeek Harness profile";; *) echo "9.9.9";; esac\n'
|
||||||
|
);
|
||||||
|
chmodSync(binaryPath, 0o755);
|
||||||
|
const hostOptions = {
|
||||||
|
processPath: root,
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'] as string[],
|
||||||
|
runCommand: () => '',
|
||||||
|
isExecutableFile: (path: string) => path === binaryPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
const gated = createDeepSeekResolverForTest(createProductionCliResolverHost(hostOptions));
|
||||||
|
expect(gated.resolve()).toBeNull();
|
||||||
|
|
||||||
|
// Control: identical setup with an injected probe resolves, proving the null
|
||||||
|
// above comes from the gate, not from the fixture or the host.
|
||||||
|
const control = createDeepSeekResolverForTest(createProductionCliResolverHost(hostOptions), () => '9.9.9');
|
||||||
|
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '9.9.9' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeepSeek profile inventory', () => {
|
||||||
|
let home: string;
|
||||||
|
const ORIGINAL_DSH_HOME = process.env.DSH_HOME;
|
||||||
|
|
||||||
|
function writeProfile(name: string, bundles: string[]): void {
|
||||||
|
const dir = join(home, 'profiles', name);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, 'package.json'),
|
||||||
|
JSON.stringify({ name: `dsh-profile-${name}`, dsh: { profile: { bundles } } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
home = mkdtempSync(join(tmpdir(), 'codeman-dsh-home-'));
|
||||||
|
temporaryDirectories.push(home);
|
||||||
|
process.env.DSH_HOME = home;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIGINAL_DSH_HOME === undefined) delete process.env.DSH_HOME;
|
||||||
|
else process.env.DSH_HOME = ORIGINAL_DSH_HOME;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours DSH_HOME over the default ~/.dsh', () => {
|
||||||
|
expect(resolveDshHome()).toBe(home);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty (not an error) when dsh has never been run', () => {
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
expect(listDeepSeekProfiles()).toEqual([]);
|
||||||
|
expect(resolveDefaultDeepSeekProfile()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies the profiles DeepSeek ships as unable to drive a pane', () => {
|
||||||
|
writeProfile('web', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']);
|
||||||
|
writeProfile('headless', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']);
|
||||||
|
|
||||||
|
const profiles = listDeepSeekProfiles();
|
||||||
|
expect(profiles.map((p) => `${p.name}:${p.kind}`).sort()).toEqual(['headless:headless', 'web:web']);
|
||||||
|
expect(profiles.every((p) => !isLaunchableProfile(p))).toBe(true);
|
||||||
|
// The whole point: a perfectly installed dsh with only the shipped profiles
|
||||||
|
// still cannot start a Codeman session.
|
||||||
|
expect(resolveDefaultDeepSeekProfile()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers an interactive profile and ignores node_modules', () => {
|
||||||
|
writeProfile('web', ['@deepseek-ai/dsh-web-app']);
|
||||||
|
writeProfile('dsh-tui', ['@deepseek-ai/dsh-base', '@deepseek-harness-tui/dsh-tui']);
|
||||||
|
mkdirSync(join(home, 'profiles', 'node_modules', 'something'), { recursive: true });
|
||||||
|
|
||||||
|
const names = listDeepSeekProfiles().map((p) => p.name);
|
||||||
|
expect(names).not.toContain('node_modules');
|
||||||
|
expect(resolveDefaultDeepSeekProfile()).toBe('dsh-tui');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an unrecognized third-party profile as launchable', () => {
|
||||||
|
// Anyone can publish an app bundle, so an unknown profile must not be hidden
|
||||||
|
// from the picker just because this classifier has not heard of it.
|
||||||
|
writeProfile('custom', ['@someone/dsh-my-own-surface']);
|
||||||
|
const profile = listDeepSeekProfiles().find((p) => p.name === 'custom')!;
|
||||||
|
expect(profile.kind).toBe('unknown');
|
||||||
|
expect(isLaunchableProfile(profile)).toBe(true);
|
||||||
|
expect(resolveDefaultDeepSeekProfile()).toBe('custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a stray directory under profiles/', () => {
|
||||||
|
mkdirSync(join(home, 'profiles', 'not-a-profile'), { recursive: true });
|
||||||
|
writeProfile('dsh-tui', ['@deepseek-harness-tui/dsh-tui']);
|
||||||
|
expect(listDeepSeekProfiles().map((p) => p.name)).toEqual(['dsh-tui']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* DeepSeek Harness (`dsh`) run mode.
|
||||||
|
*
|
||||||
|
* The interesting assertions here are the ones that differ from every sibling
|
||||||
|
* CLI, because dsh is shaped differently in two ways:
|
||||||
|
*
|
||||||
|
* 1. the agent is a PROFILE, not the binary, so the spawn line carries
|
||||||
|
* `--profile <name>` and a profile name has to be treated as a path segment;
|
||||||
|
* 2. the permission switch is an ENV VAR (`DSH_PERMISSION_MODE`), not a flag,
|
||||||
|
* so the thing to pin is that nothing permission-shaped ever reaches the
|
||||||
|
* command line.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { CreateSessionSchema, QuickStartSchema, HookEventSchema } from '../src/web/schemas.js';
|
||||||
|
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||||
|
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
|
||||||
|
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
|
||||||
|
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
|
||||||
|
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
|
||||||
|
import { _clampExternalCliBypassForOwner } from '../src/web/routes/session-routes.js';
|
||||||
|
import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js';
|
||||||
|
|
||||||
|
vi.mock('../src/utils/deepseek-cli-resolver.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../src/utils/deepseek-cli-resolver.js')>();
|
||||||
|
return { ...actual, resolveDefaultDeepSeekProfile: vi.fn(() => 'dsh-tui') };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeepSeek mode schemas', () => {
|
||||||
|
it('accepts DeepSeek session creation config', () => {
|
||||||
|
const parsed = CreateSessionSchema.parse({
|
||||||
|
workingDir: '/tmp',
|
||||||
|
mode: 'deepseek',
|
||||||
|
deepSeekConfig: { profile: 'dsh-tui', permissionMode: 'danger-full-access' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.mode).toBe('deepseek');
|
||||||
|
expect(parsed.deepSeekConfig).toEqual({ profile: 'dsh-tui', permissionMode: 'danger-full-access' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts DeepSeek quick-start config', () => {
|
||||||
|
const parsed = QuickStartSchema.parse({
|
||||||
|
caseName: 'dsh-case',
|
||||||
|
mode: 'deepseek',
|
||||||
|
deepSeekConfig: { resumeSessionId: 'sess_01H9', statusReporting: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.mode).toBe('deepseek');
|
||||||
|
expect(parsed.deepSeekConfig?.resumeSessionId).toBe('sess_01H9');
|
||||||
|
expect(parsed.deepSeekConfig?.statusReporting).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a profile name that is not a single path segment', () => {
|
||||||
|
// A profile is BOTH interpolated into a `bash -c "…"` line and joined into a
|
||||||
|
// filesystem path under $DSH_HOME/profiles, so separators and traversal have
|
||||||
|
// to die at the schema boundary.
|
||||||
|
for (const profile of ['../../etc/passwd', 'a/b', './x', '-rf', 'has space', 'semi;colon']) {
|
||||||
|
expect(() =>
|
||||||
|
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'deepseek', deepSeekConfig: { profile } })
|
||||||
|
).toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown permission preset', () => {
|
||||||
|
// The three presets are the harness's own; anything else would be exported
|
||||||
|
// verbatim as DSH_PERMISSION_MODE and silently fall back to its default.
|
||||||
|
expect(() =>
|
||||||
|
CreateSessionSchema.parse({
|
||||||
|
workingDir: '/tmp',
|
||||||
|
mode: 'deepseek',
|
||||||
|
deepSeekConfig: { permissionMode: 'yolo' },
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsafe resumeSessionId values', () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateSessionSchema.parse({
|
||||||
|
workingDir: '/tmp',
|
||||||
|
mode: 'deepseek',
|
||||||
|
deepSeekConfig: { resumeSessionId: '../../etc/passwd' },
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows DSH_* and DEEPSEEK_* env overrides but not a foreign provider key', () => {
|
||||||
|
const ok = CreateSessionSchema.parse({
|
||||||
|
workingDir: '/tmp',
|
||||||
|
mode: 'deepseek',
|
||||||
|
envOverrides: { DSH_HOME: '/tmp/dsh', DEEPSEEK_API_KEY: 'sk-test' },
|
||||||
|
});
|
||||||
|
expect(ok.envOverrides).toEqual({ DSH_HOME: '/tmp/dsh', DEEPSEEK_API_KEY: 'sk-test' });
|
||||||
|
|
||||||
|
// A dsh settings.yaml can name ANY env var as a provider credential
|
||||||
|
// (apiKeyEnv), which is pi's 34-provider-key problem in a new shape. The
|
||||||
|
// allowlist is global, so admitting them would widen every mode at once.
|
||||||
|
expect(() =>
|
||||||
|
CreateSessionSchema.parse({
|
||||||
|
workingDir: '/tmp',
|
||||||
|
mode: 'deepseek',
|
||||||
|
envOverrides: { QWEN5090_API_KEY: 'sk-test' },
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeepSeek spawn command', () => {
|
||||||
|
it('boots the requested profile', () => {
|
||||||
|
const cmd = buildSpawnCommand({
|
||||||
|
mode: 'deepseek',
|
||||||
|
sessionId: 's1',
|
||||||
|
deepSeekConfig: { profile: 'dsh-tui' },
|
||||||
|
});
|
||||||
|
expect(cmd).toBe('dsh --profile dsh-tui');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the resolved default profile when none was requested', () => {
|
||||||
|
const cmd = buildSpawnCommand({ mode: 'deepseek', sessionId: 's1' });
|
||||||
|
expect(cmd).toBe('dsh --profile dsh-tui');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never puts anything permission-shaped on the command line', () => {
|
||||||
|
// The harness has NO permission flag: the switch is the DSH_PERMISSION_MODE
|
||||||
|
// env export, applied via `tmux setenv`. If this ever starts failing, someone
|
||||||
|
// has invented a flag that does not exist.
|
||||||
|
const cmd = buildSpawnCommand({
|
||||||
|
mode: 'deepseek',
|
||||||
|
sessionId: 's1',
|
||||||
|
deepSeekConfig: { profile: 'dsh-tui', permissionMode: 'danger-full-access' },
|
||||||
|
});
|
||||||
|
expect(cmd).toBe('dsh --profile dsh-tui');
|
||||||
|
expect(cmd).not.toMatch(/danger|approve|permission|yolo|dangerously/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers an explicit resume id over the most-recent form', () => {
|
||||||
|
const cmd = buildSpawnCommand({
|
||||||
|
mode: 'deepseek',
|
||||||
|
sessionId: 's1',
|
||||||
|
deepSeekConfig: { profile: 'p', resumeSession: true, resumeSessionId: 'sess_42' },
|
||||||
|
});
|
||||||
|
expect(cmd).toBe('dsh --profile p --resume sess_42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes the most recent session when only the flag is set', () => {
|
||||||
|
const cmd = buildSpawnCommand({
|
||||||
|
mode: 'deepseek',
|
||||||
|
sessionId: 's1',
|
||||||
|
deepSeekConfig: { profile: 'p', resumeSession: true },
|
||||||
|
});
|
||||||
|
expect(cmd).toBe('dsh --profile p --resume');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an unsafe profile rather than interpolating it', () => {
|
||||||
|
// Defense in depth behind the schema: builders must not trust their callers,
|
||||||
|
// because this string is interpolated into a `bash -c "…"` argument.
|
||||||
|
const cmd = buildSpawnCommand({
|
||||||
|
mode: 'deepseek',
|
||||||
|
sessionId: 's1',
|
||||||
|
deepSeekConfig: { profile: 'evil; rm -rf /' },
|
||||||
|
});
|
||||||
|
expect(cmd).not.toContain('rm -rf');
|
||||||
|
expect(cmd).toBe('dsh --profile dsh-tui');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeepSeek mode wiring', () => {
|
||||||
|
it('is an external CLI mode', () => {
|
||||||
|
expect(isExternalCliMode('deepseek')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is NOT an alt-screen strip mode', () => {
|
||||||
|
// The strip is for Ink-style repaint TUIs (claude/codex/gemini). A dsh
|
||||||
|
// terminal profile is a third-party fullscreen TUI, i.e. the opencode case.
|
||||||
|
expect(isAltScreenStripMode('deepseek')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has default remote and docker commands', () => {
|
||||||
|
expect(defaultRemoteCommandForMode('deepseek')).toContain('dsh');
|
||||||
|
expect(defaultDockerCommandForMode('deepseek')).toBe('exec dsh');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeepSeek status bridge', () => {
|
||||||
|
it('is the only non-claude mode allowed to deliver hook signals', () => {
|
||||||
|
// Earned, not granted: the harness terminal front door REPORTS its state to
|
||||||
|
// a supervisor, so `stop` and `blocked` for a dsh session are definitive
|
||||||
|
// rather than inferred. Every other external CLI must keep failing this.
|
||||||
|
expect(hooksAvailableForMode('deepseek')).toBe(true);
|
||||||
|
expect(hooksAvailableForMode('claude')).toBe(true);
|
||||||
|
for (const mode of ['shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok'] as const) {
|
||||||
|
expect(hooksAvailableForMode(mode)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the harness lifecycle states onto real hook events', () => {
|
||||||
|
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.idle).toBe('stop');
|
||||||
|
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.blocked).toBe('permission_prompt');
|
||||||
|
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.working).toBe('agent_working');
|
||||||
|
// Every mapped event must be one the hook endpoint actually accepts, or the
|
||||||
|
// bridge would post reports the schema silently rejects.
|
||||||
|
for (const event of Object.values(DEEPSEEK_STATE_TO_HOOK_EVENT)) {
|
||||||
|
expect(() => HookEventSchema.parse({ event, sessionId: 's1' })).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeepSeek multi-user clamp', () => {
|
||||||
|
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||||
|
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps a sent danger-full-access down to workspace-write, not read-only', () => {
|
||||||
|
// The clamp removes PRIVILEGE; it must not also break the session's ability
|
||||||
|
// to edit its own workspace, which read-only would.
|
||||||
|
return _clampExternalCliBypassForOwner('nobody', undefined, undefined, undefined, undefined, undefined, {
|
||||||
|
permissionMode: 'danger-full-access',
|
||||||
|
}).then((out) => {
|
||||||
|
expect(out.deepSeekConfig?.permissionMode).toBe('workspace-write');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an ABSENT config absent (the only-if-sent branch)', async () => {
|
||||||
|
// Omitting DSH_PERMISSION_MODE leaves the harness on its own workspace-write
|
||||||
|
// preset, which still asks — so there is nothing to materialize, unlike pi.
|
||||||
|
const out = await _clampExternalCliBypassForOwner(
|
||||||
|
'nobody',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
expect(out.deepSeekConfig).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -424,7 +424,17 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
|||||||
isCliAvailable: () => true,
|
isCliAvailable: () => true,
|
||||||
});
|
});
|
||||||
const menu = app._buildMobileOverviewRunMenu();
|
const menu = app._buildMobileOverviewRunMenu();
|
||||||
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']);
|
expect(modeButtons(menu)).toEqual([
|
||||||
|
'claude',
|
||||||
|
'opencode',
|
||||||
|
'codex',
|
||||||
|
'gemini',
|
||||||
|
'antigravity',
|
||||||
|
'pi',
|
||||||
|
'grok',
|
||||||
|
'deepseek',
|
||||||
|
'shell',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gates every mode the picker actually offers', () => {
|
it('gates every mode the picker actually offers', () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
|
|||||||
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
||||||
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
|
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
|
||||||
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
|
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
|
||||||
|
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
|
||||||
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
||||||
import { isGitAvailable } from '../src/git-clone.js';
|
import { isGitAvailable } from '../src/git-clone.js';
|
||||||
|
|
||||||
@@ -55,6 +56,16 @@ vi.mock('../src/utils/grok-cli-resolver.js', () => ({
|
|||||||
resolveGrokDir: vi.fn(() => null),
|
resolveGrokDir: vi.fn(() => null),
|
||||||
getGrokCliVersion: vi.fn(() => null),
|
getGrokCliVersion: vi.fn(() => null),
|
||||||
}));
|
}));
|
||||||
|
// DeepSeek is the one mode with a two-part availability answer (binary AND a
|
||||||
|
// pane-capable profile), so both probes are mocked independently.
|
||||||
|
vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({
|
||||||
|
isDeepSeekAvailable: vi.fn(() => false),
|
||||||
|
isDeepSeekRunnable: vi.fn(() => false),
|
||||||
|
resolveDeepSeekDir: vi.fn(() => null),
|
||||||
|
getDeepSeekCliVersion: vi.fn(() => null),
|
||||||
|
listDeepSeekProfiles: vi.fn(() => []),
|
||||||
|
resolveDefaultDeepSeekProfile: vi.fn(() => null),
|
||||||
|
}));
|
||||||
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
||||||
isCloudflaredAvailable: vi.fn(() => false),
|
isCloudflaredAvailable: vi.fn(() => false),
|
||||||
resolveCloudflaredPath: vi.fn(() => null),
|
resolveCloudflaredPath: vi.fn(() => null),
|
||||||
@@ -160,6 +171,8 @@ describe('WebServer.renderIndexHtml', () => {
|
|||||||
antigravity: false,
|
antigravity: false,
|
||||||
pi: true,
|
pi: true,
|
||||||
grok: false,
|
grok: false,
|
||||||
|
deepseek: false,
|
||||||
|
deepseekBinary: false,
|
||||||
cloudflared: true,
|
cloudflared: true,
|
||||||
git: true,
|
git: true,
|
||||||
});
|
});
|
||||||
@@ -176,6 +189,8 @@ describe('WebServer.renderIndexHtml', () => {
|
|||||||
isAntigravityAvailable,
|
isAntigravityAvailable,
|
||||||
isPiAvailable,
|
isPiAvailable,
|
||||||
isGrokAvailable,
|
isGrokAvailable,
|
||||||
|
isDeepSeekAvailable,
|
||||||
|
isDeepSeekRunnable,
|
||||||
isCloudflaredAvailable,
|
isCloudflaredAvailable,
|
||||||
isGitAvailable,
|
isGitAvailable,
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user