Просмотр исходного кода

Merge branch 'master' into fix-update-builderror

Tianyi Cui 1 месяц назад
Родитель
Сommit
0fdaca601a
55 измененных файлов с 484 добавлено и 409 удалено
  1. 6 0
      .agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml
  2. 14 0
      .agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md
  3. 14 0
      .agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml
  5. 1 1
      .agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md
  6. 1 1
      .agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml
  8. 1 1
      .agents/notes/implemented/feature/2026-07-21-tui-no-banner.md
  9. 1 1
      .agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md
  10. 2 2
      .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml
  11. 1 1
      .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md
  12. 1 1
      .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md
  13. 5 3
      apps/cli/README.md
  14. 1 0
      apps/cli/package.json
  15. 143 0
      apps/cli/src/args.ts
  16. 37 13
      apps/cli/src/bin.ts
  17. 7 13
      apps/cli/src/headless.ts
  18. 23 21
      apps/cli/src/tui.ts
  19. 29 37
      apps/cli/src/web.ts
  20. 61 0
      apps/cli/tests/args.spec.ts
  21. 55 0
      apps/cli/tests/built-bin.e2e.ts
  22. 2 2
      docs/config-catalog.md
  23. 1 2
      docs/module-graph.md
  24. 1 1
      examples/README.md
  25. 1 1
      examples/cordis-agent/cordis.yml
  26. 1 1
      examples/cordis-agent/tests/keyless-smoke.e2e.ts
  27. 1 1
      examples/tui-agent/README.md
  28. 1 1
      examples/tui-agent/code-mode.cordis.yml
  29. 6 4
      examples/tui-agent/cordis.yml
  30. 1 1
      examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml
  31. 3 1
      examples/tui-agent/tests/pty-harness.ts
  32. 7 20
      examples/tui-agent/tests/tui-keyless-smoke.e2e.ts
  33. 1 2
      knip.json
  34. 1 1
      package.json
  35. 1 1
      packages/core/agent-loop/tests/config-session-id.spec.ts
  36. 2 2
      packages/examples/README.md
  37. 4 4
      packages/examples/tui-demo/README.md
  38. 1 13
      packages/examples/tui-demo/package.json
  39. 0 27
      packages/examples/tui-demo/src/bin.ts
  40. 2 2
      packages/examples/tui-demo/src/index.ts
  41. 0 98
      packages/examples/tui-demo/tests/built-bin.e2e.ts
  42. 0 6
      packages/examples/tui-demo/tsconfig.json
  43. 6 6
      packages/examples/tui-demo/tsdown.config.ts
  44. 3 3
      packages/support/loader-smoke/tests/example-launch.spec.ts
  45. 1 1
      packages/ui/README.md
  46. 3 4
      packages/ui/app-boot/README.md
  47. 12 57
      packages/ui/app-boot/src/index.ts
  48. 1 35
      packages/ui/app-boot/tests/app-boot.spec.ts
  49. 1 1
      packages/ui/tui/tests/tui.snapshot.ts
  50. 3 3
      packages/ui/tui/tests/tui.spec.ts
  51. 4 7
      pnpm-lock.yaml
  52. 1 1
      scripts/demo-code-mode.mjs
  53. 1 0
      tsconfig.host.json
  54. 1 0
      vitest.config.ts
  55. 3 1
      vitest.e2e.config.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: c1124f67a2c5d9fbba1e04c896a1021c370befc9
+2026-07-24-dsh-commander-argument-adapter.zh.md: be96c354a7dea53446f3c2e35f0e4967265596f5

Разница между файлами не показана из-за своего большого размера
+ 14 - 0
.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md


Разница между файлами не показана из-за своего большого размера
+ 14 - 0
.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md


+ 2 - 2
.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml

@@ -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

+ 1 - 1
.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md

@@ -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
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md

@@ -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
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml

@@ -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

+ 1 - 1
.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md

@@ -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.

+ 1 - 1
.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md

@@ -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):扫入动画和它所动画的横幅都已移除。

+ 2 - 2
.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml

@@ -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-20-retire-readline-front-door.md: 166e9ca17989ff14f9c3f38cd9650387581b0f78
-2026-07-20-retire-readline-front-door.zh.md: 8c2568f60c3a12fb16a9ef4fe1775e875966a49a
+2026-07-20-retire-readline-front-door.md: bf2ff2e1498668e4a45b9fcf310d92b2f3ea3099
+2026-07-20-retire-readline-front-door.zh.md: ee22437a9e7ab8fbde612ba14f296c3d230928f1

+ 1 - 1
.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md

@@ -26,7 +26,7 @@ Pipes remain the default test medium. PTY-driven subprocess tests are sanctioned
 
 - `examples/echo-agent/tests/echo.e2e.ts` proves the Loader boot + mock-model tool round-trip through `stream-json` records instead of readline transcript lines.
 - The CI demo-smoke gate (`scripts/run-gates.ts`, AGENTS.md) runs `demo:echo --output-format stream-json -p "echo ci smoke"` and parses the records structurally.
-- `packages/examples/tui-demo/tests/built-bin.e2e.ts` proves the built bin's piped-launch refusal (nonzero exit + pointer at `dsh-cli-demo`); the echo-round-trip-under-plain-Node and missing-config fail-loud proofs live in `cli-demo`'s built-bin suite.
+- The TUI's piped-launch refusal (nonzero exit + pointer at the one-shot CLI) is covered by `apps/cli/tests/built-bin.e2e.ts` (the `dsh` TTY guard under plain Node); the echo-round-trip-under-plain-Node and missing-config fail-loud proofs live in `cli-demo`'s built-bin suite.
 - `packages/context/time-context/tests/time-context.e2e.ts` runs one one-shot turn; multi-turn elapsed rendering stays unit-covered in its spec.
 
 ## Accepted losses

+ 1 - 1
.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md

@@ -26,7 +26,7 @@ Status: implemented
 
 - `examples/echo-agent/tests/echo.e2e.ts` 通过 `stream-json` 记录证明 Loader 启动 + mock 模型的工具往返,而不是匹配 readline 文本记录行。
 - CI 演示冒烟门禁(`scripts/run-gates.ts`、AGENTS.md)运行 `demo:echo --output-format stream-json -p "echo ci smoke"` 并结构化解析记录。
-- `packages/examples/tui-demo/tests/built-bin.e2e.ts` 证明构建产物 bin 对管道启动的拒绝(非零退出 + 指向 `dsh-cli-demo` 的提示);纯 Node 下的 echo 往返证明与缺失配置的快速失败证明位于 `cli-demo` 的 built-bin 套件。
+- TUI 对管道启动的拒绝(非零退出 + 指向单次任务 CLI 的提示)由 `apps/cli/tests/built-bin.e2e.ts`(纯 Node 下的 `dsh` TTY 守卫)覆盖;纯 Node 下的 echo 往返证明与缺失配置的快速失败证明位于 `cli-demo` 的 built-bin 套件。
 - `packages/context/time-context/tests/time-context.e2e.ts` 运行一个单次任务轮次;多轮 elapsed 渲染仍由其单元测试覆盖。
 
 ## 接受的损失

+ 5 - 3
apps/cli/README.md

@@ -1,11 +1,13 @@
 # `@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>` 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 resume flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session;
+- 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.

+ 1 - 0
apps/cli/package.json

@@ -74,6 +74,7 @@
     "@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"
   },

+ 143 - 0
apps/cli/src/args.ts

@@ -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
+}

+ 37 - 13
apps/cli/src/bin.ts

@@ -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)}`)
 }

+ 7 - 13
apps/cli/src/headless.ts

@@ -8,7 +8,6 @@
  * the final assistant text, exits (completed → 0, else 1).
  */
 
-import { parseArgs } from 'node:util'
 import { fileURLToPath } from 'node:url'
 import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
 import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -67,18 +66,13 @@ 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 entry = new AppCLIEntry({
     configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),

+ 23 - 21
apps/cli/src/tui.ts

@@ -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,8 +18,7 @@ import {
   installFailLoud,
   loadEnv,
   loadPersonalPatches,
-  parseResumeArg,
-  replaceResumeArg,
+  RESUME_SESSION_ID_KEY,
   resolveConfigPath,
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -28,12 +27,6 @@ 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.
@@ -48,26 +41,30 @@ 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
+  // 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 } = {}
@@ -75,13 +72,15 @@ export async function runTui(argv: string[]): Promise<void> {
     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,
-        ...replaceResumeArg(process.argv.slice(2), sessionId),
+        `--resume=${sessionId}`,
+        ...config !== undefined ? ['--config', config] : [],
       ]
-      process.env[RESUME_SESSION_ID_ENV] = sessionId
       try {
         await current.fiber.dispose()
         execve(process.execPath, nextArgv, process.env)
@@ -94,9 +93,12 @@ export async function runTui(argv: string[]): Promise<void> {
   }
   const ctx = await boot(
     NAME,
-    resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined),
+    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)
     },
   )

+ 29 - 37
apps/cli/src/web.ts

@@ -1,51 +1,43 @@
 /**
- * `dsh web` — thin bin over the config-tree boot: parse argv, run
- * AppCLIEntry, print the URL line, wire signals. All composition lives in
- * cordis.yml; all boot glue lives in AppCLIEntry.
+ * `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 { fileURLToPath } from 'node:url'
 import { AppCLIEntry } from './app-cli-entry.ts'
 
-const LOOPBACK_HOST = '127.0.0.1'
-const ALL_INTERFACES_HOST = '0.0.0.0'
-
 const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
 
-export async function runWeb(argv: string[]): Promise<void> {
-  const { values } = parseArgs({
-    args: argv,
-    options: {
-      host: { type: 'string' },
-      port: { type: 'string' },
-      dev: { type: 'boolean', default: false },
-      'workspace-root': { type: 'string' },
-    },
-    allowPositionals: false,
-  })
-  if (values.host !== undefined && 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)
-  }
-  let port: number | undefined
-  if (values.port !== undefined) {
-    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)
-    }
-  }
+// 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'
 
+/**
+ * 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.
+ */
+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: values.dev,
-    ...values.host !== undefined ? { host: values.host } : {},
-    ...port !== undefined ? { port } : {},
-    ...values['workspace-root'] !== undefined ? { workspaceRoot: values['workspace-root'] } : {},
+    dev,
+    ...host !== undefined && { host },
+    ...port !== undefined && { port },
+    ...workspaceRoot !== undefined && { workspaceRoot },
   })
   const { ctx, port: boundPort } = await entry.run()
 
@@ -56,7 +48,7 @@ export async function runWeb(argv: string[]): Promise<void> {
     void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
   }
 
-  const lanCandidate = values.host === ALL_INTERFACES_HOST
+  const lanCandidate = host === ALL_INTERFACES_HOST
     ? Object.values(networkInterfaces()).flat()
       .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
     : undefined

+ 61 - 0
apps/cli/tests/args.spec.ts

@@ -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)
+  })
+})

+ 55 - 0
apps/cli/tests/built-bin.e2e.ts

@@ -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)
+})

+ 2 - 2
docs/config-catalog.md

@@ -1806,8 +1806,8 @@ export interface Config {
   /**
    * Shell command template the TUI prints on exit and lists under `/resume`,
    * with `{session}` replaced by the live session id (forwarded to the front
-   * door). Set it to a command that resumes via this app's env var, e.g.
-   * `RESUME_SESSION_ID={session} dsh`.
+   * door). Set it to a command that resumes the session, e.g.
+   * `dsh --resume {session}`.
    */
   resumeCommand?: string
   /** Full-screen TUI presentation settings. */

+ 1 - 2
docs/module-graph.md

@@ -778,7 +778,6 @@ flowchart TD
   pkg_tui_demo --> pkg_agent
   pkg_tui_demo --> pkg_agent_loop
   pkg_tui_demo --> pkg_agent_spine_demo
-  pkg_tui_demo --> pkg_app_boot
   pkg_tui_demo --> pkg_command_goal
   pkg_tui_demo --> pkg_commands
   pkg_tui_demo --> pkg_invariants
@@ -933,4 +932,4 @@ flowchart TD
 | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
 | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
 | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
-| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
+| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |

+ 1 - 1
examples/README.md

@@ -1,6 +1,6 @@
 # Examples
 
-Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the `demo:*` scripts invoke each app package's bin.
+Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI (which mounts the `tui-demo` bundle), and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins.
 
 ## headless-agent
 

+ 1 - 1
examples/cordis-agent/cordis.yml

@@ -56,7 +56,7 @@
   config:
     provider: deepseek
     model: deepseek-v4-pro
-    resumeSessionId: !!js process.env.RESUME_SESSION_ID
+    resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"
     persistenceRoot: './.sessions'
     workspaceContext:
       maxBytes: 65536

+ 1 - 1
examples/cordis-agent/tests/keyless-smoke.e2e.ts

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
 import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
 import { runTuiPtySmoke } from '../../tui-agent/tests/pty-harness.ts'
 
-const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url))
+const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
 const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 

+ 1 - 1
examples/tui-agent/README.md

@@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio
 dsh --resume <prior-session-id>
 ```
 
-`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume <id>`. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID=<prior-session-id> pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately.
+`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume <id>`. The TUI still prints that command on exit and shows it when a custom host cannot hand off. `dsh --resume <id>` provides the id on the boot context, which `cordis.yml` reads (`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`); with no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately.
 
 ## Code Mode
 

+ 1 - 1
examples/tui-agent/code-mode.cordis.yml

@@ -10,7 +10,7 @@
         config:
           provider: deepseek
           model: deepseek-v4-pro
-          resumeSessionId: !!js process.env.RESUME_SESSION_ID
+          resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"
           persistenceRoot: './.sessions'
           resumeCommand: 'dsh --resume {session}'
           workspaceContext:

+ 6 - 4
examples/tui-agent/cordis.yml

@@ -32,12 +32,14 @@
   config:
     provider: deepseek
     model: deepseek-v4-pro
-    # Set RESUME_SESSION_ID to continue a prior persisted session (the ids live
-    # under ./.sessions); unset starts a fresh session each run.
-    resumeSessionId: !!js process.env.RESUME_SESSION_ID
+    # `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 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.
-    # `dsh --resume <id>` sets RESUME_SESSION_ID above, so run it from this cwd.
+    # `dsh --resume <id>` resumes that session, so run it from this cwd.
     resumeCommand: 'dsh --resume {session}'
     workspaceContext:
       maxBytes: 65536

+ 1 - 1
examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml

@@ -29,7 +29,7 @@
     # The smoke's log inspection reads plain `.jsonl`; keep the scripted
     # fixture uncompressed like the other snapshot-facing configs.
     persistenceCompression: none
-    resumeSessionId: !!js process.env.RESUME_SESSION_ID
+    resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"
     resumeCommand: 'dsh --resume {session}'
     workspaceContext:
       maxBytes: 65536

+ 3 - 1
examples/tui-agent/tests/pty-harness.ts

@@ -192,10 +192,12 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise<strin
     await options.prepare?.(cwd)
     const launch = resolveExampleLaunch({
       srcBin: options.binScript,
+      // `configPath` is the dsh `--config <path>` tree override; `configArgs`
+      // is the raw-args escape (e.g. `['--resume', <id>]`) for other flags.
       configArgs: options.configArgs !== undefined
         ? [...options.configArgs]
         /* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */
-        : [options.configPath ?? './cordis.yml'],
+        : options.configPath !== undefined ? ['--config', options.configPath] : [],
       tsconfigPath: options.tsconfigPath,
       env: {
         DSH_HOME: join(cwd, '.dsh'),

+ 7 - 20
examples/tui-agent/tests/tui-keyless-smoke.e2e.ts

@@ -8,7 +8,6 @@ import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/d
 import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
 import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
 
-const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url))
 const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url))
 const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
 const codeModeConfigPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
@@ -87,11 +86,11 @@ async function readLoggedSystemPrompt(cwd: string): Promise<string> {
   throw new Error(`session log ${logRelPath} has no request/header event`)
 }
 
-/** Shared defaults: the keyless key, the tui-demo bin, and the live cordis.yml. */
+/** Shared defaults: the keyless key, the dsh bin, and the live cordis.yml (passed as the positional config). */
 function smoke(overrides: Partial<TuiPtySmokeOptions> & { label: string }): Promise<string> {
   return runTuiPtySmoke({
     tempDirPrefix: 'tui-agent-smoke-',
-    binScript,
+    binScript: dshBinScript,
     configPath,
     tsconfigPath,
     env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
@@ -246,18 +245,6 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
     expect(output).toContain('\u001B[?2004l')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
-  it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => {
-    const output = await smoke({
-      label: 'tui-agent resume failure',
-      tempDirPrefix: 'tui-agent-resume-',
-      env: {
-        DEEPSEEK_API_KEY: 'keyless-tui-no-call',
-        RESUME_SESSION_ID: 'missing-session',
-      },
-      expectedExitCode: 1,
-    })
-    expect(output).toContain('ui-tui: session "missing-session" failed to start:')
-  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 })
 
 describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
@@ -266,7 +253,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
       label: 'dsh in-place resume',
       tempDirPrefix: 'dsh-in-place-resume-',
       binScript: dshBinScript,
-      configArgs: [scriptedConfigPath],
+      configPath: scriptedConfigPath,
       prepare: seedResumeSession,
       actions: [
         { waitFor: 'scripted TUI ready.', send: '/resume\r' },
@@ -341,9 +328,9 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
 
   it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => {
     // The flag path end to end: apps/cli parses `--resume missing-session` and
-    // sets RESUME_SESSION_ID, the shipped config's `!!js` reads it, and the
-    // resume fails loud — proving the printed `dsh --resume <id>` hint reaches
-    // the same intake as the env var.
+    // provides the id on the boot context, the shipped config's `!!js` reads it
+    // as a bare identifier, and the resume fails loud — proving the printed
+    // `dsh --resume <id>` hint reaches the config resume intake with no env var.
     const output = await smoke({
       label: 'dsh resume flag failure',
       tempDirPrefix: 'dsh-resume-flag-',
@@ -363,7 +350,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
       label: 'dsh source-path prompt',
       tempDirPrefix: 'dsh-source-path-',
       binScript: dshBinScript,
-      configArgs: [scriptedConfigPath],
+      configPath: scriptedConfigPath,
       actions: [
         ...SELECT_PRO_MODEL,
         { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },

+ 1 - 2
knip.json

@@ -409,8 +409,7 @@
     },
     "packages/examples/tui-demo": {
       "entry": [
-        "tests/**/*.spec.ts",
-        "tests/**/*.e2e.ts"
+        "tests/**/*.spec.ts"
       ],
       "project": [
         "src/**/*.ts",

+ 1 - 1
package.json

@@ -93,7 +93,7 @@
     "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
     "demo:tui": "node --import tsx apps/cli/src/bin.ts",
     "demo:code-mode": "node scripts/demo-code-mode.mjs",
-    "demo:cordis": "node --import tsx packages/examples/tui-demo/src/bin.ts examples/cordis-agent/cordis.yml",
+    "demo:cordis": "node --import tsx apps/cli/src/bin.ts --config examples/cordis-agent/cordis.yml",
     "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
     "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web",
     "dev:web": "tsx scripts/dev-web.ts --poll",

+ 1 - 1
packages/core/agent-loop/tests/config-session-id.spec.ts

@@ -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)
 

+ 2 - 2
packages/examples/README.md

@@ -5,12 +5,12 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
 | Package | npm name | Role |
 |---|---|---|
 | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack |
-| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
+| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app bundle: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent; no bin, booted by the [`dsh`](../../apps/cli/README.md) CLI |
 | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
 | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP automation server app: the spine + persisted goals + JSONL persistence + the [`acp`](../acp/acp/README.md) bridge (no stdout logger), with a boot `bin` |
 | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
 
-`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
+`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors. `cli-demo` and `acp-demo` own their boot bins; `tui-demo` ships only the bundle plugin, and the product [`dsh`](../../apps/cli/README.md) CLI is its terminal front door. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
 
 These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), human/SDK channels and boot glue in [`ui/`](../ui/README.md), the automation transport in [`acp/`](../acp/README.md), and swappable backends in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.
 

+ 4 - 4
packages/examples/tui-demo/README.md

@@ -1,8 +1,8 @@
 # @deepseek-ai/dsh-tui-demo
 
-The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
+The full-screen terminal app bundle: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). A `cordis.yml` mounts it as one entry; the [`dsh`](../../../apps/cli/README.md) CLI is the front door that boots such a config.
 
-Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback.
+Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This bundle requires a TTY pair and has no line-oriented fallback.
 
 ## What it bakes in
 
@@ -47,9 +47,9 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
 
 Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff.
 
-## The bin
+## Front door
 
-`dsh-tui-demo [path-to-cordis.yml]` defaults to `./cordis.yml`, loads the optional cwd `.env`, boots the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node.
+This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: bare `dsh` boots the shipped `examples/tui-agent/cordis.yml` (which mounts this bundle), and `dsh --config <path-to-cordis.yml>` boots an alternate leaf config that mounts it. It loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node.
 
 ## Example leaf
 

+ 1 - 13
packages/examples/tui-demo/package.json

@@ -1,14 +1,11 @@
 {
   "name": "@deepseek-ai/dsh-tui-demo",
-  "description": "Full-screen terminal app: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent",
+  "description": "Full-screen TUI app bundle plugin: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent (mounted by the dsh CLI's config)",
   "version": "0.0.1",
   "private": true,
   "type": "module",
   "main": "lib/index.js",
   "types": "lib/types/index.d.ts",
-  "bin": {
-    "dsh-tui-demo": "lib/bin.js"
-  },
   "exports": {
     ".": {
       "types": "./lib/types/index.d.ts",
@@ -18,26 +15,19 @@
       "types": "./lib/types/invariant.d.ts",
       "default": "./lib/invariant.js"
     },
-    "./bin": {
-      "types": "./lib/types/bin.d.ts",
-      "default": "./lib/bin.js"
-    },
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
   "files": [
     "lib/index.js",
     "lib/invariant.js",
-    "lib/bin.js",
     "lib/types/**/*.d.ts",
     "lib/types/**/*.d.ts.map",
     "src"
   ],
   "license": "BSD-3-Clause",
   "peerDependencies": {
-    "@cordisjs/plugin-include": "^1.0.4",
     "@cordisjs/plugin-loader": "^1.0.0-rc.5",
-    "@deepseek-ai/dsh-app-boot": "^0.0.1",
     "@deepseek-ai/dsh-agent": "^0.0.1",
     "@deepseek-ai/dsh-agent-loop": "^0.0.1",
     "@deepseek-ai/dsh-commands": "^0.0.1",
@@ -60,9 +50,7 @@
     "schemastery": "^3.17.0"
   },
   "devDependencies": {
-    "@cordisjs/plugin-include": "workspace:^",
     "@cordisjs/plugin-loader": "workspace:^",
-    "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",

+ 0 - 27
packages/examples/tui-demo/src/bin.ts

@@ -1,27 +0,0 @@
-#!/usr/bin/env node
-/**
- * Boot a TUI app from a leaf `cordis.yml`; usage is `dsh-tui-demo [config]`, defaulting to the
- * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
- * dsh-app-boot. The tui-agent and cordis-agent demos invoke this bin with their own leaf configs.
- * @module @deepseek-ai/dsh-tui-demo/bin
- */
-
-import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
-
-const NAME = 'dsh-tui-demo'
-
-/* v8 ignore start -- thin self-executing composition over the unit-tested
-   dsh-app-boot helpers; exercised end-to-end by the tui-agent PTY smoke and
-   the built-bin fail-loud smoke */
-// 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; `
-    + 'use the one-shot dsh-cli-demo bin for pipes and automation\n')
-  process.exit(1)
-}
-installFailLoud(NAME)
-loadEnv(NAME)
-await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined))
-/* v8 ignore stop */

+ 2 - 2
packages/examples/tui-demo/src/index.ts

@@ -64,8 +64,8 @@ export interface Config {
   /**
    * Shell command template the TUI prints on exit and lists under `/resume`,
    * with `{session}` replaced by the live session id (forwarded to the front
-   * door). Set it to a command that resumes via this app's env var, e.g.
-   * `RESUME_SESSION_ID={session} dsh`.
+   * door). Set it to a command that resumes the session, e.g.
+   * `dsh --resume {session}`.
    */
   resumeCommand?: string
   /** Full-screen TUI presentation settings. */

+ 0 - 98
packages/examples/tui-demo/tests/built-bin.e2e.ts

@@ -1,98 +0,0 @@
-import { spawn } from 'node:child_process'
-import { existsSync } from 'node:fs'
-import { mkdtemp, mkdir, rm, symlink, readFile } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { afterEach, describe, expect, it } from 'vitest'
-
-/**
- * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
- * The TUI app owns no non-TTY fallback, so the piped subprocess must refuse to boot with a
- * nonzero exit and a stderr pointer at the one-shot CLI — the bin guards BEFORE the Loader
- * because a compose-time throw inside the tree is logged per-entry, not rethrown. The consumer
- * links only the bin's import chain (dsh-app-boot and its vendored Loader stack): the refusal
- * fires before any config is read, so no plugin tree is needed. Missing-config fail-loud and
- * full-boot coverage for the shared dsh-app-boot glue live in cli-demo's built-bin suite; it
- * skips before build, and interactive TTY behavior is PTY-covered by examples/tui-agent (the
- * one sanctioned PTY surface).
- */
-
-const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
-const tuiBin = join(repoRoot, 'packages/examples/tui-demo/lib/bin.js')
-
-// Symlink each package the bin imports at module load by package name so plain
-// Node resolves its built `main`, matching an installed dependency rather than
-// tsconfig paths.
-const dshPackages = ['examples/tui-demo', 'ui/app-boot']
-const vendorPackages = ['cordis', 'loader', 'include', 'schemastery', 'cosmokit']
-
-async function pkgName(absDir: string): Promise<string> {
-  const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
-  return json.name
-}
-
-/** Build a temporary external consumer with built workspace/vendor links. */
-async function makeConsumer(): Promise<string> {
-  const dir = await mkdtemp(join(tmpdir(), 'tui-built-bin-'))
-  const nm = join(dir, 'node_modules')
-  for (const rel of dshPackages) {
-    const abs = join(repoRoot, 'packages', rel)
-    const target = join(nm, await pkgName(abs))
-    await mkdir(dirname(target), { recursive: true })
-    await symlink(abs, target)
-  }
-  for (const v of vendorPackages) {
-    const abs = join(repoRoot, 'vendor', v)
-    const target = join(nm, await pkgName(abs))
-    await mkdir(dirname(target), { recursive: true })
-    await symlink(abs, target)
-  }
-  return dir
-}
-
-/** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */
-function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> {
-  return new Promise((resolve, reject) => {
-    // NO tsx — this is the published `node lib/bin.js` path; the guard fires
-    // before the Loader resolves the config tree.
-    const child = spawn(process.execPath, [tuiBin, './cordis.yml'], {
-      cwd,
-      env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
-      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(`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 }) })
-    child.on('error', (err) => { clearTimeout(timer); reject(err) })
-    child.stdin.end()
-  })
-}
-
-let consumer: string | undefined
-
-afterEach(async () => {
-  // Windows can briefly retain released handles after exit; retry removal.
-  if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
-  consumer = undefined
-})
-
-describe.skipIf(!existsSync(tuiBin))('dsh-tui-demo BUILT bin (node lib/bin.js, no tsx)', () => {
-  it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
-    consumer = await makeConsumer()
-    const { stdout, code, stderr } = await runBuiltBin(consumer)
-    expect(code).not.toBe(0)
-    expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
-    expect(stderr).toContain('dsh-cli-demo')
-    // The refusal happens before any plugin mounts: stdout stays silent.
-    expect(stdout).toBe('')
-  }, 30_000)
-})

+ 0 - 6
packages/examples/tui-demo/tsconfig.json

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

+ 6 - 6
packages/examples/tui-demo/tsdown.config.ts

@@ -1,14 +1,14 @@
 import { defineConfig } from 'tsdown'
 
 /**
- * tui-demo ships two entries: the plugin (`index`) and the CLI `bin`
- * (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`.
- * The root tsdown builds only `lib/types/index.js`, so this override adds
- * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false),
- * matching every package.
+ * tui-demo ships the plugin (`index`) and its invariant companion; the CLI
+ * front door is `dsh` (apps/cli), which mounts this bundle through its config.
+ * The root tsdown builds only `lib/types/index.js`, so this override adds the
+ * invariant entry. Declarations come from `tsc -b` (dts: false), matching
+ * every package.
  */
 export default defineConfig({
-  entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'],
+  entry: ['lib/types/index.js', 'lib/types/invariant.js'],
   outDir: 'lib',
   format: ['esm'],
   platform: 'node',

+ 3 - 3
packages/support/loader-smoke/tests/example-launch.spec.ts

@@ -5,7 +5,7 @@ import {
   resolveExampleMode,
 } from '@deepseek-ai/dsh-loader-smoke'
 
-const SRC_BIN = '/repo/packages/examples/tui-demo/src/bin.ts'
+const SRC_BIN = '/repo/packages/examples/cli-demo/src/bin.ts'
 const TSCONFIG = '/repo/tsconfig.json'
 
 const originalMode = process.env[EXAMPLE_MODE_ENV]
@@ -65,7 +65,7 @@ describe('resolveExampleLaunch', () => {
       env: { DSH_HOME: '/tmp/home' },
     })
     expect(args).not.toContain('--import')
-    expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js')
+    expect(args).toContain('/repo/packages/examples/cli-demo/lib/bin.js')
     expect(args.slice(-2)).toEqual(['--config', './cordis.yml'])
     expect(env.TSX_TSCONFIG_PATH).toBeUndefined()
     expect(env.DSH_HOME).toBe('/tmp/home')
@@ -100,6 +100,6 @@ describe('resolveExampleLaunch', () => {
   it('defaults the mode from the environment', () => {
     process.env[EXAMPLE_MODE_ENV] = 'lib'
     const { args } = resolveExampleLaunch({ srcBin: SRC_BIN })
-    expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js')
+    expect(args).toContain('/repo/packages/examples/cli-demo/lib/bin.js')
   })
 })

+ 1 - 1
packages/ui/README.md

@@ -17,4 +17,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 the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers.
 
-The runnable app bundles that bake these interfaces into boot bins live in [`examples/`](../examples/README.md), composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md). `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
+The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `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 human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.

+ 3 - 4
packages/ui/app-boot/README.md

@@ -1,17 +1,16 @@
 # `@deepseek-ai/dsh-app-boot`
 
-Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts.
+Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts.
 
 | Export | Role |
 |---|---|
 | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
-| `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh |
-| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume <sessionId>` pair while preserving positional arguments |
 | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
 | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
 | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
 | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
-| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
+| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
+| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
 | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
 | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
 

+ 12 - 57
packages/ui/app-boot/src/index.ts

@@ -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.
@@ -36,62 +36,6 @@ export function resolveConfigPath(
   return resolve(dir, replayName)
 }
 
-/** CLI flag the interactive surface accepts to resume a persisted session by id. */
-const RESUME_FLAG = '--resume'
-
-/**
- * Split a leading `--resume <id>` / `--resume=<id>` flag out of a CLI argument
- * vector, returning the resumed session id (when the flag is present) and the
- * remaining arguments with the flag and its value removed — so a positional
- * config path stays readable regardless of the flag's position. A `--resume`
- * with no following id, an empty id (`--resume=`), or a repeated `--resume`
- * throws: a mistyped resume must fail loud, never silently start a fresh
- * session. The id is not validated here; an unknown id fails loud downstream
- * when the session cannot load.
- * @param argv - the CLI arguments after subcommand dispatch.
- * @returns the parsed resume id (or `undefined`) and the flag-stripped arguments.
- */
-export function parseResumeArg(
-  argv: readonly string[],
-): { resumeSessionId: string | undefined; rest: string[] } {
-  const rest: string[] = []
-  let resumeSessionId: string | undefined
-  let skipNext = false
-  for (const [i, arg] of argv.entries()) {
-    if (skipNext) {
-      skipNext = false
-      continue
-    }
-    const inlineValue = arg.startsWith(`${RESUME_FLAG}=`)
-    if (arg === RESUME_FLAG || inlineValue) {
-      if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`)
-      const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1]
-      // A following token that is itself resume syntax (`--resume --resume x`)
-      // is a missing id, not a session literally named `--resume…`.
-      if (value === undefined || value === '' || value === RESUME_FLAG || value.startsWith(`${RESUME_FLAG}=`)) {
-        throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} <session-id>)`)
-      }
-      resumeSessionId = value
-      skipNext = !inlineValue // the space form consumed the following token as its value
-      continue
-    }
-    rest.push(arg)
-  }
-  return { resumeSessionId, rest }
-}
-
-/**
- * Replace any existing resume flag with one canonical trailing `--resume <id>` pair.
- * @param argv - current arguments after command dispatch.
- * @param sessionId - selected session id.
- * @returns flag-normalized arguments for a process replacement.
- */
-export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] {
-  if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`)
-  const { rest } = parseResumeArg(argv)
-  return [...rest, RESUME_FLAG, sessionId]
-}
-
 /**
  * Load the optional gitignored `.env` from `dir`. Missing files fall back to the
  * ambient environment; other read failures are reported through `warn`.
@@ -212,6 +156,17 @@ 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)`
+ * makes `id` readable as the bare identifier `resumeSessionId` in a config
+ * `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
+ * so resuming a session needs no environment variable. A bin that never
+ * provides it leaves the identifier undeclared, so configs read it defensively
+ * (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
+ */
+export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
+
 /**
  * Boot the Loader against `absoluteConfigPath` and return only after the whole
  * tree settles. Entry names load through the Loader's internal module loader

+ 1 - 35
packages/ui/app-boot/tests/app-boot.spec.ts

@@ -6,7 +6,7 @@ import { Context } from 'cordis'
 import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import {
   addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
-  installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess,
+  installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
 } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
@@ -30,40 +30,6 @@ describe('resolveConfigPath', () => {
   })
 })
 
-describe('parseResumeArg', () => {
-  it('returns no resume id and passes arguments through when the flag is absent', () => {
-    expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] })
-    expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] })
-  })
-
-  it('parses the space form, the inline form, and leaves a positional config path in any position', () => {
-    expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] })
-    expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] })
-    expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] })
-    expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] })
-  })
-
-  it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => {
-    expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id')
-    expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id')
-    expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once')
-  })
-
-  it('rejects resume syntax used as the flag value instead of resuming a session named like the flag', () => {
-    expect(() => parseResumeArg(['--resume', '--resume', 'sess'])).toThrow('--resume requires a session id')
-    expect(() => parseResumeArg(['--resume', '--resume=sess'])).toThrow('--resume requires a session id')
-  })
-})
-
-describe('replaceResumeArg', () => {
-  it('keeps positional arguments and replaces either existing flag form', () => {
-    expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
-    expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
-    expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next'])
-    expect(() => replaceResumeArg([], '')).toThrow('non-empty session id')
-  })
-})
-
 describe('loadEnv', () => {
   it('loads variables from .env in the given dir', () => {
     const dir = tmp()

+ 1 - 1
packages/ui/tui/tests/tui.snapshot.ts

@@ -650,7 +650,7 @@ describe('TUI terminal-state snapshots', () => {
     const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z'))
     const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }
     const harness = await setupSnapshot({
-      config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' },
+      config: { resumeCommand: 'dsh --resume {session}' },
       sessionPersistence: {
         list: async () => [earlier],
         load: async () => ({

+ 3 - 3
packages/ui/tui/tests/tui.spec.ts

@@ -206,7 +206,7 @@ describe('TUI config', () => {
 })
 
 describe('resume command and /resume', () => {
-  const RESUME = 'RESUME_SESSION_ID={session} dsh'
+  const RESUME = 'dsh --resume {session}'
   const header = (id: string, createdAt: number, cwd: string): SessionHeader =>
     ({ version: 0, id: SessionId(id), createdAt, cwd })
   const resumeEvents = (
@@ -234,7 +234,7 @@ describe('resume command and /resume', () => {
     result.terminal.send('/exit')
     result.terminal.send('\r')
     await tick()
-    expect(result.terminal.output).toContain('To resume this session: RESUME_SESSION_ID=main-session dsh')
+    expect(result.terminal.output).toContain('To resume this session: dsh --resume main-session')
     expect(result.exit).toHaveBeenCalledWith(0)
     await dispose(result)
   })
@@ -1000,7 +1000,7 @@ describe('resume command and /resume', () => {
     result.terminal.send('\r')
     await tick()
     expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:')
-    expect(result.terminal.output).toContain('RESUME_SESSION_ID=fallback-session')
+    expect(result.terminal.output).toContain('dsh --resume fallback-session')
     expect(result.terminal.stopped).toBe(0)
     await dispose(result)
   })

+ 4 - 7
pnpm-lock.yaml

@@ -278,6 +278,9 @@ importers:
       '@deepseek-ai/dsh-workspace-context':
         specifier: workspace:^
         version: link:../../packages/context/workspace-context
+      commander:
+        specifier: ^15.0.0
+        version: 15.0.0
       cordis:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
@@ -1746,9 +1749,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
@@ -1761,9 +1761,6 @@ importers:
       '@deepseek-ai/dsh-agent-spine-demo':
         specifier: workspace:^
         version: link:../agent-spine-demo
-      '@deepseek-ai/dsh-app-boot':
-        specifier: workspace:^
-        version: link:../../ui/app-boot
       '@deepseek-ai/dsh-command-goal':
         specifier: workspace:^
         version: link:../../goal/command-goal
@@ -1814,7 +1811,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

+ 1 - 1
scripts/demo-code-mode.mjs

@@ -7,7 +7,7 @@ import { spawn } from 'node:child_process'
 
 // Each UI's node invocation matches its base demo script plus the overlay config.
 const UIS = new Map([
-  ['tui', ['--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']],
+  ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/tui-agent/code-mode.cordis.yml']],
   ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']],
 ])
 

+ 1 - 0
tsconfig.host.json

@@ -8,6 +8,7 @@
     "rewriteRelativeImportExtensions": false
   },
   "include": [
+    "apps/cli/tests/**/*.ts",
     "examples/*/src/**/*.ts",
     "examples/*/start.ts",
     "examples/*/tests/**/*.ts",

+ 1 - 0
vitest.config.ts

@@ -31,6 +31,7 @@ const windowsCoverageExclusions = process.platform === 'win32'
 
 const testIncludes = [
   'packages/*/*/tests/**/*.spec.{ts,tsx}',
+  'apps/*/tests/**/*.spec.ts',
   'examples/*/tests/**/*.spec.ts',
   'scripts/**/*.spec.ts',
 ]

+ 3 - 1
vitest.e2e.config.ts

@@ -38,7 +38,9 @@ export default defineConfig({
   plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })],
   test: {
     setupFiles: ['./scripts/test-invariants.ts'],
-    include: ['packages/*/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'],
+    // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built
+    // frontend dist and runs under vitest.web.config.ts (the test:web job).
+    include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'],
     // Real model calls: generous timeouts, and retries for transient flakes
     // (the shared internal key hits concurrency quotas). No coverage — the
     // unit suites own the coverage gate.

Некоторые файлы не были показаны из-за большого количества измененных файлов