Merge remote-tracking branch 'origin/master' into worktree/web-session-model-selector

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
#	apps/cli/src/web.ts
#	apps/web/tests/session-title.snapshot.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/src/client/index.ts
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/fake-api.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
#	packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
#	packages/client/ui-conversation/tests/chat-view.spec.tsx
#	packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/rpc.schema.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	packages/host/runtime/README.md
#	packages/llm/llm-deepseek/README.md
#	scripts/translation-pairing.manifest.json
#	tsconfig.client.json
This commit is contained in:
Yichen Jiang
2026-07-26 13:13:53 +08:00
1600 changed files with 56281 additions and 31357 deletions

View File

@@ -1,16 +1,18 @@
# `@deepseek-ai/dsh`
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot.
The TUI surface:
- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
- resumes a persisted session with `dsh --resume <session-id>` — the form the TUI prints on exit and lists under `/resume`; the flag sets `RESUME_SESSION_ID` before boot so the shipped config rehydrates that session, and a missing or unreadable id fails loud and exits nonzero;
- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config <path>` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume <id>`; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session;
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. The headless surface retains deterministic fallback titles without making the auxiliary title-model request.
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
## Install (developer machine)
@@ -20,4 +22,4 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
`pnpm run demo:tui` runs the same entry from the repo root. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.
`pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.

246
apps/cli/cordis.yml Normal file
View File

@@ -0,0 +1,246 @@
# dsh web — the full web-shape composition: host runtime (layer 1), the
# transport/service layer (layer 2), and the browser plugin roster (dshClient
# rows the modules node half scans into window.__DSH_BOOT__). Row order
# carries no load semantics (activation is service-availability driven); the
# grouping below is for readers. `--dev` appends the dsh-client-hmr row in
# code (AppCLIEntry) — prod and dev differ by exactly that one row.
# AppCLIEntry patches this tree before boot: profile json + CLI flags +
# distIndex land as config patches over the rows below (yaml = engineering
# defaults, json = user config, user wins per field).
# ── layer 1: runtime ────────────────────────────────────────────────────────
- id: timer
name: '@cordisjs/plugin-timer'
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: session
name: '@deepseek-ai/dsh-session'
- id: session-title
name: '@deepseek-ai/dsh-session-title'
config:
fallbackMaxWords: 5
fallbackMaxBytes: 40
maxTitleBytes: 80
# Model-made titles on the first-message cadence (the web sidebar renders
# session/title). Same values as the TUI composition.
- id: session-title-llm
name: '@deepseek-ai/dsh-session-title-first-message-llm'
config:
targetWords: 5
targetCjkCharacters: 10
maxInputBytes: 4096
maxOutputTokens: 64
timeoutMs: 60000
- id: system-prompt
name: '@deepseek-ai/dsh-system-prompt'
config:
persona: ''
- id: tools
name: '@deepseek-ai/dsh-tools'
- id: user-interaction
name: '@deepseek-ai/dsh-user-interaction'
- id: agent
name: '@deepseek-ai/dsh-agent'
- id: tasks
name: '@deepseek-ai/dsh-tasks'
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents: []
# The native DeepSeek adapter; reads the key/base-url the boot's layered
# .env loading (cwd then $DSH_HOME) left in the environment.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: './.sessions'
- id: storage
name: '@deepseek-ai/dsh-storage'
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: './.storages'
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
config:
backend: json
- id: workspace
name: '@deepseek-ai/dsh-workspace'
- id: bash-local
name: '@deepseek-ai/dsh-bash-local'
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# fs cwd stays the package default (process.cwd()) — the same value the
# gateway injects into session.cwd, so paths and sessions agree.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
- id: workspace-context
name: '@deepseek-ai/dsh-workspace-context'
config:
maxBytes: 65536
- id: skill
name: '@deepseek-ai/dsh-skill'
- id: skill-local
name: '@deepseek-ai/dsh-skill-local'
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'
# token-meter rejects unknown config keys — keep this row bare.
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subagent-spawn
name: '@deepseek-ai/dsh-subagent-spawn'
config:
providerName: spawn
- id: subagent-fork
name: '@deepseek-ai/dsh-subagent-fork'
config:
providerName: fork
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent
- id: tool-subagent-fork
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
config:
provider: spawn
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
# Omitting maxInlineBytes makes the whole policy a silent no-op — always
# state it explicitly.
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. provider/model are the host default routing — the profile json's
# mapping target (user config overrides these engineering defaults).
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'
config:
provider: deepseek
model: deepseek-v4-flash
# ── layer 2: transport/service ──────────────────────────────────────────────
# Plain route-registration carrier. distIndex is an assembly fact, not user
# config — AppCLIEntry resolves the frontend dist and patches it in; host and
# port arrive as CLI-flag patches over these defaults.
- id: webserver
name: '@deepseek-ai/dsh-host-webserver'
config:
host: 127.0.0.1
port: 3080
# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──
# Dual-face: node half scans this very tree for dshClient rows, composes
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the
# module table the shell kernel constructs before cordis exists (§4.7 —
# adopted as a plugin entry by the kernel, never fetched).
- id: modules
name: '@deepseek-ai/dsh-client-modules'
# Owns both ends of the web transport: node half binds the gateway to the
# webserver under /api; browser half is the fetch/SSE client.
- id: connection
name: '@deepseek-ai/dsh-client-connection'
- id: client-runtime
name: '@deepseek-ai/dsh-client-runtime'
- id: ui-theme
name: '@deepseek-ai/dsh-client-ui-theme'
- id: i18n
name: '@deepseek-ai/dsh-client-i18n'
- id: ui-layout
name: '@deepseek-ai/dsh-client-ui-layout'
- id: ui-sidebar
name: '@deepseek-ai/dsh-client-ui-sidebar'
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
- id: ui-model-selector
name: '@deepseek-ai/dsh-client-ui-model-selector'
- id: ui-workspace
name: '@deepseek-ai/dsh-client-ui-workspace'
- id: ui-question
name: '@deepseek-ai/dsh-client-ui-question'
- id: ui-trajectory
name: '@deepseek-ai/dsh-client-ui-trajectory'

View File

@@ -9,14 +9,22 @@
},
"files": [
"lib/bin.js",
"cordis.yml",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@cordisjs/plugin-include": "workspace:*",
"@cordisjs/plugin-loader": "workspace:*",
"@cordisjs/plugin-timer": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
@@ -25,11 +33,53 @@
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-runtime": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^"
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-storage-json": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"commander": "^15.0.0",
"cordis": "^4.0.0-rc.7",
"js-yaml": "^4.2.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9"
}
}

View File

@@ -0,0 +1,240 @@
/**
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
* Everything here is what must exist before the Loader runs: layered env,
* the patch composition over the shipped cordis.yml (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud triple after the
* tree settles.
*/
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import type { FiberState } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
entryId: string
configKey: string
}
/**
* The static profile→row mapping table. json is user config and wins over the
* yml engineering default per field; a json key absent from this table fails
* loud (a typo silently ignored would read as "setting has no effect").
* Developers extend deployments by adding rows here.
*/
const PROFILE_MAPPINGS: ProfileMapping[] = [
{ jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' },
{ jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' },
{ jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' },
]
// The include's YAML dialect: `!!js` scalars become expression nodes the
// Loader evaluates at entry activation. The bypass parse below must accept
// them (and passing one through a patch unchanged is legal).
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: data => typeof data === 'string',
construct: data => ({ __jsExpr: String(data) }),
})
const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
/**
* Value mirror of cordis's `FiberState` const enum members the sweep needs
* (a const enum has no runtime object to import; same rationale as the
* client-side mirror in dsh-client-web).
*/
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_PENDING = 0 as FiberState.PENDING
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
export interface AppCLIEntryOptions {
/** Absolute path of the shipped cordis.yml. */
configPath: string
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
dev: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
/**
* Listen port override onto the webserver row. Web passes the --port flag
* value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
* never collide — and the printed URL still opens the live session in a
* browser).
*/
port?: number
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
workspaceRoot?: string
}
/**
* Boot driver for the config-tree dsh surfaces (web and headless share the
* one composition; the surfaces differ only in constructor facts): holds only
* what exists independently of (and prior to) cordis — argv facts, the
* composed patch set, and finally the root ctx.
*/
export class AppCLIEntry {
/** The root context, set by {@link run}. */
ctx!: Context
private patches: PatchOptions[] = []
constructor(private readonly options: AppCLIEntryOptions) {}
/**
* Run the boot chain: layered env → patch composition → Loader include
* boot (dev row before await) → fail-loud triple.
* @returns the settled root context and the listening port.
*/
async run(): Promise<{ ctx: Context; port: number }> {
this.loadEnvLayers()
this.composePatches()
await this.bootTree()
this.assertBoot()
const port = this.ctx.get('httpServer')?.port
/* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
return { ctx: this.ctx, port }
}
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
private loadEnvLayers(): void {
loadEnv('dsh', resolveDshHome())
}
/**
* Compose the patch set from the three non-yml config sources: profile
* json (user config), CLI flags, and the resolved frontend dist. Patches
* replace a row's config wholesale, so each patched row's yml static
* values are re-read here (bypass parse) and merged under the overrides.
*/
private composePatches(): void {
const rows = this.parseYmlRows()
const overrides = new Map<string, Record<string, unknown>>()
const put = (entryId: string, key: string, value: unknown): void => {
const bag = overrides.get(entryId) ?? {}
bag[key] = value
overrides.set(entryId, bag)
}
// Source 1: profile json (missing file = empty; unmapped key = loud).
for (const [key, value] of Object.entries(this.readProfile())) {
const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
if (mapping === undefined) {
throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
}
put(mapping.entryId, mapping.configKey, value)
}
// Source 2: CLI flags (field set disjoint from the json mappings).
if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
// Source 3: the frontend dist — an assembly fact of this app, never yml
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
this.patches = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
})
}
/** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */
private async bootTree(): Promise<void> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await ctx.loader.create({
name: 'cordis:include',
config: {
path: pathToFileURL(resolve(this.options.configPath)).href,
...this.patches.length > 0 ? { patches: this.patches } : {},
},
})
if (this.options.dev) {
await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
}
this.ctx = ctx
await ctx.loader.await()
}
/**
* Fail-loud triple: assertEntriesLoaded catches import failures,
* installFailLoud catches late apply rejections, and the all-ACTIVE sweep
* below catches PENDING fibers (cordis inject waiting has no timeout).
*/
private assertBoot(): void {
installFailLoud('dsh')
assertEntriesLoaded(this.ctx, 'dsh')
const failures: string[] = []
for (const entry of this.ctx.loader.entries()) {
if (entry.fiber === undefined || entry.disabled) continue
const state = entry.fiber.state
if (state === FIBER_ACTIVE) continue
if (state === FIBER_PENDING) {
const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined)
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
}
}
if (failures.length > 0) {
throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
private parseYmlRows(): Map<string, { config?: unknown }> {
const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
const rows = new Map<string, { config?: unknown }>()
for (const row of doc as { id?: string; config?: unknown }[]) {
if (typeof row.id === 'string') rows.set(row.id, row)
}
return rows
}
/** Profile json under cwd; read-only — never created here, absent = no user config. */
private readProfile(): Record<string, unknown> {
let raw: string
try {
raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
throw error
}
const parsed: unknown = JSON.parse(raw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
}
return parsed as Record<string, unknown>
}
/** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */
private resolveDistIndex(): string {
const require = createRequire(import.meta.url)
try {
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
}
}
}

143
apps/cli/src/args.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* Commander adapter for the `dsh` command-line entry: the one place argv is
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant
* and dynamic-imports that mode's module. One program: the default (no
* subcommand) is the TUI/headless surface with option-only flags; `web` is a
* real subcommand. Commander owns `--help`/`--version` and parse errors — it
* prints and exits at the point of failure (a domain failure routes through
* `command.error`), so this returns only a resolved mode.
* @module @deepseek-ai/dsh/args
*/
import { Command, CommanderError } from 'commander'
/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume <id>` rehydrates a session. */
interface TuiInvocation {
mode: 'tui'
config?: string
resume?: string
}
/** Headless one-shot: `dsh -p "task"`. */
interface HeadlessInvocation {
mode: 'headless'
prompt: string
}
/**
* Browser UI: `dsh web`. `host`/`port` are present only when the flag was
* passed — pass-through overrides with no CLI default and no CLI validation:
* the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal,
* `port` a natural ≤ 65535) is the single source of both the default (the
* shipped `cordis.yml` value stands when a flag is absent) and validity (a bad
* value fails loud at boot). `port` is `Number`-coerced only because the schema
* wants a number, not a string. `dev` mounts the client HMR driver;
* `workspaceRoot` is the parent directory for name-created workspaces.
*/
interface WebInvocation {
mode: 'web'
host?: string
port?: number
dev: boolean
workspaceRoot?: string
}
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
/** Raw web-subcommand options straight from Commander. */
interface WebOptions {
host?: string
port?: string
dev?: boolean
workspaceRoot?: string
}
/**
* Narrow the raw `web` options into a {@link WebInvocation}. No host/port
* validation: both flow to the webserver schema, which is the sole gate. `port`
* is coerced to a number (the schema rejects a string) but not range-checked
* here — `NaN`/out-of-range fail loud at the schema on boot.
*/
function resolveWeb(options: WebOptions): WebInvocation {
return {
mode: 'web',
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true,
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
}
}
/**
* Resolve the raw argv into a {@link DshInvocation}, or print and exit for
* `--help`/`--version`/a parse error. The default (no subcommand) is the
* TUI/headless surface; `web` is a subcommand.
* @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
* @param version - the version string `--version` prints; read from this app's package.json.
* @returns the resolved invocation (only reached on a valid, non-help invocation).
*/
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
let resolved: DshInvocation | undefined
const program = new Command()
.name('dsh')
.version(version, '-V, --version', 'output the version number')
.description('dsh: interactive TUI (default), headless task, and browser UI')
.exitOverride()
// Default surface: option-only (no positional), so `web` can be a real
// subcommand without a positional collision.
.option('--config <path>', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)')
.option('-p, --prompt <task>', 'run one headless turn for this task, print the result, and exit')
.option('--resume <id>', 'resume the persisted session with this id (TUI mode)')
.action((options: { config?: string; prompt?: string; resume?: string }) => {
if (options.prompt !== undefined) {
// A headless prompt owns the invocation; an empty task has nothing to
// run, and --config/--resume are TUI inputs that must not silently
// vanish from a headless run.
if (options.prompt === '') program.error('error: --prompt needs a task')
if (options.config !== undefined || options.resume !== undefined) {
program.error('error: --prompt takes no --config or --resume')
}
resolved = { mode: 'headless', prompt: options.prompt }
return
}
// An empty --resume= id would silently start a fresh session downstream
// (agent-loop treats '' as no-resume), so a mistyped resume must fail loud.
if (options.resume === '') program.error('error: --resume needs a session id')
resolved = {
mode: 'tui',
...options.config !== undefined && { config: options.config },
...options.resume !== undefined && { resume: options.resume },
}
})
const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
web
.option('--host <host>', 'override the config bind host (127.0.0.1 or 0.0.0.0)')
.option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
.option('--workspace-root <path>', 'parent directory for name-created workspaces')
.action((options: WebOptions) => {
// Commander parses the parent (default-surface) options on either side of
// the subcommand into `program.opts()`. `web` shares none of them, so a
// leaked `--config`/`-p`/`--resume` is a mistyped invocation that must
// fail loud rather than silently start the web server and drop it.
const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
program.error('error: web takes none of --config, -p/--prompt, or --resume')
}
resolved = resolveWeb(options)
})
try {
program.parse(argv, { from: 'user' })
} catch (error) {
// Commander printed help/version/the error under `exitOverride`; exit with
// the code it chose (0 for help/version, 1 for a parse or domain error).
/* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */
return process.exit(error instanceof CommanderError ? error.exitCode : 1)
}
/* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */
if (resolved === undefined) throw new Error('dsh: no invocation resolved')
return resolved
}

View File

@@ -1,25 +1,49 @@
#!/usr/bin/env node
/**
* dsh — command-line entry. Coarse dispatch only; each surface module owns its
* argument handling. Dynamic imports keep unrelated surfaces out of each
* dispatch path; everything except `web` and headless prompts opens the TUI.
* dsh — command-line entry. Dynamic imports per mode keep unrelated modes out
* of each dispatch path; the adapter prints and exits for
* `--help`/`--version`/a parse error, so only a valid mode reaches the switch.
* @module @deepseek-ai/dsh/bin
*/
/* v8 ignore file -- built-bin and PTY tests exercise this self-executing dispatch. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
import { parseDshArgs } from './args.ts'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
// one directory under apps/cli, so the checked-in manifest resolves with the
// same relative hop from either artifact.
/** This app's version, read from its checked-in package.json. */
function readVersion(): string {
const manifest = JSON.parse(
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
) as { version?: unknown }
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
}
loadEnv('dsh')
const argv = process.argv.slice(2)
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
if (argv[0] === 'web') {
const { runWeb } = await import('./web.ts')
await runWeb(argv.slice(1))
} else if (argv.includes('-p') || argv.includes('--prompt')) {
const { runHeadless } = await import('./headless.ts')
await runHeadless(argv)
} else {
const { runTui } = await import('./tui.ts')
await runTui(argv)
switch (invocation.mode) {
case 'web': {
const { runWeb } = await import('./web.ts')
await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot)
break
}
case 'headless': {
const { runHeadless } = await import('./headless.ts')
await runHeadless(invocation.prompt)
break
}
case 'tui': {
const { runTui } = await import('./tui.ts')
await runTui(invocation.config, invocation.resume)
break
}
default:
invocation satisfies never
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
}

View File

@@ -1,18 +1,19 @@
/**
* `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic
* injection (InProcessApiClient over the host handler, so the full carrier
* chain — wire serialization, zod, SSE framing — really runs; this is the
* protocol's second real consumer). No HTTP server, no port, no dist
* resolution. Runs one task turn, prints the final assistant text, exits
* (completed → 0, else 1).
* `dsh -p "task"` — headless over the one shared composition: AppCLIEntry
* boots the same cordis.yml as `dsh web` (port 0, so parallel runs never
* collide), then in-process isomorphic injection (InProcessApiClient over
* toFetchHandler(ctx.apiProxy), so the full carrier chain — wire
* serialization, zod, SSE framing — really runs). The printed URL opens the
* live session in a browser while the task runs. Runs one task turn, prints
* the final assistant text, exits (completed → 0, else 1).
*/
import { parseArgs } from 'node:util'
import { startHost } from '@deepseek-ai/dsh-host-runtime'
import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy'
import { fileURLToPath } from 'node:url'
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { AppCLIEntry } from './app-cli-entry.ts'
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
@@ -65,28 +66,26 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
return { text, reason: 'error' }
}
export async function runHeadless(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: { prompt: { type: 'string', short: 'p' } },
allowPositionals: false,
})
const task = values.prompt
if (task === undefined || task === '') {
process.stderr.write('usage: dsh -p "task"\n')
process.exit(1)
}
/**
* Run one headless turn for `task` and exit (completed → 0, else 1). The task
* is the non-empty prompt the argument adapter parsed from `-p`/`--prompt`
* (the adapter rejects an empty task, so no guard is needed here).
* @param task - the prompt text for the single turn.
*/
export async function runHeadless(task: string): Promise<void> {
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: false,
},
const entry = new AppCLIEntry({
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
dev: false,
port: 0,
})
const api = new InProcessApiClient(host.handler)
const { ctx, port } = await entry.run()
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), () => host.dispose())
const created = await unwrap(await api.sessions.create({}), dispose)
// Open the stream before prompting so no frame is lost — kept in this order
// even though in-process delivery has no race, so the code survives a move
@@ -99,11 +98,11 @@ export async function runHeadless(argv: string[]): Promise<void> {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: task }],
}), () => host.dispose())
}), dispose)
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
await host.dispose()
await dispose()
process.exit(outcome.reason === 'completed' ? 0 : 1)
}

View File

@@ -1,6 +1,6 @@
/**
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
* tui-agent config (or an explicit config argument) with the personal overlay
* tui-agent config (or the `--config` override) with the personal overlay
* from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
* ambient environment, then the invoking directory's `.env`, then the personal one)
* and its `config.yaml` patches the booted tree. The workspace is the invoking
@@ -18,19 +18,15 @@ import {
installFailLoud,
loadEnv,
loadPersonalPatches,
parseResumeArg,
RESUME_SESSION_ID_KEY,
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { Context } from 'cordis'
import type { TuiResumeHost } from '@deepseek-ai/dsh-tui'
const NAME = 'dsh'
// The env var the shipped tui-agent config reads (`resumeSessionId: !!js
// process.env.RESUME_SESSION_ID`) to rehydrate a persisted session. The
// `--resume <id>` flag is CLI sugar that sets it before boot, so the printed
// `dsh --resume <id>` exit hint runs back through this same intake.
const RESUME_SESSION_ID_ENV = 'RESUME_SESSION_ID'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
// one directory under apps/cli, so the shipped default config resolves with
// the same relative hop from either artifact.
@@ -45,27 +41,68 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
the tui-agent PTY smoke drives this path end to end, personal overlay included */
/**
* Run the interactive TUI from the invoking directory.
* @param argv - arguments after the subcommand dispatch; a `--resume <id>` flag
* resumes that persisted session, and the first non-flag argument may name a
* config to boot instead of the shipped default.
* @param config - a config path to boot instead of the shipped default, or
* `undefined` for the default; already parsed from `--config`.
* @param resumeSessionId - a persisted session id to resume, or `undefined`;
* already parsed and non-empty-validated from `--resume`. It is provided on the
* boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config
* reads through `!!js` to rehydrate that session.
*/
export async function runTui(argv: string[]): Promise<void> {
export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise<void> {
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree
// is logged per-entry rather than rethrown, so a piped launch would
// otherwise settle into an idle UI-less process instead of exiting nonzero.
if (!process.stdin.isTTY || !process.stdout.isTTY) {
process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs\n`)
process.stderr.write(
`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; use \`${NAME} -p "task"\` for pipes and automation\n`,
)
process.exit(1)
}
installFailLoud(NAME)
// The bin already loaded the invoking directory's .env; the personal .env
// only fills what is still unset (process.loadEnvFile never overrides).
loadEnv(NAME, resolveDshHome())
// An explicit `--resume` flag beats any ambient RESUME_SESSION_ID, so set it
// after loadEnv and before boot reads it through the config's `!!js`.
const { resumeSessionId, rest } = parseResumeArg(argv)
if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId
const ctx = await boot(NAME, resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME))
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
// flag, so the resumed process rehydrates through this same intake. The host
// is offered only when Node exposes `process.execve` and knows its own entry.
const entry = process.argv[1]
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : {
async handoff(sessionId): Promise<never> {
const current = app.current
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
// Rebuild argv from the parsed config plus the selected id: TUI mode's
// only arguments are `--config <path>` and `--resume <id>`.
const nextArgv = [
process.execPath,
...process.execArgv,
entry,
`--resume=${sessionId}`,
...config !== undefined ? ['--config', config] : [],
]
try {
await current.fiber.dispose()
execve(process.execPath, nextArgv, process.env)
throw new Error('process replacement returned unexpectedly')
} catch (error) {
process.stderr.write(`${NAME}: resume handoff failed after terminal release: ${String(error)}\n`)
process.exit(1)
}
},
}
const ctx = await boot(
NAME,
resolveConfigPath(config ?? DEFAULT_CONFIG, undefined),
loadPersonalPatches(NAME),
(hostCtx) => {
// Inject the resume id (or undefined) so the shipped config's `!!js`
// reads it as a bare identifier; then offer the in-place handoff host.
hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
},
)
app.current = ctx
addHarnessSourceSection(ctx, SOURCE_ROOT)
}
/* v8 ignore stop */

View File

@@ -1,161 +1,60 @@
/**
* `dsh web` — the web-shape assembly: startHost + dist resolution +
* startWebServer + the URL line + signal wiring. Mixing host and carrier
* concerns is this app module's job (packages stay single-sided).
* `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the
* already-parsed host/port/dev, print the URL line, wire signals. All
* composition lives in cordis.yml; all boot glue lives in AppCLIEntry. Host and
* port are unvalidated pass-through overrides — the `dsh-host-webserver` schema
* gates them at boot.
*/
import { parseArgs } from 'node:util'
import { networkInterfaces } from 'node:os'
import { createRequire } from 'node:module'
import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
// Display-only mirrors of the webserver schema's allowed hosts: the loopback
// address the local URL always prints, and the all-interfaces value that gates
// LAN-address discovery. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
const ALL_INTERFACES_HOST = '0.0.0.0'
// --- Client composition (composition decisions live in the composing app) ---
// The composition layer owns one decision: which plugin packages mount (the
// roster). Dependency edges and the boot prefetch tier live in each package's
// dshClient declaration.
/**
* Dev-only plugin: the client HMR driver. Whether it composes in is a
* deployment decision — the dev graph includes its row, the prod graph does
* not mount it at all.
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
* through only when the flag was given; absent, the `cordis.yml` value stands.
* @param host - the bind host, or `undefined` to keep the config default.
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
*/
const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr'
/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */
const CLIENT_BUNDLE_POLL_MS = 500
/** The client plugin roster (flat; per-row boot behavior comes from manifests). */
const CLIENT_PACKAGES = [
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-theme',
'@deepseek-ai/dsh-client-i18n',
'@deepseek-ai/dsh-client-ui-layout',
'@deepseek-ai/dsh-client-ui-sidebar',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-model-selector',
'@deepseek-ai/dsh-client-ui-question',
'@deepseek-ai/dsh-client-ui-trajectory',
] as const
export async function runWeb(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: {
host: { type: 'string', default: LOOPBACK_HOST },
port: { type: 'string', default: '3080' },
dev: { type: 'boolean', default: false },
},
allowPositionals: false,
export async function runWeb(
host: string | undefined,
port: number | undefined,
dev: boolean,
workspaceRoot: string | undefined,
): Promise<void> {
const entry = new AppCLIEntry({
configPath: CONFIG_PATH,
dev,
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },
})
if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
process.stderr.write(
`dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`,
)
process.exit(1)
}
const hostAddress = values.host
const port = Number(values.port)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
process.exit(1)
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: { maxBytes: 65_536 },
sessionTitleLlm: true,
},
})
// Client plugin chain: in-memory Loader tree over the composed roster, then
// the registry that feeds the __DSH_BOOT__ entry graph and
// /plugins/<id>/client.js. All row content comes from dshClient discovery
// over the mounted roster (dev adds the HMR driver row and turns on the
// bundle watch that drives rebuilt frames).
const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []]
const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url)
const webPlugins = createHostWebPluginRegistry({
ctx: host.ctx,
loader: mounted.loader,
resolvePkgJson: mounted.resolvePkgJson,
onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) },
...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {},
})
if (values.dev) {
// Dev visibility (the registry is a library and never prints): list what
// the bundle watch covers, then log every observed rebuild. This is a
// second onRebuilt subscription — the SSE relay inside the webserver is
// unaffected (multicast).
const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev]))
const bundlePaths = [...revs.keys()]
.map(id => webPlugins.clientPath(id))
.filter((path): path is string => path !== undefined)
console.log(
`dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`,
)
webPlugins.onRebuilt((id, rev) => {
console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`)
revs.set(id, rev)
})
}
// Published so the webserver invariant companion can audit manifest/bundle
// consistency; nothing else reads this key.
host.ctx.reflect.provide('webPlugins', webPlugins)
// Dist location is workspace knowledge of this app: resolved through
// @deepseek-ai/dsh-frontend's package exports, not configured.
const require = createRequire(import.meta.url)
let distIndex: string
try {
distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n')
await host.dispose()
process.exit(1)
}
const { ctx, port: boundPort } = await entry.run()
let exiting = false
async function shutdown(code: number): Promise<void> {
const shutdown = (code: number): void => {
if (exiting) return
exiting = true
try {
await server.close()
await host.dispose()
} finally {
process.exit(code)
}
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
let server: Awaited<ReturnType<typeof startWebServer>>
try {
server = await startWebServer(
{ host: hostAddress, port, distIndex, apiHandler: host.handler, webPlugins },
(err: Error) => {
process.stderr.write(`dsh web: ${String(err)}\n`)
void shutdown(1)
},
)
} catch (error: unknown) {
// listen failed (EADDRINUSE…): no server to close, dispose the host directly.
process.stderr.write(`dsh web: ${String(error)}\n`)
await host.dispose()
process.exit(1)
}
const lan = hostAddress === ALL_INTERFACES_HOST
const lanCandidate = host === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
: undefined
const localUrl = `http://${LOOPBACK_HOST}:${server.port}`
console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`)
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
process.on('SIGTERM', () => { void shutdown(0) })
process.on('SIGINT', () => { void shutdown(130) })
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
}

View File

@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseDshArgs } from '../src/args.ts'
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
/**
* `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets
* Commander print to the real streams; capture the exit code and mute output.
*/
function exitCode(argv: string[]): number {
const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
parse(argv)
throw new Error(`expected ${JSON.stringify(argv)} to exit`)
} catch {
return exit.mock.calls.at(-1)?.[0] as number
} finally {
vi.restoreAllMocks()
}
}
afterEach(() => { vi.restoreAllMocks() })
describe('parseDshArgs', () => {
it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => {
expect(parse([])).toEqual({ mode: 'tui' })
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
// Bare `web` carries no host/port: the shipped cordis.yml owns the default.
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
// Host/port are unvalidated pass-throughs (the webserver schema gates them
// at boot); the adapter only coerces the port string to a number.
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
})
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
// Empty resume/prompt would be swallowed downstream; --prompt mixed with
// TUI inputs must not lose them. (Bad host/port are gated by the webserver
// schema at boot, not here.)
expect(exitCode(['--resume='])).toBe(1)
expect(exitCode(['-p', ''])).toBe(1)
expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1)
expect(exitCode(['--bogus'])).toBe(1)
expect(exitCode(['bogus-positional'])).toBe(1)
// A default-surface flag on either side of `web` leaks into program.opts()
// but the web subcommand shares none of them: reject rather than serve.
expect(exitCode(['web', '-p', 'task'])).toBe(1)
expect(exitCode(['web', '--resume', 's'])).toBe(1)
expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1)
})
it('exits 0 for --help (disclosing web) and --version', () => {
expect(exitCode(['--help'])).toBe(0)
expect(exitCode(['--version'])).toBe(0)
})
})

View File

@@ -0,0 +1,55 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
* plain Node (no tsx) with PIPED stdio and assert the TUI refuses to boot.
* `dsh` is the sole terminal front door; the TUI owns no non-TTY fallback, so a
* piped launch must exit nonzero with a stderr pointer at the one-shot `-p`
* mode. The guard fires inside `runTui` BEFORE the Loader resolves the config
* tree — a compose-time throw inside the tree is logged per-entry, not
* rethrown, so without this guard a piped launch would settle into an idle
* UI-less process. The bin resolves its workspace deps through the repo's
* node_modules, so no external consumer is assembled; missing-config fail-loud
* and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's
* built-bin suite, and interactive TTY behavior is PTY-covered by
* examples/tui-agent. Skips before the bin is built.
*/
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
/** Run the built bin with PIPED stdio; resolve with output + exit code. */
function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', (c: string) => { stdout += c })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (c: string) => { stderr += c })
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 25_000)
// Resolve on `close` (all stdio drained), not `exit`, so captured output is complete.
child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
child.on('error', (err) => { clearTimeout(timer); reject(err) })
child.stdin.end()
})
}
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
const { stdout, code, stderr } = await runBuiltBin()
expect(code).not.toBe(0)
expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
expect(stderr).toContain('dsh -p')
// The refusal happens before any plugin mounts: stdout stays silent.
expect(stdout).toBe('')
}, 30_000)
})

View File

@@ -14,9 +14,6 @@
{
"path": "../../packages/host/apiproxy"
},
{
"path": "../../packages/host/runtime"
},
{
"path": "../../packages/host/webserver"
},
@@ -26,6 +23,9 @@
{
"path": "../../packages/ui/app-boot"
},
{
"path": "../../packages/ui/tui"
},
{
"path": "../../packages/util/paths"
},