The `Run > DeepSeek web UI...` shortcut failed three ways at once against a real
install, and the three are independent.
1. It hardcoded `--port 3080`. That is dsh web's OWN default, which makes it
precisely the port a DeepSeek user is most likely to be serving on already,
so the launch died with EADDRINUSE against the user's own server. The port
now comes from `GET /api/deepseek/web-port`, which walks 3080..3119 for a
free loopback port by BINDING it (a connect probe cannot tell "free" from
"listening but not answering yet").
2. It opened the tab unconditionally. The crashed server left a saved dashboard
pointing at nothing, with the failure only visible in a shell tab nobody had
a reason to look at. The launch now polls the existing webview probe until
the URL answers, and on timeout reports the error naming the shell tab
instead of persisting a dead dashboard.
3. The saved tab was untrusted, so the frame was sandboxed without
`allow-same-origin` and the dashboard was broken twice over: the dsh
client-runtime reads `localStorage` while loading its plugins and died there
("the document is sandboxed and lacks the 'allow-same-origin' flag"), and an
opaque-origin frame sends `Origin: null`, so dsh's own trust fence 403'd
every `/api` call no matter which authority `--trusted-host` named. Passing
`location.host` only means anything once the frame actually carries that
origin, so `--trusted-host` had never once done its job. The managed tab is
now created `trusted: true`.
That trade is real and deliberate: a trusted proxied frame is same-origin
with Codeman and can reach Codeman's API. It is defensible only because this
dashboard is an agent harness Codeman just started itself, on loopback, which
can already run code as the user. It is not a precedent for trusting
third-party dashboards, which is why it is set at this one call site rather
than defaulted.
Separately, the shortcut listed its own dashboard twice: once as the menu entry
that starts it and once as the row that entry had written on the previous click.
Webviews now carry an optional `managed` marker, managed rows are filtered out
of the saved-dashboard list, and a relaunch repoints the existing row rather
than stacking one dead dashboard per restart (which the per-launch port would
otherwise guarantee). `managed` is declared in the schema because a plain
`z.object` strips undeclared keys, so an undeclared marker would never survive
the round trip.
`DEEPSEEK_WEB_PORT` is gone from constants.js; its doc comment asserted that a
hand-started `dsh web` and the shortcut "land on the same place and share one
saved tab", which is the bug stated as a feature.
Verified on a real install with the user's own `dsh web` holding 3080: the
shortcut takes 3081, the server answers, exactly one DeepSeek entry shows in the
run menu, and the proxied dashboard renders its workspaces and completes its own
API calls (the previously-403'd `api/settings.describe` now succeeds). Full gate
green (6142 passed), typecheck/lint/format/public-assets clean.
16 KiB
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; the per-decision invariants live inarchitecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek. Template: the grok integration (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
(open-sourced 2026-08-13, MIT) is a plugin-native agent framework: tools, skills,
sessions, sandboxes and whole APPS are Cordis plugins composed into profiles.
dsh is the launcher — dsh --profile <name> boots
$DSH_HOME/profiles/<name>, an ordered stack of plugin-bundle patch layers under
the user's own overrides. State lives in ~/.dsh (.env 0600, settings.yaml,
cordis.patch.yml, profiles/, sessions/, storages/).
2. Shape decisions (why DeepSeek is wired the way it is)
DeepSeek is a ninth run mode. Never a location overlay, never a web tab (the browser UI is handled separately, §3). Three of its decisions have no precedent in the six external CLIs before it.
| Question | Decision | Why |
|---|---|---|
| What does a pane run? | dsh --profile <name>, profile discovered |
The decision that shapes everything else. DeepSeek ships web, headless and base — no terminal agent. The interactive front door is always a third-party plugin, so Codeman resolves a binary AND a profile inventory, and "available" means both. resolveDefaultDeepSeekProfile() prefers a recognized TUI, then an UNRECOGNIZED profile (anyone can publish an app bundle; a classifier that has not heard of one must not hide it), and refuses web/headless, which cannot occupy a pane. |
| Which TUI? | none blessed; default for BOOTSTRAP only | POST /api/deepseek/install-profile defaults to @deepseek-harness-tui/dsh-tui (~27.5k weekly downloads, ~4x the next, MIT, and it speaks the status contract in §2.3), but accepts any npm name and the resolver never assumes that profile exists. Codeman offers a default; it does not pick a winner. |
| Permission bypass | DSH_PERMISSION_MODE env export, no flag |
The harness has NO command-line permission option; its sandbox/approval rows read one env var with three presets (read-only / workspace-write / danger-full-access, read off dsh --dump-default-config). This is the one legitimate exception to the CLAUDE_CODE_EFFORT_LEVEL ban: that var hard-locks in-session switching, whereas the harness reads this with ?? as a boot-time DEFAULT, so it stays soft. Exported via tmux setenv, never on the command line. The Run button sends danger-full-access, matching every sibling Run button. |
| Multi-user clamp branch | only-if-sent, clamped to workspace-write, plus an env-var half |
Omitting the export leaves the harness on workspace-write, which still ASKS, so an absent config is already safe (the codex/antigravity/grok shape, not pi's materialize). Clamping to workspace-write rather than read-only is deliberate: the clamp removes privilege, it must not break a session's ability to edit its own workspace. ⚠️ Unlike every sibling, clamping the CONFIG is only half the gate: the switch is an env var, DSH_* is an allowlisted envOverrides prefix, and applyEnvOverrides() runs AFTER _configureDeepSeek(), so envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'} on the same request would land last and win. clampEnvOverridesForOwner() drops DSH_PERMISSION_MODE and DSH_HOME for a non-granted owner (dropping falls through to the clamped export). DSH_HOME because it aims the launcher at a profile tree whose plugin code runs at BOOT, before any approval row. |
hooksAvailableForMode() granularity |
per SESSION for deepseek, per mode for everything else | deepSeekConfig.statusReporting: false disarms the HERDR_* export, and the triple is the only reason a dsh session posts anything, so a mode-only answer would accept until=stop where nothing can send one — the infinite-wait the predicate exists to prevent. Call sites pass sessionHookOptions(session); the default stays permissive so a forgotten one degrades to the old behaviour. ⚠️ Profile conformance stays unknowable at request time (an unrecognized profile is deliberately launchable), so a non-conforming TUI still times out on an explicit stop; the default set keeps idle/exit for that. ⚠️ The predicate is NOT "is this claude": Read My Mind and intent capture read Claude's transcript and were silently widened by this change, so they compare mode === 'claude' directly now. |
| Profile install spawn | own process group, hand-rolled timeout | dsh plugin add fans out into package-manager children, and spawn's built-in timeout signals only the direct child: survivors keep the inherited stdio pipes open, close never fires, and the held-open request leaks with no route-level deadline. detached: true + negative-pid SIGTERM→SIGKILL, the same escalation runGit() uses for the same reason, plus a last-resort reap for a grandchild that escaped the group. |
| Idle detection | real hook events via a status shim | The standout decision. The TUI already reports its lifecycle to a supervising process through a generic env-gated contract inherited from Herdr: HERDR_ENV=1 + HERDR_BIN_PATH + HERDR_PANE_ID make it run <bin> pane report-agent <id> --state idle|working|blocked … on every state change, exit 0 = delivered. deepseek-status-shim.ts generates a script into the data dir and points HERDR_BIN_PATH at it. So deepseek is the only non-claude mode that passes hooksAvailableForMode() — earned by emitting definitive signals, not granted. An interface implementation, not an impersonation: no real herdr binary is ever executed, and a TUI that ignores the contract simply falls back to output stabilization. |
agent_working event |
new, 157th SSE constant | The one hook event with no Claude Code hook behind it. A harness turn cannot run while its own modal approval is on screen, so "started working" proves a dialog was answered in the terminal. Without it a dsh red alert would survive until the next stop — the exact stuck-alert bug the claude path already fixed once, and its pane-capture staleness sweep is Claude-dialog-shaped and cannot help here. |
| Resolver | identity probe THEN version probe | Strictest of the family, and not by preference. dsh is not merely a squattable npm name: Debian ships an unrelated dsh (dancer's shell, apt install dsh) which would answer a version probe convincingly and then be handed a spawn line. dsh --help must match DeepSeek Harness first. DEEPSEEK_VERSION_REGEX keeps the prerelease tail (0.1.1-rc.2), since truncating it would report an rc as a release. |
| Env allowlist | DSH_* + DEEPSEEK_* |
DSH_* covers the launcher's documented inputs (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, the DSH_TUI_* knobs); DEEPSEEK_* is the vendor namespace holding DEEPSEEK_API_KEY/DEEPSEEK_BASE_URL, same reasoning that admitted XAI_* for grok. ⚠️ Pi's lesson repeats exactly: a dsh settings.yaml can nominate ANY env var as a provider credential (apiKeyEnv), and the allowlist is one GLOBAL list, so admitting those would widen every mode at once. They stay out. |
| Model | NOT a session field | The model is a composition entry (agent-default-model) in the profile's config tree, set in ~/.dsh/settings.yaml + cordis.patch.yml. Both create paths deliberately resolve no model for this mode rather than inventing a flag. |
| Alt-screen strip | OUT of isAltScreenStripMode() |
Third-party fullscreen TUIs with their own scrollback and mouse handling — the opencode case, not the Ink case. |
| Local echo | 'buffer' via the _updateLocalEchoState fallthrough |
UNMEASURED against a live authenticated session (see §5), same honest gap grok shipped with. The leading TUI's composer supports @ completion and history search, which may make it per-keystroke reactive like codex; if so the fallback is the 'off' branch. |
| Docker | image installs dsh AND a profile | Profiles are deliberately NOT seeded from the host: each is a per-profile node_modules tree, host-arch-specific and far too large to copy per container start. Only ~/.dsh/.env, settings.yaml, cordis.patch.yml are seeded (auth + model composition). The profile install rides the useradd layer so the closing chgrp/chmod g=u covers it, which is what keeps it usable under the arbitrary uid the container runs as. |
| Remote SSH | exec "$SHELL" -i -l -c 'dsh' |
Boots the remote box's default profile; a remote with several needs the per-host commands.deepseek override, since deepSeekConfig does not cross ssh. |
3. The web profile
The browser UI is the only interactive surface DeepSeek ships itself, so it gets
a shortcut, not a run mode: Run ▸ DeepSeek web UI… starts
dsh web --no-open --host 127.0.0.1 --port <free> --trusted-host <codeman-authority>
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.
Three things about this shortcut are load-bearing and each came from it failing in exactly that way against a real install:
- The port is chosen, never hardcoded.
GET /api/deepseek/web-portwalks 3080..3119 for a free loopback port. 3080 is dsh's own default, which makes it precisely the port a DeepSeek user is most likely to already be serving on: binding it unconditionally killed the launch withEADDRINUSEagainst the user's owndsh web. - The tab is opened only after the server answers. The launch polls
POST /api/webviews/probeuntil the URL responds, so a server that dies on startup reports the failure and points at its shell tab, instead of silently persisting a dashboard aimed at nothing. - The saved tab is
trusted: true, and must be. An untrusted webview is sandboxed withoutallow-same-origin, which breaks this dashboard twice: the dsh client-runtime readslocalStoragewhile loading plugins and dies there, and an opaque-origin frame sendsOrigin: null, so dsh's trust check 403s every/apicall regardless of what--trusted-hostnames. Passinglocation.hostonly means anything once the frame actually carries that origin. The trade is real — a trusted proxied frame is same-origin with Codeman and can reach Codeman's API — and is defensible only because this particular dashboard is an agent harness Codeman just started itself on loopback, which can already run code as the user. It is not a precedent for trusting third-party dashboards generally.
The record is marked managed: 'deepseek-web', which keeps it out of the
saved-dashboard list: the shortcut that maintains it is already a menu entry, so
listing both showed the same dashboard twice. Being managed is also what lets a
relaunch repoint the existing row instead of stacking one dead dashboard per
restart, since the port is now chosen per launch.
⚠️ The authority baked into --trusted-host is the one the launch was clicked
from. Codeman reachable at several authorities (loopback and a tailnet name)
therefore needs the server restarted from whichever one is in use; the reuse
path checks that the server is reachable, not that it trusts the current origin.
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; teachingPredictiveEchoAddonits 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-cacheagent-image rebuild — see the--no-cacherule in CLAUDE.md). - A remote-SSH deepseek case.
- The web-UI shortcut against a tunnel authority. Loopback is verified end to end
through the webview proxy (dashboard renders, its
/apicalls succeed); the cross-authority case above is a known limitation rather than an open question.
6. Follow-ups
- Response viewer: read
~/.dsh/sessions/**(JSONL) the way codex rollouts are read back. Highest-value follow-up, and very achievable. headlessas 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. --patchoverlays 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.