refactor(cli): bail early in the arg adapter instead of returning errors as data

Address review and cut ceremony: the adapter no longer models help/version/
errors as DshInvocation members. Commander owns those under exitOverride — it
prints usage or the diagnostic and one try/catch in parseDshArgs turns the
thrown CommanderError into process.exit with the intended code. bin.ts drops its
help/version/error cases; the union is the three real modes.

Domain checks bail via command.error(print + exit 1): --prompt rejects an empty
task or a stray config/--resume, empty --resume= fails loud, and --host/--port
are validated. A repeated --resume or a flag captured as a value is Commander's
standard behavior, left alone (a bad id fails loud downstream). dsh --help
discloses web via addHelpText. Net: args.ts 185 -> 112 lines.

Also fixes review nits: built-bin e2e resolves on `close`; the /resume handoff
uses `dsh --resume=<id> -- <config>` so a config named `web` stays a positional;
and stale prose (cordis.yml comment, app-boot module doc + duplicate JSDoc,
ui/README, two feature notes, an agent-loop test name) tracks the shipped state.
Removes tui-demo's now-dead plugin-include dep and vendor/loader + app-boot
tsconfig references.
This commit is contained in:
Turtle
2026-07-25 14:15:25 +08:00
parent 0901140b3f
commit 007e8fd92f
22 changed files with 116 additions and 165 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-24-dsh-commander-argument-adapter.md: c038f4facdc62039b8a31be5d660648fd81aebd2
2026-07-24-dsh-commander-argument-adapter.zh.md: 285917af7c4b0da769ae7595bb1a6e5966adafd9
2026-07-24-dsh-commander-argument-adapter.md: 0f6b18848eacc5d5771e500bf226cc6f680f8d3f
2026-07-24-dsh-commander-argument-adapter.zh.md: ae523d0e37d09ce1f85b317bb0850194045474cb

View File

@@ -10,15 +10,15 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di
## Decision
Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` resolves the invocation into a discriminated `DshInvocation` union: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, `{ mode: 'web', host, port, dev }`, `{ mode: 'help' | 'version', text }`, or `{ mode: 'error', message }`. Commander runs under `exitOverride()` with output captured, so it never writes or exits on its own — `--help`, `--version`, and every parse error come back as data.
Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host, port, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`.
`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module. Each mode module now consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`) and `dsh -p x web` is just a headless prompt whose second positional is dropped, with no cross-command leakage to guard against. Each parser reads Commander's `opts()`/`processedArgs` after `parse()` rather than through action closures. `--host` is a `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])` and `--port` an `argParser` that range-checks 065535, moving both from the inline `runWeb` checks into the parser; `--dev` mounts the client HMR driver and bundle watch. Two post-parse checks preserve the "never silently start fresh" invariant: an empty `--resume=` id and an empty `-p` task each become a `mode: 'error'`, because agent-loop treats an empty resume id as no-resume and an empty prompt has nothing to run. A repeated `--resume` is Commander's natural last-wins (the old bespoke scanner rejected it; last-wins is the standard CLI behavior and needs no special case). `--version` reads this app's `package.json`.
`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`). Each parser reads Commander's `opts()`/`processedArgs` after `parse()`, then bails via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a stray config/`--resume` rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); `--host` must be loopback/all-interfaces and `--port` an integer in 065535, moving both from the inline `runWeb` checks into the parser. `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `dsh --help` discloses the `web` mode through an `addHelpText` line (a real `web` subcommand would hijack the `[config]` positional). `--version` reads this app's `package.json`.
`parseResumeArg` is deleted from `dsh-app-boot` (its export, its README row, and its unit block); the pre-release stance permits the removal. `dsh-app-boot` keeps its boot/env/config/personal-overlay helpers — only the argv scanner leaves.
## Resume without an environment variable
Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a bin that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly as `dsh [config] --resume <id>` from the parsed values, so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`.
Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly from the parsed values as `dsh --resume=<id> [-- <config>]` — the `--` keeps a config named `web` or starting with `-` a positional — so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`.
## One terminal front door: `dsh`
@@ -44,7 +44,7 @@ The argument surface stays inside `apps/cli`, the assembly tier, not a `packages
## Testing
`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), the fail-loud checks (empty resume/prompt, bad host/port, unknown option), and `--help`/`--version` surfacing as data. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots the config as a positional, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command.
`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), and the exit-code behavior for the fail-loud checks (empty resume/prompt, bad host/port, `--prompt` mixed with a config, unknown option) and `--help`/`--version`, captured through a `process.exit` spy. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots the config as a positional, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command.
## Consequences

View File

@@ -10,15 +10,15 @@ Status: implemented
## 决策
argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适配器(SDK bin,如 `create-sdk``dsh-scripts`已经统一采用的那个解析器)。`parseDshArgs(argv, version)` 将调用解析为一个判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }``{ mode: 'headless', prompt }``{ mode: 'web', host, port, dev }``{ mode: 'help' | 'version', text }``{ mode: 'error', message }`。Commander 在 `exitOverride()` 下运行并捕获输出,因此它自身从不写出或退出:`--help``--version` 和每个解析错误都以数据形式返回
argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器SDK bin `create-sdk``dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }``{ mode: 'headless', prompt }``{ mode: 'web', host, port, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0解析错误或领域错误为 1唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`
`bin.ts` 调用一次适配器,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`动态导入所选模式对应的模块。每个模式模块现在只消费已解析好的值:`runTui(config, resume)``runHeadless(task)``runWeb(host, port, dev)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法——`dsh web -p x` 会显式报错(`web` 没有 `-p`,而 `dsh -p x web` 只是一个 headless prompt其第二个位置参数被丢弃无需防范任何跨命令泄漏。每个解析器都`parse()` 之后读取 Commander `opts()`/`processedArgs`,而不是通过 action 闭包。`--host` 是一个 `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])``--port`一个对 065535 范围检查的 `argParser`,二者都从内联的 `runWeb` 检查移入解析器`--dev` 会挂载客户端 HMR热模块替换驱动并启用构建产物监视。两处解析后的检查保留了「绝不静默重新开始」不变式:空的 `--resume=` id 和空的 `-p` 任务各自变为 `mode: 'error'`,因为 agent-loop 把空的 resume id 视为不恢复,而空的 prompt 没有任何内容可运行。重复出现的 `--resume` 采用 Commander 天然的后者胜出(旧的定制扫描器会拒绝它;后者胜出是标准的 CLI 行为,无需特殊处理)。`--version` 读取本应用的 `package.json`
`bin.ts` 调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)``runHeadless(task)``runWeb(host, port, dev)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法`dsh web -p x` 会显式报错(`web` 没有 `-p`。每个解析器都读取 Commander 的 `opts()`/`processedArgs``parse()` 之后),随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式,并在任务为空或存在多余的配置位置参数或 `--resume` 时拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败agent-loop 把 `''` 视为不恢复);`--host` 必须是回环地址或全接口地址`--port` 必须是 065535 范围内的整数,这两项校验都从 `runWeb` 的内联检查移入解析器`--dev` 会挂载客户端 HMR热模块替换驱动并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume``--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`dsh --help` 会展示 `web` 模式,具体通过一行 `addHelpText` 文本实现(真正的 `web` 子命令会劫持 `[config]` 位置参数)。`--version` 读取本应用的 `package.json`
`parseResumeArg``dsh-app-boot` 中删除包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。
## 无需环境变量即可恢复
将与本解析器并行开发的安全会话恢复功能合入时,系统移除了 `RESUME_SESSION_ID` 环境变量。此前,它是将 `--resume` 的值传给随产品提供的配置字段 `resumeSessionId: !!js process.env.RESUME_SESSION_ID` 的唯一通道。`runTui` 现在通过 `boot``prepare(ctx)` 钩子注入已解析的 id`ctx.provide(RESUME_SESSION_ID_KEY, id)``dsh-app-boot` 的新导出,值为 `'resumeSessionId'`tui-agent 和 cordis-agent 的四份配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?``:` 解析为映射;`typeof` 守卫使从未提供该槽位的 bin 也能正常运行。`/resume` 原地交接(`process.execve`)直接根据解析后的值将重新执行的 argv 构造成 `dsh [config] --resume <id>`因此合并时引入的 `replaceResumeArg``parseResumeArg` 一并删除。
将与本解析器并行开发的安全会话恢复功能合入时,系统移除了 `RESUME_SESSION_ID` 环境变量。此前,它是将 `--resume` 的值传给随产品提供的配置字段 `resumeSessionId: !!js process.env.RESUME_SESSION_ID` 的唯一通道。`runTui` 现在通过 `boot``prepare(ctx)` 钩子注入已解析的 id`ctx.provide(RESUME_SESSION_ID_KEY, id)``dsh-app-boot` 的新导出,值为 `'resumeSessionId'`tui-agent 和 cordis-agent 的四份配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?``:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)直接根据解析后的值将重新执行的 argv 构造成 `dsh --resume=<id> [-- <config>]`;其中 `--` 可确保名称为 `web` 或以 `-` 开头的配置仍被视为位置参数。因此合并时引入的 `replaceResumeArg``parseResumeArg` 一并删除。
## 唯一的终端入口:`dsh`
@@ -44,7 +44,7 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适
## 测试
`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态选择模式(包括 `web --dev`、显式报错场景(空的恢复会话 id、空提示词、非法主机、非法端口和未知选项)以及 `--help``--version` 作为数据返回`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts``tui-agent` 组将配置作为位置参数启动,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg``replaceResumeArg` 测试块TUI 单元测试和快照 fixture测试前置数据使用 `dsh --resume {session}` 恢复命令。
`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev`,并验证以下情况各自的退出码行为:显式报错检查(恢复 id提示词为空、host 或 port 无效、`--prompt` 与配置混用、未知选项)以及 `--help``--version`;这些退出码通过 `process.exit` spy 捕获`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts``tui-agent` 组将配置作为位置参数启动,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg``replaceResumeArg` 测试块TUI 单元测试和快照 fixture测试前置数据使用 `dsh --resume {session}` 恢复命令。
## 影响

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-dsh-system-prompt-source-path.md: b54d01488fd7c0b49e06200c93af2b056c9fd00b
2026-07-21-dsh-system-prompt-source-path.zh.md: 208e3dce072f63c280999e15276dce62ff4e5c43
2026-07-21-dsh-system-prompt-source-path.md: 4cb89e8124840bba6633235d195e95957245137c
2026-07-21-dsh-system-prompt-source-path.zh.md: 90c23bed4a3f95155e323c63a68fe2da09543ea6

View File

@@ -16,7 +16,7 @@ The testable logic lives in `dsh-app-boot`, not in `apps/cli`, because `apps/*`
## Scope
Only the `dsh` CLI adds this. The demo bins (`dsh-tui-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs.
Only the `dsh` CLI adds this. The demo bins (`dsh-cli-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs.
## HMR

View File

@@ -16,7 +16,7 @@ Status: implemented
## Scope
只有 `dsh` CLI 会加入这一段。demo bin`dsh-tui-demo``dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。
只有 `dsh` CLI 会加入这一段。demo bin`dsh-cli-demo``dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。
## HMR

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-tui-no-banner.md: f5f4b1b847740e741ec3e33a6116e7497e955bd1
2026-07-21-tui-no-banner.zh.md: 956fe03e2c0b09ea7378ffd53ffbe8d712d1e152
2026-07-21-tui-no-banner.md: a6e0956f289cfc810da766fd0cae94b97baf5280
2026-07-21-tui-no-banner.zh.md: acc5614727cf67881832af1685be557d675696e7

View File

@@ -13,7 +13,7 @@ The TUI opened with a boxed product banner ("DEEPSEEK HARNESS" + model/session d
## Decision
- `HeaderComponent`, the sweep animation, and its lifecycle wiring are deleted. The TUI mounts straight into the transcript; startup renders nothing above the separator.
- The model name moves into the footer status line's left segment (`<model> <cwd> ↑tokens ↓tokens`), so the session's driving model stays visible at all times, not just at boot. The session id is no longer displayed — it lives in the session log and `./.sessions` filenames, and `RESUME_SESSION_ID` consumers retrieve it there.
- The model name moves into the footer status line's left segment (`<model> <cwd> ↑tokens ↓tokens`), so the session's driving model stays visible at all times, not just at boot. The session id is no longer displayed — it lives in the session log and `./.sessions` filenames, where `dsh --resume <id>` and the `/resume` selector retrieve it.
- `welcome`, when configured, renders as the transcript's first line (a muted notice) inside `rebuildTranscript`, so palette swaps preserve it. Unset renders nothing. Fixtures keep their configured welcomes; the PTY smoke's boot marker becomes the footer's model name, the only mounted-TUI text guaranteed to render regardless of cwd length.
This supersedes the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md) entirely: both the sweep and the banner it animated are gone.

View File

@@ -13,7 +13,7 @@ TUI 启动时展示一个带框的产品横幅("DEEPSEEK HARNESS" + 模型/会
## Decision
- 删除 `HeaderComponent`、扫入动画及其生命周期接线。TUI 直接挂载进 transcript启动时分隔线之上不渲染任何东西。
- 模型名移入页脚状态行的左段(`<model> <cwd> ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`RESUME_SESSION_ID` 的使用者从那里获取
- 模型名移入页脚状态行的左段(`<model> <cwd> ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`dsh --resume <id>``/resume` 选择器会从中获取该 id
- 配置了 `welcome` 时,它作为 transcript 的第一行(一条弱化的通知)在 `rebuildTranscript` 内渲染因此调色板切换会保留它。未设置则什么也不渲染。fixture 保留各自配置的欢迎语PTY 冒烟测试的启动标记改为页脚的模型名——无论 cwd 多长都保证渲染的唯一挂载后文本。
本 note 完全取代[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md):扫入动画和它所动画的横幅都已移除。

View File

@@ -1,16 +1,14 @@
/**
* 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; each mode module then consumes the
* already-parsed values instead of re-reading argv. Output is suppressed and
* `exitOverride` is set so Commander never writes or exits on its own — every
* outcome (including `--help`/`--version` and parse errors) is returned to the
* caller as data. The `web` subcommand is a reserved first token dispatched to
* its own parser, so root flags and `web` flags never share a grammar.
* and dynamic-imports that mode's module. 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.
* The `web` subcommand is a reserved first token dispatched to its own parser.
* @module @deepseek-ai/dsh/args
*/
import { Command, CommanderError, InvalidArgumentError, Option } from 'commander'
import { Command, CommanderError } from 'commander'
/** The loopback host `dsh web` binds by default. */
export const LOOPBACK_HOST = '127.0.0.1'
@@ -31,10 +29,7 @@ interface HeadlessInvocation {
prompt: string
}
/**
* Browser UI: `dsh web`. Host constrained to {@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST};
* port already coerced and range-checked; `dev` mounts the client HMR driver and bundle watch.
*/
/** Browser UI: `dsh web`. Host is loopback/all-interfaces, port a 065535 integer, `dev` mounts the HMR driver. */
interface WebInvocation {
mode: 'web'
host: string
@@ -42,120 +37,76 @@ interface WebInvocation {
dev: boolean
}
/** `--help` or `--version` requested: `bin.ts` prints `text` to stdout and exits 0. */
interface InfoInvocation {
mode: 'help' | 'version'
text: string
}
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
/** A parse error (unknown option, missing/invalid argument): `bin.ts` prints `message` to stderr and exits 1. */
interface ErrorInvocation {
mode: 'error'
message: string
}
/** The resolved `dsh` invocation: exactly one mode, all values parsed and validated. */
export type DshInvocation =
| TuiInvocation
| HeadlessInvocation
| WebInvocation
| InfoInvocation
| ErrorInvocation
/** Coerce `--port` to an integer in 065535; a bad value fails loud as a parse error. */
function parsePort(raw: string): number {
const port = Number(raw)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new InvalidArgumentError(`invalid --port ${raw}`)
}
return port
}
/**
* A configured `Command` under `exitOverride` with output captured into `sink`,
* so `--help`, `--version`, and parse errors surface as thrown `CommanderError`s
* (see {@link settle}) rather than writing to a stream or exiting.
*/
function program(name: string, version: string, sink: string[]): Command {
return new Command()
.name(name)
.version(version, '-V, --version', 'output the version number')
.exitOverride()
.configureOutput({
writeOut: chunk => void sink.push(chunk),
writeErr: chunk => void sink.push(chunk),
})
}
/**
* Run `command.parse` and map its thrown `CommanderError` to an info/error
* invocation, or `undefined` when the parse succeeded (the caller then reads the
* parsed options).
*/
function settle(command: Command, argv: readonly string[], sink: string[]): InfoInvocation | ErrorInvocation | undefined {
try {
command.parse(argv, { from: 'user' })
return undefined
} catch (error) {
/* v8 ignore next -- Commander only throws CommanderError from parse under exitOverride */
if (!(error instanceof CommanderError)) throw error
if (error.code === 'commander.helpDisplayed') return { mode: 'help', text: sink.join('') }
if (error.code === 'commander.version') return { mode: 'version', text: sink.join('') }
return { mode: 'error', message: error.message }
}
/** A `Command` under `exitOverride`, so {@link parseDshArgs} owns the exit, named for its usage line. */
function program(name: string, version: string): Command {
return new Command().name(name).version(version, '-V, --version', 'output the version number').exitOverride()
}
/** Parse `dsh web` arguments (everything after the `web` token). */
function parseWeb(argv: readonly string[], version: string): DshInvocation {
const sink: string[] = []
const web = program('dsh web', version, sink)
function parseWeb(argv: readonly string[], version: string): WebInvocation {
const web = program('dsh web', version)
.description('serve the browser UI')
.addOption(new Option('--host <host>', 'bind host').choices([LOOPBACK_HOST, ALL_INTERFACES_HOST]).default(LOOPBACK_HOST))
.addOption(new Option('--port <port>', 'listen port').default(DEFAULT_WEB_PORT).argParser(parsePort))
.option('--host <host>', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`, LOOPBACK_HOST)
.option('--port <port>', 'listen port', String(DEFAULT_WEB_PORT))
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
const settled = settle(web, argv, sink)
if (settled !== undefined) return settled
const { host, port, dev } = web.opts<{ host: string; port: number; dev?: boolean }>()
return { mode: 'web', host, port, dev: dev ?? false }
web.parse(argv, { from: 'user' })
const { host, port, dev } = web.opts<{ host: string; port: string; dev?: boolean }>()
if (host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) {
web.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`)
}
const portNumber = Number(port)
if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) {
web.error('error: --port must be an integer in 0-65535')
}
return { mode: 'web', host, port: portNumber, dev: dev === true }
}
/** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */
function parseRoot(argv: readonly string[], version: string): DshInvocation {
const sink: string[] = []
const root = program('dsh', version, sink)
const root = program('dsh', version)
.description('dsh: interactive TUI, headless task, and browser UI')
.argument('[config]', 'config to boot instead of the shipped default (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)')
const settled = settle(root, argv, sink)
if (settled !== undefined) return settled
// Disclose the web mode in `dsh --help`; a real `web` subcommand would
// hijack the `[config]` positional. `parseDshArgs` intercepts `web` first.
.addHelpText('after', '\nCommands:\n web serve the browser UI (run `dsh web --help`)')
root.parse(argv, { from: 'user' })
const { prompt, resume } = root.opts<{ prompt?: string; resume?: string }>()
const config = root.processedArgs[0] as string | undefined
if (prompt !== undefined) {
// A headless prompt owns the invocation; an empty task has nothing to run.
if (prompt === '') return { mode: 'error', message: "error: option '-p, --prompt <task>' must not be empty" }
// A headless prompt owns the invocation; an empty task has nothing to run,
// and a config or --resume alongside it is a TUI input that must not
// silently vanish from the run.
if (prompt === '') root.error('error: --prompt needs a task')
if (config !== undefined || resume !== undefined) root.error('error: --prompt takes no config or --resume')
return { mode: 'headless', prompt }
}
// 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 (resume === '') return { mode: 'error', message: "error: option '--resume <id>' must not be empty" }
return {
mode: 'tui',
...config !== undefined ? { config } : {},
...resume !== undefined ? { resume } : {},
}
if (resume === '') root.error('error: --resume needs a session id')
return { mode: 'tui', ...config !== undefined && { config }, ...resume !== undefined && { resume } }
}
/**
* Resolve the raw argv into a single {@link DshInvocation}. Never writes to a
* stream and never exits; `--help`/`--version` and every parse error come back
* as data for `bin.ts` to act on. A leading `web` token dispatches to the web
* parser; everything else is the default TUI/headless grammar.
* Resolve the raw argv into a {@link DshInvocation}, or print and exit for
* `--help`/`--version`/a parse error. A leading `web` token dispatches to the
* web parser; everything else is the default TUI/headless grammar.
* @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, discriminated by `mode`.
* @returns the resolved invocation (only reached on a valid, non-help invocation).
*/
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version)
try {
return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version)
} 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)
}
}

View File

@@ -3,8 +3,8 @@
* dsh — command-line entry. Parses argv once through the Commander adapter and
* switches on the resolved mode; dynamic imports keep unrelated modes out of
* each dispatch path. `web` and headless prompts run their own module;
* everything else opens the TUI. `--help`/`--version` print and exit 0; a parse
* error prints to stderr and exits 1.
* everything else opens the TUI. The adapter itself prints and exits for
* `--help`/`--version`/a parse error, so only a valid mode reaches the switch.
* @module @deepseek-ai/dsh/bin
*/
@@ -45,13 +45,6 @@ switch (invocation.mode) {
await runTui(invocation.config, invocation.resume)
break
}
case 'help':
case 'version':
process.stdout.write(invocation.text)
process.exit(0)
case 'error':
process.stderr.write(`${invocation.message}\n`)
process.exit(1)
default:
invocation satisfies never
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)

View File

@@ -71,7 +71,6 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
* @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: {

View File

@@ -74,13 +74,13 @@ export async function runTui(config: string | undefined, resumeSessionId: string
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 the optional config positional and `--resume <id>`.
// The `--` guard keeps a config named like a flag or `web` a positional.
const nextArgv = [
process.execPath,
...process.execArgv,
entry,
...config !== undefined ? [config] : [],
'--resume',
sessionId,
`--resume=${sessionId}`,
...config !== undefined ? ['--', config] : [],
]
try {
await current.fiber.dispose()

View File

@@ -1,8 +1,28 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ALL_INTERFACES_HOST, LOOPBACK_HOST, 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' })
@@ -10,25 +30,24 @@ describe('parseDshArgs', () => {
expect(parse(['--resume', 'sess', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080, dev: false })
expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080']))
.toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080, dev: false })
expect(parse(['web', '--dev'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080, dev: true })
expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080', '--dev']))
.toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080, dev: true })
})
it('fails loud instead of silently starting fresh or serving on bad input', () => {
// An empty resume/prompt would otherwise be swallowed (agent-loop treats an
// empty resume id as no-resume); a bad host/port must not reach the listener.
expect(parse(['--resume=']).mode).toBe('error')
expect(parse(['-p', '']).mode).toBe('error')
expect(parse(['web', '--host', '10.0.0.1']).mode).toBe('error')
expect(parse(['web', '--port', 'abc']).mode).toBe('error')
expect(parse(['--bogus']).mode).toBe('error')
it('exits nonzero instead of silently starting fresh, serving, or dropping inputs', () => {
// Empty resume/prompt would be swallowed downstream; bad host/port must not
// reach the listener; --prompt mixed with TUI inputs must not lose them.
expect(exitCode(['--resume='])).toBe(1)
expect(exitCode(['-p', ''])).toBe(1)
expect(exitCode(['web', '--host', '10.0.0.1'])).toBe(1)
expect(exitCode(['web', '--port', 'abc'])).toBe(1)
expect(exitCode(['web', '--port='])).toBe(1)
expect(exitCode(['config.yml', '-p', 'x'])).toBe(1)
expect(exitCode(['--bogus'])).toBe(1)
})
it('surfaces --help and --version as printable data, not a process exit', () => {
const help = parse(['--help'])
expect(help).toMatchObject({ mode: 'help' })
if (help.mode === 'help') expect(help.text).toContain('Usage: dsh')
expect(parse(['--version'])).toEqual({ mode: 'version', text: '1.2.3\n' })
it('exits 0 for --help (disclosing web) and --version', () => {
expect(exitCode(['--help'])).toBe(0)
expect(exitCode(['--version'])).toBe(0)
})
})

View File

@@ -36,7 +36,8 @@ function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }
child.kill('SIGKILL')
reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 25_000)
child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
// 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()
})

View File

@@ -34,8 +34,8 @@
model: deepseek-v4-pro
# `dsh --resume <id>` provides the session id on the boot context (the ids
# live under ./.sessions); with no flag the identifier is undefined and a
# fresh session starts each run. The demo bin never provides it, so the
# typeof guard reads undefined there rather than throwing.
# fresh session starts each run. The typeof guard tolerates a launcher that
# never provides the slot, reading undefined rather than throwing.
resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"
persistenceRoot: './.sessions'
# Printed on exit and listed by `/resume`; `{session}` fills the live id.

View File

@@ -359,7 +359,7 @@ describe('config-driven session id', () => {
await ctx2.fiber.dispose()
})
it('config-driven resumeSessionId continues a persisted session (env-var resume)', async () => {
it('config-driven resumeSessionId continues a persisted session', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-resume-'))
dirs.push(root)

View File

@@ -27,7 +27,6 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
@@ -51,7 +50,6 @@
"schemastery": "^3.17.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",

View File

@@ -14,12 +14,6 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../ui/app-boot"
},
{
"path": "../../core/agent"
},

View File

@@ -18,4 +18,4 @@ A UI integration is a client-driver plugin, not a loop change: it consumes the e
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
The runnable app bundles that bake these bridges into boot bins — the TUI app, ACP server app, and JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
The runnable app bundles that compose these bridges — the TUI app, ACP server app, and JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.

View File

@@ -1,5 +1,5 @@
/**
* Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
* optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
* against a leaf `cordis.yml` until the whole tree has settled.
@@ -156,7 +156,6 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
}
}
/**
/**
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`

5
pnpm-lock.yaml generated
View File

@@ -1539,9 +1539,6 @@ importers:
packages/examples/tui-demo:
devDependencies:
'@cordisjs/plugin-include':
specifier: workspace:^
version: link:../../../vendor/include
'@cordisjs/plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
@@ -1604,7 +1601,7 @@ importers:
version: link:../../context/workspace-context
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
schemastery:
specifier: ^3.17.0
version: 3.18.0