SaylorTwift HF Staff commited on
Commit
08c07af
·
verified ·
1 Parent(s): 57ba6cf

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. apps/cli/README.i18n.yaml +6 -0
  2. apps/cli/README.md +58 -0
  3. apps/cli/README.zh.md +58 -0
  4. apps/cli/composition.md +279 -0
  5. apps/cli/package.json +152 -0
  6. apps/cli/reference/README.i18n.yaml +6 -0
  7. apps/cli/reference/README.md +110 -0
  8. apps/cli/reference/README.zh.md +112 -0
  9. apps/cli/tests/agent-team-headless.e2e.ts +125 -0
  10. apps/cli/tests/args.spec.ts +134 -0
  11. apps/cli/tests/built-bin.e2e.ts +1151 -0
  12. apps/cli/tests/dsh-badge.expected.e2e.ts +176 -0
  13. apps/cli/tests/github-webhook-real.e2e.ts +467 -0
  14. apps/cli/tests/headless-shutdown.e2e.ts +131 -0
  15. apps/cli/tests/lazy-search-startup.compat.spec.ts +128 -0
  16. apps/cli/tests/memory-mcp-configs.spec.ts +132 -0
  17. apps/cli/tests/process-shutdown.spec.ts +179 -0
  18. apps/cli/tests/profile-hmr.spec.ts +46 -0
  19. apps/cli/tests/profile-initialization.spec.ts +158 -0
  20. apps/cli/tests/profile-mcp.spec.ts +43 -0
  21. apps/cli/tests/source-launch.compat.spec.ts +42 -0
  22. apps/cli/tests/telemetry-switch.spec.ts +22 -0
  23. apps/cli/tests/web-agent-presets.e2e.ts +995 -0
  24. apps/cli/tests/web-auth.e2e.ts +210 -0
  25. apps/cli/tests/web-browser-open.expected.e2e.ts +240 -0
  26. apps/cli/tests/windows-shell.spec.ts +159 -0
  27. apps/cli/tsconfig.json +75 -0
  28. apps/cli/tsdown.config.ts +18 -0
  29. apps/desktop-host/package.json +28 -0
  30. apps/desktop-host/tsconfig.json +19 -0
  31. apps/desktop-host/tsdown.config.ts +12 -0
  32. apps/desktop/README.i18n.yaml +6 -0
  33. apps/desktop/README.md +202 -0
  34. apps/desktop/README.zh.md +202 -0
  35. apps/desktop/electron-builder.config.d.mts +49 -0
  36. apps/desktop/electron-builder.config.mjs +127 -0
  37. apps/desktop/package.json +51 -0
  38. apps/desktop/tsconfig.json +11 -0
  39. apps/desktop/tsdown.config.ts +27 -0
  40. apps/web/.npmignore +1 -0
  41. apps/web/index.html +14 -0
  42. apps/web/package.json +64 -0
  43. apps/web/product-isolation.ts +105 -0
  44. apps/web/tsconfig.json +148 -0
  45. apps/web/vite.config.ts +246 -0
  46. benchmarks/AGENTS.md +16 -0
  47. benchmarks/package.json +41 -0
  48. benchmarks/tsdown.config.ts +59 -0
  49. docs/AGENTS.md +76 -0
  50. docs/agent-lifecycle.i18n.yaml +6 -0
apps/cli/README.i18n.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write apps/cli/README.md
5
+ README.md: cfab7a4983c31a21b68157c9cefb38c1654571ff
6
+ README.zh.md: 67d07fba1fc27bca9c2a696daddde88aac3aa39c
apps/cli/README.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `@deepseek-ai/dsh`
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, and fatal configuration or boot failures exit nonzero.
6
+
7
+ ## Entry modes
8
+
9
+ | Command | Purpose |
10
+ |---|---|
11
+ | `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
12
+ | `dsh --profile <name> --from-default-profile <template>` | Create a new custom profile from a shipped template, then boot it. |
13
+ | `dsh --profile acp` | Serve automation clients over ACP stdio until disconnect. |
14
+ | `dsh --profile headless "job"` | Run one fresh persisted session, print the final answer, and exit. |
15
+ | `dsh --profile sdk` | Serve SDK clients over JSON-RPC stdio until shutdown or disconnect. |
16
+ | `dsh --profile sdk-minimal` | Serve SDK clients with the standalone minimal agent tree. |
17
+ | `dsh web` | Alias of `--profile web`. |
18
+ | `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
19
+
20
+ The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create another profile at an unused, non-shipped name with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot, config-dump, and plugin-management requests for it.
21
+
22
+ ## App arguments
23
+
24
+ The launcher parses only its own flags and hands everything after them to the booted profile, where any injected app plugin may parse the shared immutable snapshot ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). The first token the launcher does not recognize starts the app's arguments:
25
+
26
+ ```sh
27
+ dsh --profile web --port 8080 # --port belongs to the web app
28
+ dsh --profile tui --resume <id> # example, assuming the tui profile is installed; --resume belongs to the terminal app
29
+ dsh --profile headless "run the tests"
30
+ dsh --profile web --help # the web app's flags, not the launcher's
31
+ dsh --help # the launcher's own help
32
+ ```
33
+
34
+ <a id="profiles"></a>
35
+ ## Profiles
36
+
37
+ A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile and home-level patch files; `startup` applies them once.
38
+
39
+ The tree composes over an empty root:
40
+ - each bundle's patch in `dsh.profile.bundles` order
41
+ - then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`
42
+ - then `--patch` overlays
43
+
44
+ Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins.
45
+
46
+ Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
47
+
48
+ The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution. The [startup and reload failure table](../../packages/boot/app-boot/README.md#startup-and-reload-failures) compares optional and required plugin failures with configuration HMR.
49
+
50
+ ## Optional overlays
51
+
52
+ `config/examples/` ships opt-in overlays for GitHub review webhooks, session-local Schedule, memory MCP servers, and runtime Cordis tools. They are never part of a default profile; the [user guides](../../docs/user/guide/index.md) and [developer practice guides](../../docs/user/develop/practice/index.md) own setup and safety instructions.
53
+
54
+ ## Development
55
+
56
+ Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh <args...>` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
57
+
58
+ The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.
apps/cli/README.zh.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `@deepseek-ai/dsh`
2
+
3
+ [English](README.md) | 中文
4
+
5
+ `dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP(Agent Client Protocol)都是 profile,而不是独立的公开可执行命令。Python 运行时 wheel 包中也包含同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项,以及致命的配置或启动错误都会以非零状态退出。
6
+
7
+ ## 入口模式
8
+
9
+ | 命令 | 用途 |
10
+ |---|---|
11
+ | `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
12
+ | `dsh --profile <name> --from-default-profile <template>` | 从随附模板创建新的自定义 profile,然后启动它。 |
13
+ | `dsh --profile acp` | 通过 ACP stdio 为自动化客户端提供服务,直至断开连接。 |
14
+ | `dsh --profile headless "job"` | 运行一个全新的持久化会话,打印最终答案并退出。 |
15
+ | `dsh --profile sdk` | 通过 JSON-RPC stdio 为 SDK 客户端提供服务,直至关闭或断开连接。 |
16
+ | `dsh --profile sdk-minimal` | 以独立极简 agent(智能体)配置树为 SDK 客户端提供服务。 |
17
+ | `dsh web` | `--profile web` 的别名。 |
18
+ | `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
19
+
20
+ 运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一,在尚未使用的非内置名称处创建其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI(命令行界面)会拒绝针对它的启动、配置 dump 和插件管理请求。
21
+
22
+ ## 应用参数
23
+
24
+ 启动器只解析自身的 flag,并将其后的所有内容交给已启动的 profile;注入该 profile 的任意应用插件都可以解析这份共享的不可变快照([`dsh-cmdline`](../../packages/boot/cmdline/README.zh.md))。启动器无法识别的第一个 token 标志着应用参数的开始:
25
+
26
+ ```sh
27
+ dsh --profile web --port 8080 # --port belongs to the web app
28
+ dsh --profile tui --resume <id> # example, assuming the tui profile is installed; --resume belongs to the terminal app
29
+ dsh --profile headless "run the tests"
30
+ dsh --profile web --help # the web app's flags, not the launcher's
31
+ dsh --help # the launcher's own help
32
+ ```
33
+
34
+ <a id="profiles"></a>
35
+ ## Profile
36
+
37
+ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表与 `patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile 与 home 级 patch 文件,`startup` 则只应用一次。
38
+
39
+ 配置树以空根为起点,依次叠加以下配置层:
40
+ - `dsh.profile.bundles` 中各组合包的 patch
41
+ - profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`
42
+ - `--patch` 指定的覆盖层
43
+
44
+ `dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。
45
+
46
+ 使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
47
+
48
+ 层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。[启动与重载失败表](../../packages/boot/app-boot/README.zh.md#startup-and-reload-failures)对比 optional、required 插件启动失败与配置 HMR 的行为。
49
+
50
+ ## 可选覆盖层
51
+
52
+ `config/examples/` 交付 GitHub 评审 webhook、会话内 Schedule、记忆 MCP 服务器与运行时 Cordis 工具的可选覆盖层。它们绝不属于默认 profile;设置与安全说明由[用户指南](../../docs/user/guide/index.zh.md)和[开发实战指南](../../docs/user/develop/practice/index.zh.md)负责。
53
+
54
+ ## 开发
55
+
56
+ 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。
57
+
58
+ [Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,��是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。
apps/cli/composition.md ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.
2
+ Run `pnpm run gen-doc-graphs` to regenerate. -->
3
+
4
+ # DSH Base Composition
5
+
6
+ The dsh-base bundle patch shared by the web, headless, sdk, and acp profiles; their mode bundles and user layers patch over it, while sdk-minimal owns a separate standalone tree.
7
+
8
+ ```mermaid
9
+ flowchart LR
10
+ cfg["packages/bundle/base/cordis.patch.yml<br/>cordis.yml"]
11
+ plugin_dsh_base_timer["timer<br/>@deepseek-ai/cordis-plugin-timer"]
12
+ cfg --> plugin_dsh_base_timer
13
+ plugin_dsh_base_hmr["hmr<br/>@deepseek-ai/cordis-plugin-hmr"]
14
+ cfg --> plugin_dsh_base_hmr
15
+ plugin_dsh_base_llm["llm<br/>@deepseek-ai/dsh-llm"]
16
+ cfg --> plugin_dsh_base_llm
17
+ plugin_dsh_base_deepseek_llm_api_extensions["deepseek-llm-api-extensions<br/>@deepseek-ai/dsh-deepseek-llm-api-extensions"]
18
+ cfg --> plugin_dsh_base_deepseek_llm_api_extensions
19
+ plugin_dsh_base_session["session<br/>@deepseek-ai/dsh-session"]
20
+ cfg --> plugin_dsh_base_session
21
+ plugin_dsh_base_session_log_deepseek["session-log-deepseek<br/>@deepseek-ai/dsh-session-log-deepseek"]
22
+ cfg --> plugin_dsh_base_session_log_deepseek
23
+ plugin_dsh_base_typert["typert<br/>@deepseek-ai/dsh-typert-registry"]
24
+ cfg --> plugin_dsh_base_typert
25
+ plugin_dsh_base_typert_loader["typert-loader<br/>@deepseek-ai/dsh-typert-loader"]
26
+ cfg --> plugin_dsh_base_typert_loader
27
+ plugin_dsh_base_typert_gateway["typert-gateway<br/>@deepseek-ai/dsh-api-gateway"]
28
+ cfg --> plugin_dsh_base_typert_gateway
29
+ plugin_dsh_base_session_title["session-title<br/>@deepseek-ai/dsh-session-title"]
30
+ cfg --> plugin_dsh_base_session_title
31
+ plugin_dsh_base_session_title_llm["session-title-llm<br/>@deepseek-ai/dsh-session-title-first-prompt-llm"]
32
+ cfg --> plugin_dsh_base_session_title_llm
33
+ plugin_dsh_base_user_questions["user-questions<br/>@deepseek-ai/dsh-user-questions"]
34
+ cfg --> plugin_dsh_base_user_questions
35
+ plugin_dsh_base_agent["agent<br/>@deepseek-ai/dsh-agent"]
36
+ cfg --> plugin_dsh_base_agent
37
+ plugin_dsh_base_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek<br/>@deepseek-ai/dsh-plugin-package-inventory-deepseek"]
38
+ cfg --> plugin_dsh_base_plugin_package_inventory_deepseek
39
+ plugin_dsh_base_agent_default_model["agent-default-model<br/>@deepseek-ai/dsh-agent-default-model"]
40
+ cfg --> plugin_dsh_base_agent_default_model
41
+ plugin_dsh_base_jobs["jobs<br/>@deepseek-ai/dsh-jobs-local"]
42
+ cfg --> plugin_dsh_base_jobs
43
+ plugin_dsh_base_llm_retry["llm-retry<br/>@deepseek-ai/dsh-llm-retry"]
44
+ cfg --> plugin_dsh_base_llm_retry
45
+ plugin_dsh_base_settings["settings<br/>@deepseek-ai/dsh-settings-file"]
46
+ cfg --> plugin_dsh_base_settings
47
+ plugin_dsh_base_credentials["credentials<br/>@deepseek-ai/dsh-credentials-local"]
48
+ cfg --> plugin_dsh_base_credentials
49
+ plugin_dsh_base_llm_pi_ai["llm-pi-ai<br/>@deepseek-ai/dsh-llm-pi-ai"]
50
+ cfg --> plugin_dsh_base_llm_pi_ai
51
+ plugin_dsh_base_session_persistence_jsonl["session-persistence-jsonl<br/>@deepseek-ai/dsh-session-persistence-jsonl"]
52
+ cfg --> plugin_dsh_base_session_persistence_jsonl
53
+ plugin_dsh_base_attachment_local["attachment-local<br/>@deepseek-ai/dsh-attachment-local"]
54
+ cfg --> plugin_dsh_base_attachment_local
55
+ plugin_dsh_base_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
56
+ cfg --> plugin_dsh_base_session_query_sqlite
57
+ plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
58
+ cfg --> plugin_dsh_base_session_projection
59
+ plugin_dsh_base_storage["storage<br/>@deepseek-ai/dsh-storage"]
60
+ cfg --> plugin_dsh_base_storage
61
+ plugin_dsh_base_storage_json["storage-json<br/>@deepseek-ai/dsh-storage-json"]
62
+ cfg --> plugin_dsh_base_storage_json
63
+ plugin_dsh_base_storage_domain["storage-domain<br/>@deepseek-ai/dsh-storage-domain"]
64
+ cfg --> plugin_dsh_base_storage_domain
65
+ plugin_dsh_base_session_projection_cache["session-projection-cache<br/>@deepseek-ai/dsh-session-projection-cache"]
66
+ cfg --> plugin_dsh_base_session_projection_cache
67
+ plugin_dsh_base_session_telemetry_otel["session-telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"]
68
+ cfg --> plugin_dsh_base_session_telemetry_otel
69
+ plugin_dsh_base_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
70
+ cfg --> plugin_dsh_base_subprocess
71
+ plugin_dsh_base_sandbox["sandbox<br/>@deepseek-ai/dsh-sandbox-local"]
72
+ cfg --> plugin_dsh_base_sandbox
73
+ plugin_dsh_base_sandbox_policy["sandbox-policy<br/>@deepseek-ai/dsh-sandbox-policy"]
74
+ cfg --> plugin_dsh_base_sandbox_policy
75
+ plugin_dsh_base_bash_sandbox["bash-sandbox<br/>@deepseek-ai/dsh-bash-sandbox"]
76
+ cfg --> plugin_dsh_base_bash_sandbox
77
+ plugin_dsh_base_pwsh_sandbox["pwsh-sandbox<br/>@deepseek-ai/dsh-pwsh-sandbox"]
78
+ cfg --> plugin_dsh_base_pwsh_sandbox
79
+ plugin_dsh_base_approval["approval<br/>@deepseek-ai/dsh-user-approval"]
80
+ cfg --> plugin_dsh_base_approval
81
+ plugin_dsh_base_permission["permission<br/>@deepseek-ai/dsh-permission-presets"]
82
+ cfg --> plugin_dsh_base_permission
83
+ plugin_dsh_base_shell_env["shell-env<br/>@deepseek-ai/dsh-shell-env"]
84
+ cfg --> plugin_dsh_base_shell_env
85
+ plugin_dsh_base_tool_bash["tool-bash<br/>@deepseek-ai/dsh-tool-bash"]
86
+ cfg --> plugin_dsh_base_tool_bash
87
+ plugin_dsh_base_tool_pwsh["tool-pwsh<br/>@deepseek-ai/dsh-tool-pwsh"]
88
+ cfg --> plugin_dsh_base_tool_pwsh
89
+ plugin_dsh_base_tool_jobs["tool-jobs<br/>@deepseek-ai/dsh-tool-jobs"]
90
+ cfg --> plugin_dsh_base_tool_jobs
91
+ plugin_dsh_base_fs_observation_policy["fs-observation-policy<br/>@deepseek-ai/dsh-fs-observation-policy"]
92
+ cfg --> plugin_dsh_base_fs_observation_policy
93
+ plugin_dsh_base_tool_fs["tool-fs<br/>@deepseek-ai/dsh-tool-fs"]
94
+ cfg --> plugin_dsh_base_tool_fs
95
+ plugin_dsh_base_tool_fs_search["tool-fs-search<br/>@deepseek-ai/dsh-tool-fs-search"]
96
+ cfg --> plugin_dsh_base_tool_fs_search
97
+ plugin_dsh_base_agent_instructions["agent-instructions<br/>@deepseek-ai/dsh-agent-instructions"]
98
+ cfg --> plugin_dsh_base_agent_instructions
99
+ plugin_dsh_base_skill["skill<br/>@deepseek-ai/dsh-skill"]
100
+ cfg --> plugin_dsh_base_skill
101
+ plugin_dsh_base_skill_filesystem["skill-filesystem<br/>@deepseek-ai/dsh-skill-filesystem"]
102
+ cfg --> plugin_dsh_base_skill_filesystem
103
+ plugin_dsh_base_skill_badge["skill-badge<br/>@deepseek-ai/dsh-skill-badge"]
104
+ cfg --> plugin_dsh_base_skill_badge
105
+ plugin_dsh_base_tool_skill["tool-skill<br/>@deepseek-ai/dsh-tool-skill"]
106
+ cfg --> plugin_dsh_base_tool_skill
107
+ plugin_dsh_base_commands["commands<br/>@deepseek-ai/dsh-commands"]
108
+ cfg --> plugin_dsh_base_commands
109
+ plugin_dsh_base_command_feedback["command-feedback<br/>@deepseek-ai/dsh-command-feedback"]
110
+ cfg --> plugin_dsh_base_command_feedback
111
+ plugin_dsh_base_goal["goal<br/>@deepseek-ai/dsh-goal"]
112
+ cfg --> plugin_dsh_base_goal
113
+ plugin_dsh_base_goal_round_driver["goal-round-driver<br/>@deepseek-ai/dsh-goal-round-driver"]
114
+ cfg --> plugin_dsh_base_goal_round_driver
115
+ plugin_dsh_base_command_goal["command-goal<br/>@deepseek-ai/dsh-command-goal"]
116
+ cfg --> plugin_dsh_base_command_goal
117
+ plugin_dsh_base_plan_mode["plan-mode<br/>@deepseek-ai/dsh-plan-mode"]
118
+ cfg --> plugin_dsh_base_plan_mode
119
+ plugin_dsh_base_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"]
120
+ cfg --> plugin_dsh_base_token_meter
121
+ plugin_dsh_base_compaction_basic["compaction-basic<br/>@deepseek-ai/dsh-compaction-basic"]
122
+ cfg --> plugin_dsh_base_compaction_basic
123
+ plugin_dsh_base_command_compact["command-compact<br/>@deepseek-ai/dsh-command-compact"]
124
+ cfg --> plugin_dsh_base_command_compact
125
+ plugin_dsh_base_subagent["subagent<br/>@deepseek-ai/dsh-subagent"]
126
+ cfg --> plugin_dsh_base_subagent
127
+ plugin_dsh_base_subagent_spawn_in_process["subagent-spawn-in-process<br/>@deepseek-ai/dsh-subagent-spawn-in-process"]
128
+ cfg --> plugin_dsh_base_subagent_spawn_in_process
129
+ plugin_dsh_base_subagent_fork_in_process["subagent-fork-in-process<br/>@deepseek-ai/dsh-subagent-fork-in-process"]
130
+ cfg --> plugin_dsh_base_subagent_fork_in_process
131
+ plugin_dsh_base_tool_subagent_control["tool-subagent-control<br/>@deepseek-ai/dsh-tool-subagent-control"]
132
+ cfg --> plugin_dsh_base_tool_subagent_control
133
+ plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents<br/>@deepseek-ai/dsh-tool-subagent-control/list-agents"]
134
+ cfg --> plugin_dsh_base_tool_subagent_list_agents
135
+ plugin_dsh_base_tool_subagent["tool-subagent<br/>@deepseek-ai/dsh-tool-subagent"]
136
+ cfg --> plugin_dsh_base_tool_subagent
137
+ plugin_dsh_base_tool_subagent_fork["tool-subagent-fork<br/>@deepseek-ai/dsh-tool-subagent"]
138
+ cfg --> plugin_dsh_base_tool_subagent_fork
139
+ plugin_dsh_base_ptc_runtime["ptc-runtime<br/>@deepseek-ai/dsh-ptc-runtime-node"]
140
+ cfg --> plugin_dsh_base_ptc_runtime
141
+ plugin_dsh_base_workflow_ptc["workflow-ptc<br/>@deepseek-ai/dsh-workflow-ptc"]
142
+ cfg --> plugin_dsh_base_workflow_ptc
143
+ plugin_dsh_base_tool_workflow["tool-workflow<br/>@deepseek-ai/dsh-tool-workflow"]
144
+ cfg --> plugin_dsh_base_tool_workflow
145
+ plugin_dsh_base_timeout_policy["timeout-policy<br/>@deepseek-ai/dsh-tool-call-timeout-policy"]
146
+ cfg --> plugin_dsh_base_timeout_policy
147
+ plugin_dsh_base_spill_local["spill-local<br/>@deepseek-ai/dsh-spill-local"]
148
+ cfg --> plugin_dsh_base_spill_local
149
+ plugin_dsh_base_spill_policy["spill-policy<br/>@deepseek-ai/dsh-spill-policy"]
150
+ cfg --> plugin_dsh_base_spill_policy
151
+ plugin_dsh_base_session_checkpoint_policy["session-checkpoint-policy<br/>@deepseek-ai/dsh-session-checkpoint-policy"]
152
+ cfg --> plugin_dsh_base_session_checkpoint_policy
153
+ plugin_dsh_base_tool_result_pruner["tool-result-pruner<br/>@deepseek-ai/dsh-compaction-tool-result-pruner"]
154
+ cfg --> plugin_dsh_base_tool_result_pruner
155
+ plugin_dsh_base_image_offload["image-offload<br/>@deepseek-ai/dsh-compaction-image-offload"]
156
+ cfg --> plugin_dsh_base_image_offload
157
+ plugin_dsh_base_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
158
+ cfg --> plugin_dsh_base_tool_todo
159
+ plugin_dsh_base_tool_goal["tool-goal<br/>@deepseek-ai/dsh-tool-goal"]
160
+ cfg --> plugin_dsh_base_tool_goal
161
+ plugin_dsh_base_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
162
+ cfg --> plugin_dsh_base_tool_ralph
163
+ plugin_dsh_base_repeat_tool_reminder["repeat-tool-reminder<br/>@deepseek-ai/dsh-repeat-tool-reminder"]
164
+ cfg --> plugin_dsh_base_repeat_tool_reminder
165
+ plugin_dsh_base_web["web<br/>@deepseek-ai/dsh-web"]
166
+ cfg --> plugin_dsh_base_web
167
+ plugin_dsh_base_web_search_deepseek["web-search-deepseek<br/>@deepseek-ai/dsh-web-search-deepseek"]
168
+ cfg --> plugin_dsh_base_web_search_deepseek
169
+ plugin_dsh_base_web_fetch_http["web-fetch-http<br/>@deepseek-ai/dsh-web-fetch-http"]
170
+ cfg --> plugin_dsh_base_web_fetch_http
171
+ plugin_dsh_base_tool_web["tool-web<br/>@deepseek-ai/dsh-tool-web"]
172
+ cfg --> plugin_dsh_base_tool_web
173
+ plugin_dsh_base_mcp_resources["mcp-resources<br/>@deepseek-ai/dsh-mcp-resources"]
174
+ cfg --> plugin_dsh_base_mcp_resources
175
+ plugin_dsh_base_tools["tools<br/>@deepseek-ai/dsh-tools"]
176
+ cfg --> plugin_dsh_base_tools
177
+ plugin_dsh_base_system_prompt["system-prompt<br/>@deepseek-ai/dsh-system-prompt"]
178
+ cfg --> plugin_dsh_base_system_prompt
179
+ plugin_dsh_base_agent_loop["agent-loop<br/>@deepseek-ai/dsh-agent-loop"]
180
+ cfg --> plugin_dsh_base_agent_loop
181
+ plugin_dsh_base_fs_sandbox["fs-sandbox<br/>@deepseek-ai/dsh-fs-sandbox"]
182
+ cfg --> plugin_dsh_base_fs_sandbox
183
+ plugin_dsh_base_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
184
+ cfg --> plugin_dsh_base_llm_deepseek
185
+ ```
186
+
187
+ | Plugin id | Package / module |
188
+ | --- | --- |
189
+ | `timer` | `@deepseek-ai/cordis-plugin-timer` |
190
+ | `hmr` | `@deepseek-ai/cordis-plugin-hmr` |
191
+ | `llm` | `@deepseek-ai/dsh-llm` |
192
+ | `deepseek-llm-api-extensions` | `@deepseek-ai/dsh-deepseek-llm-api-extensions` |
193
+ | `session` | `@deepseek-ai/dsh-session` |
194
+ | `session-log-deepseek` | `@deepseek-ai/dsh-session-log-deepseek` |
195
+ | `typert` | `@deepseek-ai/dsh-typert-registry` |
196
+ | `typert-loader` | `@deepseek-ai/dsh-typert-loader` |
197
+ | `typert-gateway` | `@deepseek-ai/dsh-api-gateway` |
198
+ | `session-title` | `@deepseek-ai/dsh-session-title` |
199
+ | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` |
200
+ | `user-questions` | `@deepseek-ai/dsh-user-questions` |
201
+ | `agent` | `@deepseek-ai/dsh-agent` |
202
+ | `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` |
203
+ | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` |
204
+ | `jobs` | `@deepseek-ai/dsh-jobs-local` |
205
+ | `llm-retry` | `@deepseek-ai/dsh-llm-retry` |
206
+ | `settings` | `@deepseek-ai/dsh-settings-file` |
207
+ | `credentials` | `@deepseek-ai/dsh-credentials-local` |
208
+ | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
209
+ | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
210
+ | `attachment-local` | `@deepseek-ai/dsh-attachment-local` |
211
+ | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
212
+ | `session-projection` | `@deepseek-ai/dsh-session-projection` |
213
+ | `storage` | `@deepseek-ai/dsh-storage` |
214
+ | `storage-json` | `@deepseek-ai/dsh-storage-json` |
215
+ | `storage-domain` | `@deepseek-ai/dsh-storage-domain` |
216
+ | `session-projection-cache` | `@deepseek-ai/dsh-session-projection-cache` |
217
+ | `session-telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
218
+ | `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
219
+ | `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
220
+ | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` |
221
+ | `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` |
222
+ | `pwsh-sandbox` | `@deepseek-ai/dsh-pwsh-sandbox` |
223
+ | `approval` | `@deepseek-ai/dsh-user-approval` |
224
+ | `permission` | `@deepseek-ai/dsh-permission-presets` |
225
+ | `shell-env` | `@deepseek-ai/dsh-shell-env` |
226
+ | `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
227
+ | `tool-pwsh` | `@deepseek-ai/dsh-tool-pwsh` |
228
+ | `tool-jobs` | `@deepseek-ai/dsh-tool-jobs` |
229
+ | `fs-observation-policy` | `@deepseek-ai/dsh-fs-observation-policy` |
230
+ | `tool-fs` | `@deepseek-ai/dsh-tool-fs` |
231
+ | `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` |
232
+ | `agent-instructions` | `@deepseek-ai/dsh-agent-instructions` |
233
+ | `skill` | `@deepseek-ai/dsh-skill` |
234
+ | `skill-filesystem` | `@deepseek-ai/dsh-skill-filesystem` |
235
+ | `skill-badge` | `@deepseek-ai/dsh-skill-badge` |
236
+ | `tool-skill` | `@deepseek-ai/dsh-tool-skill` |
237
+ | `commands` | `@deepseek-ai/dsh-commands` |
238
+ | `command-feedback` | `@deepseek-ai/dsh-command-feedback` |
239
+ | `goal` | `@deepseek-ai/dsh-goal` |
240
+ | `goal-round-driver` | `@deepseek-ai/dsh-goal-round-driver` |
241
+ | `command-goal` | `@deepseek-ai/dsh-command-goal` |
242
+ | `plan-mode` | `@deepseek-ai/dsh-plan-mode` |
243
+ | `token-meter` | `@deepseek-ai/dsh-token-meter` |
244
+ | `compaction-basic` | `@deepseek-ai/dsh-compaction-basic` |
245
+ | `command-compact` | `@deepseek-ai/dsh-command-compact` |
246
+ | `subagent` | `@deepseek-ai/dsh-subagent` |
247
+ | `subagent-spawn-in-process` | `@deepseek-ai/dsh-subagent-spawn-in-process` |
248
+ | `subagent-fork-in-process` | `@deepseek-ai/dsh-subagent-fork-in-process` |
249
+ | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` |
250
+ | `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` |
251
+ | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
252
+ | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
253
+ | `ptc-runtime` | `@deepseek-ai/dsh-ptc-runtime-node` |
254
+ | `workflow-ptc` | `@deepseek-ai/dsh-workflow-ptc` |
255
+ | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` |
256
+ | `timeout-policy` | `@deepseek-ai/dsh-tool-call-timeout-policy` |
257
+ | `spill-local` | `@deepseek-ai/dsh-spill-local` |
258
+ | `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
259
+ | `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` |
260
+ | `tool-result-pruner` | `@deepseek-ai/dsh-compaction-tool-result-pruner` |
261
+ | `image-offload` | `@deepseek-ai/dsh-compaction-image-offload` |
262
+ | `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
263
+ | `tool-goal` | `@deepseek-ai/dsh-tool-goal` |
264
+ | `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
265
+ | `repeat-tool-reminder` | `@deepseek-ai/dsh-repeat-tool-reminder` |
266
+ | `web` | `@deepseek-ai/dsh-web` |
267
+ | `web-search-deepseek` | `@deepseek-ai/dsh-web-search-deepseek` |
268
+ | `web-fetch-http` | `@deepseek-ai/dsh-web-fetch-http` |
269
+ | `tool-web` | `@deepseek-ai/dsh-tool-web` |
270
+ | `mcp-resources` | `@deepseek-ai/dsh-mcp-resources` |
271
+ | `tools` | `@deepseek-ai/dsh-tools` |
272
+ | `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
273
+ | `agent-loop` | `@deepseek-ai/dsh-agent-loop` |
274
+ | `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` |
275
+ | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
276
+
277
+ Source config: [`packages/bundle/base/cordis.patch.yml`](../../packages/bundle/base/cordis.patch.yml).
278
+
279
+ Maintenance mode: hybrid: the patch row list is parsed from its `cordis.yml`; app package expansion is curated from package source.
apps/cli/package.json ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@deepseek-ai/dsh",
3
+ "description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
4
+ "version": "0.1.6-alpha.1",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "apps/cli"
12
+ },
13
+ "type": "module",
14
+ "bin": {
15
+ "dsh": "lib/bin.js"
16
+ },
17
+ "files": [
18
+ "lib/*.js"
19
+ ],
20
+ "dsh": {
21
+ "configTrees": [
22
+ {
23
+ "mount": "config/agent-presets",
24
+ "path": "../../packages/preset/agent-presets/presets",
25
+ "scanRoster": true
26
+ }
27
+ ]
28
+ },
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "@deepseek-ai/cordis": "workspace:^",
32
+ "@deepseek-ai/cordis-plugin-hmr": "workspace:^",
33
+ "@deepseek-ai/cordis-plugin-include": "workspace:^",
34
+ "@deepseek-ai/cordis-plugin-loader": "workspace:^",
35
+ "@deepseek-ai/cordis-plugin-timer": "workspace:^",
36
+ "@deepseek-ai/dsh-acp-app": "workspace:^",
37
+ "@deepseek-ai/dsh-agent-instructions": "workspace:^",
38
+ "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^",
39
+ "@deepseek-ai/dsh-app-boot": "workspace:^",
40
+ "@deepseek-ai/dsh-base": "workspace:^",
41
+ "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
42
+ "@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
43
+ "@deepseek-ai/dsh-cmdline": "workspace:^",
44
+ "@deepseek-ai/dsh-command-compact": "workspace:^",
45
+ "@deepseek-ai/dsh-command-goal": "workspace:^",
46
+ "@deepseek-ai/dsh-compaction-basic": "workspace:^",
47
+ "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
48
+ "@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
49
+ "@deepseek-ai/dsh-fs-local": "workspace:^",
50
+ "@deepseek-ai/dsh-goal": "workspace:^",
51
+ "@deepseek-ai/dsh-goal-round-driver": "workspace:^",
52
+ "@deepseek-ai/dsh-headless": "workspace:^",
53
+ "@deepseek-ai/dsh-home-paths": "workspace:^",
54
+ "@deepseek-ai/dsh-hooks-claude-code": "workspace:^",
55
+ "@deepseek-ai/dsh-hooks-codex": "workspace:^",
56
+ "@deepseek-ai/dsh-jobs-local": "workspace:^",
57
+ "@deepseek-ai/dsh-launch-environment": "workspace:^",
58
+ "@deepseek-ai/dsh-mcp-client": "workspace:^",
59
+ "@deepseek-ai/dsh-persona": "workspace:^",
60
+ "@deepseek-ai/dsh-plan-mode": "workspace:^",
61
+ "@deepseek-ai/dsh-pwsh-local": "workspace:^",
62
+ "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
63
+ "@deepseek-ai/dsh-schedule": "workspace:^",
64
+ "@deepseek-ai/dsh-sdk-app": "workspace:^",
65
+ "@deepseek-ai/dsh-sdk-minimal": "workspace:^",
66
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
67
+ "@deepseek-ai/dsh-session-reference": "workspace:^",
68
+ "@deepseek-ai/dsh-skill": "workspace:^",
69
+ "@deepseek-ai/dsh-skill-filesystem": "workspace:^",
70
+ "@deepseek-ai/dsh-terminal": "workspace:^",
71
+ "@deepseek-ai/dsh-terminal-bash": "workspace:^",
72
+ "@deepseek-ai/dsh-time-context": "workspace:^",
73
+ "@deepseek-ai/dsh-tmux-context": "workspace:^",
74
+ "@deepseek-ai/dsh-token-meter": "workspace:^",
75
+ "@deepseek-ai/dsh-tool-ask-user": "workspace:^",
76
+ "@deepseek-ai/dsh-tool-bash": "workspace:^",
77
+ "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
78
+ "@deepseek-ai/dsh-tool-cordis": "workspace:^",
79
+ "@deepseek-ai/dsh-tool-present": "workspace:^",
80
+ "@deepseek-ai/dsh-tool-fs": "workspace:^",
81
+ "@deepseek-ai/dsh-tool-fs-search": "workspace:^",
82
+ "@deepseek-ai/dsh-tool-goal": "workspace:^",
83
+ "@deepseek-ai/dsh-tool-jobs": "workspace:^",
84
+ "@deepseek-ai/dsh-tool-pwsh": "workspace:^",
85
+ "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^",
86
+ "@deepseek-ai/dsh-tool-ralph": "workspace:^",
87
+ "@deepseek-ai/dsh-tool-skill": "workspace:^",
88
+ "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
89
+ "@deepseek-ai/dsh-tool-subagent": "workspace:^",
90
+ "@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
91
+ "@deepseek-ai/dsh-tool-todo": "workspace:^",
92
+ "@deepseek-ai/dsh-tool-web": "workspace:^",
93
+ "@deepseek-ai/dsh-tool-workflow": "workspace:^",
94
+ "@deepseek-ai/dsh-web-app": "workspace:^",
95
+ "@deepseek-ai/dsh-webhook": "workspace:^",
96
+ "@deepseek-ai/dsh-webhook-github": "workspace:^",
97
+ "@deepseek-ai/dsh-workflow-ptc": "workspace:^",
98
+ "@deepseek-ai/schemastery": "workspace:^",
99
+ "commander": "^15.0.0",
100
+ "js-yaml": "^4.2.0",
101
+ "node-addon-require-builtin": "^0.1.6",
102
+ "@deepseek-ai/dsh-http-proxy": "workspace:^",
103
+ "@deepseek-ai/dsh-mcp-resources": "workspace:^"
104
+ },
105
+ "devDependencies": {
106
+ "@agentclientprotocol/sdk": "1.4.0",
107
+ "@deepseek-ai/dsh-acp": "workspace:^",
108
+ "@deepseek-ai/dsh-agent": "workspace:^",
109
+ "@deepseek-ai/dsh-agent-loop": "workspace:^",
110
+ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
111
+ "@deepseek-ai/dsh-attachment-local": "workspace:^",
112
+ "@deepseek-ai/dsh-bash-local": "workspace:^",
113
+ "@deepseek-ai/dsh-credentials-local": "workspace:^",
114
+ "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^",
115
+ "@deepseek-ai/dsh-experimental-agent-team": "workspace:^",
116
+ "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^",
117
+ "@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:^",
118
+ "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^",
119
+ "@deepseek-ai/dsh-fs-observation-policy": "workspace:^",
120
+ "@deepseek-ai/dsh-fs-sandbox": "workspace:^",
121
+ "@deepseek-ai/dsh-host-frontend-static": "workspace:^",
122
+ "@deepseek-ai/dsh-host-webserver": "workspace:^",
123
+ "@deepseek-ai/dsh-llm": "workspace:^",
124
+ "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
125
+ "@deepseek-ai/dsh-llm-mock-server": "workspace:^",
126
+ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
127
+ "@deepseek-ai/dsh-llm-replay": "workspace:^",
128
+ "@deepseek-ai/dsh-loader-smoke": "workspace:^",
129
+ "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^",
130
+ "@deepseek-ai/dsh-sandbox-local": "workspace:^",
131
+ "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
132
+ "@deepseek-ai/dsh-session": "workspace:^",
133
+ "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
134
+ "@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
135
+ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
136
+ "@deepseek-ai/dsh-session-query": "workspace:^",
137
+ "@deepseek-ai/dsh-shell-env": "workspace:^",
138
+ "@deepseek-ai/dsh-settings": "workspace:^",
139
+ "@deepseek-ai/dsh-settings-file": "workspace:^",
140
+ "@deepseek-ai/dsh-subagent": "workspace:^",
141
+ "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^",
142
+ "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",
143
+ "@deepseek-ai/dsh-subprocess-local": "workspace:^",
144
+ "@deepseek-ai/dsh-system-prompt": "workspace:^",
145
+ "@deepseek-ai/dsh-tools": "workspace:^",
146
+ "@deepseek-ai/dsh-user-approval": "workspace:^",
147
+ "@types/js-yaml": "^4.0.9",
148
+ "@types/ws": "8.18.1",
149
+ "execa": "^10.0.0",
150
+ "ws": "8.21.0"
151
+ }
152
+ }
apps/cli/reference/README.i18n.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write apps/cli/reference/README.md
5
+ README.md: b2931e05c1c10c6de13427b2cdaf38a0e78db904
6
+ README.zh.md: 2a9014a5d338c3d81d9976d8cb47474a95c45cb5
apps/cli/reference/README.md ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `dsh` CLI behavior reference
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
6
+
7
+ ## Profile boot
8
+
9
+ `dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
10
+
11
+ Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and materializes the resulting fallback links. The internal runtime and dual modes consume the same immutable generation in tests without changing the CLI's link-mode behavior. Profile-installed packages keep native priority in every mode.
12
+
13
+ The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loudly with a hint to run `dsh plugin --profile <name> add <package>`.
14
+
15
+ `dsh --profile <name> --from-default-profile <template>` initializes a new custom target from one of those five shipped templates before boot. The target name cannot be a shipped profile name, and its complete profile directory must not exist. The launcher claims that directory exclusively, so residual files and another concurrent creator are rejected without modification. It copies the template's current bundle list and `patchReload` value into a new manifest with empty dependencies and an empty user patch. It does not read the local profile named by `<template>`, copy its dependencies or patch, or persist an inheritance field; later template-list changes do not rewrite the new profile. The in-box bundles named by that copied list still resolve from the current dsh installation. A successful initialization adds no launcher output.
16
+
17
+ An existing profile rejects `--from-default-profile` without changing or booting it; omit the option to use it. A residual target directory is also preserved and requires a different profile name. An unknown template or a shipped target name fails before creating the target. Unknown-template diagnostics name the valid templates. Initialization is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk and the retry omits the creation option. `--dump-config` and `--dump-default-config` accept the option, initialize the target, print the requested tree, and do not boot it.
18
+
19
+ ```sh
20
+ dsh --profile rescue --from-default-profile web
21
+ dsh --profile rescue
22
+ ```
23
+
24
+ ### App arguments
25
+
26
+ The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile rescue --from-default-profile web --no-open` therefore initializes before handing `--no-open` to Web, `dsh --profile web --port 8080` reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
27
+
28
+ A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. In a `patchReload: live` profile, a patch-file edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
29
+
30
+ Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments.
31
+
32
+ The shipped apps own these command lines:
33
+
34
+ | Profile | Arguments |
35
+ |---|---|
36
+ | `web` | `--host`, `--port`, repeatable `--trusted-host`, `--no-open` |
37
+ | `headless` | the task text, as the positional argument |
38
+ | `sdk` | no options; stdio carries the JSON-RPC protocol |
39
+ | `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol |
40
+ | `acp` | no options; stdio carries ACP (Agent Client Protocol) |
41
+
42
+ A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It streams non-empty provider reasoning deltas to stderr under a `dsh: reasoning:` heading, prints only the final text on stdout, and exits 0 for `completed`, else 1; a successful response with no reasoning leaves stderr empty. An invocation with no task is a usage error from that app. The shipped headless profile mounts no browser Connection, HTTP server, Web runtime, or browser client, and opens no listening port.
43
+
44
+ Inspect the composed tree without booting it:
45
+
46
+ ```sh
47
+ dsh --profile web --dump-default-config
48
+ dsh --profile web --patch ./extra.yml --dump-config
49
+ ```
50
+
51
+ `--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files but does not prepare the runtime module fallback under `$DSH_HOME/profiles/node_modules`. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
52
+
53
+ ## Plugin management
54
+
55
+ `dsh plugin --profile <name> <args...>` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `<args...>` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack.
56
+
57
+ The Codex and Claude Code subagent providers are separate optional Bundles. Add either package, both in one command, or remove either package independently:
58
+
59
+ ```sh
60
+ dsh plugin --profile <name> add @deepseek-ai/dsh-subagent-codex
61
+ dsh plugin --profile <name> add @deepseek-ai/dsh-subagent-claude-code
62
+ dsh plugin --profile <name> add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code
63
+ dsh plugin --profile <name> remove @deepseek-ai/dsh-subagent-codex
64
+ dsh plugin --profile <name> remove @deepseek-ai/dsh-subagent-claude-code
65
+ ```
66
+
67
+ The successful pnpm operation changes the Profile manifest and Bundle list on disk; a running Profile keeps the Bundle set from its current start. Restart that Profile after adding, removing, or updating a Bundle. This startup boundary applies to Bundle membership, while ordinary edits to the Profile or home `cordis.patch.yml` take effect through hot reload. On the next start, each installed Bundle registers only its dormant Host provider; a copied Preset must separately enable the matching tool row for new Agents. The [Codex provider README](../../../packages/subagent/subagent-codex/README.md) and [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.md) own executable, authentication, payload, and failure details; the [base Bundle reference](../../../packages/bundle/base/README.md) owns the default dependency closure.
68
+
69
+ ```sh
70
+ dsh plugin --profile tui add github:deepseek-harness/turtle-ui
71
+ dsh plugin --profile tui remove turtle-ui
72
+ dsh --profile tui
73
+ ```
74
+
75
+ Git-hosted plugins that ship sources build during install through their `prepare` script, which pnpm ≥10 blocks until the consumer allows it: the first `add` fails with pnpm's `allowBuilds` hint (and a dsh pointer at the profile's `pnpm-workspace.yaml`); copy the printed key there and re-run. Installing a built tarball or a local checkout needs no allowance.
76
+
77
+ ## Web alias
78
+
79
+ `dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
80
+
81
+ ```sh
82
+ dsh web
83
+ dsh web --no-open
84
+ dsh web --patch ./extra.cordis.yml
85
+ dsh web --dump-config
86
+ dsh web --help
87
+ ```
88
+
89
+ The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default and, for a local launch, opens that canonical host URL only after the complete Loader tree settles. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses the browser handoff because the SSH client or editor owns the local forwarded address; the host URL is still printed. The CLI intentionally does not support `--host 0.0.0.0` and exits with a usage error. Immediately before a local handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, a diagnostic on stderr states the reason, leaves the server running, and names the URL for manual use. `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
90
+
91
+ Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
92
+
93
+ The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root but intentionally omits filesystem tools, instruction discovery, and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers.
94
+
95
+ New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service.
96
+
97
+ `DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only the platform-selected persistent shell. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place.
98
+
99
+ ## Shared deployment behavior
100
+
101
+ The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the public-only HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
102
+
103
+ Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
104
+
105
+ Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
106
+
107
+ <a id="source-execution"></a>
108
+ ## Source execution
109
+
110
+ From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh <args...>`. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment, and `runProfile` resolves the outbound proxy from that snapshot before any entry mounts, so `HTTP_PROXY`/`HTTPS_PROXY` (and a proxy declared in a `.env` layer) apply without any further flag. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository.
apps/cli/reference/README.zh.md ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # `dsh` CLI(命令行界面)行为参考
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 本参考定义 profile 启动、web 别名、插件管理和配置 dump 等命令模式。argv 由 [`src/args.ts`](../src/args.ts) 统一解析一次,[`src/bin.ts`](../src/bin.ts) 只会动态导入选中的运行器。
6
+
7
+ <a id="profile-boot"></a>
8
+
9
+ ## Profile 启动
10
+
11
+ `dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
12
+
13
+ 组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并物化计算出的 fallback 链接。内部 runtime 与 dual 模式会在测试中消费同一份不可变 generation,但不改变 CLI 的 link 模式行为。所有模式都保留 profile 已安装包的原生优先级。
14
+
15
+ `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
16
+
17
+ `dsh --profile <name> --from-default-profile <template>` 会在启动前,从上述五个随附模板之一初始化新的自定义目标。目标名称不能是随附 profile 名称,并且完整的目标 profile 目录必须不存在。launcher 会以独占方式领取该目录,因此残留文件和另一个并发创建者都会在不作修改的情况下被拒绝。它把模板当前的组合包列表和 `patchReload` 值复制进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置组合包仍从当前 dsh 安装目录解析。初始化成功不会增加 launcher 输出。
18
+
19
+ profile 已经存在时,`--from-default-profile` 会被拒绝,且不会修改或启动它;去掉该选项即可使用它。残留的目标目录同样会被原样保留,此时必须改用另一个 profile 名称。未知模板或随附目标名称会在创建目标之前失败;未知模板的诊断会列出有效模板。初始化在组合包解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上,重试时需要去掉创建选项。`--dump-config` 和 `--dump-default-config` 接受该选项:它们初始化目标并打印所请求的配置树,但不启动应用。
20
+
21
+ ```sh
22
+ dsh --profile rescue --from-default-profile web
23
+ dsh --profile rescue
24
+ ```
25
+
26
+ ### 应用参数
27
+
28
+ 启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh --profile rescue --from-default-profile web --no-open` 会先初始化,再把 `--no-open` 交给 Web;`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
29
+
30
+ 每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到���务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。在 `patchReload: live` profile 中,编辑 patch 文件会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。
31
+
32
+ 启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。
33
+
34
+ 随附的应用接受以下命令行参数:
35
+
36
+ | Profile | 参数 |
37
+ |---|---|
38
+ | `web` | `--host`、`--port`、可重复的 `--trusted-host`、`--no-open` |
39
+ | `headless` | 任务文本,作为位置参数 |
40
+ | `sdk` | 无选项;stdio 携带 JSON-RPC 协议 |
41
+ | `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 |
42
+ | `acp` | 无选项;stdio 携带 ACP(Agent Client Protocol) |
43
+
44
+ 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 `dsh: reasoning:` 标题下将非空的提供方推理(reasoning)增量流式写入 stderr,只在 stdout 打印最终文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出;没有推理内容的成功响应会保持 stderr 为空。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载浏览器 Connection、HTTP 服务器、Web 运行时或浏览器客户端,也不会打开监听端口。
45
+
46
+ 可在不启动的情况下检查组合出的配置树:
47
+
48
+ ```sh
49
+ dsh --profile web --dump-default-config
50
+ dsh --profile web --patch ./extra.yml --dump-config
51
+ ```
52
+
53
+ `--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件,但不会准备 `$DSH_HOME/profiles/node_modules` 下的运行时模块 fallback。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。
54
+
55
+ ## 插件管理
56
+
57
+ `dsh plugin --profile <name> <args...>` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `<args...>` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。
58
+
59
+ Codex 与 Claude Code subagent 提供方是两个彼此独立的可选组合包。可以只添加一个包、在同一命令中添加两个包,或独立移除任一包:
60
+
61
+ ```sh
62
+ dsh plugin --profile <name> add @deepseek-ai/dsh-subagent-codex
63
+ dsh plugin --profile <name> add @deepseek-ai/dsh-subagent-claude-code
64
+ dsh plugin --profile <name> add @deepseek-ai/dsh-subagent-codex @deepseek-ai/dsh-subagent-claude-code
65
+ dsh plugin --profile <name> remove @deepseek-ai/dsh-subagent-codex
66
+ dsh plugin --profile <name> remove @deepseek-ai/dsh-subagent-claude-code
67
+ ```
68
+
69
+ pnpm 操作成功后会改变磁盘上的 Profile manifest 与组合包列表;正在运行的 Profile 会保留本次启动时的组合包集合。添加、移除或更新组合包后须重启该 Profile。这个启动边界只适用于组合包成员变化,Profile 或 home 中普通 `cordis.patch.yml` 的编辑通过热重载生效。下一次启动时,每个已安装组���包只注册自己的休眠 Host 提供方;还须在复制出的 Preset 中单独启用对应工具行,新 Agent 才能看到该工具。[Codex provider README](../../../packages/subagent/subagent-codex/README.zh.md) 与 [Claude Code provider README](../../../packages/subagent/subagent-claude-code/README.zh.md) 负责可执行文件、身份验证、载荷与失败细节;[base 组合包参考](../../../packages/bundle/base/README.zh.md) 负责默认依赖闭包。
70
+
71
+ ```sh
72
+ dsh plugin --profile tui add github:deepseek-harness/turtle-ui
73
+ dsh plugin --profile tui remove turtle-ui
74
+ dsh --profile tui
75
+ ```
76
+
77
+ 随源码发布的 Git 托管插件会在安装期间通过 `prepare` 脚本构建,而 pnpm ≥10 默认会阻止该脚本,直到使用方明确允许。首次运行 `add` 会失败,并显示 pnpm 的 `allowBuilds` 提示;dsh 还会提示应修改该 profile 的 `pnpm-workspace.yaml`。将输出的键复制到该文件后,重新运行命令即可。安装已经构建好的 tarball 或本地 checkout 时,无需加入 `allowBuilds`。
78
+
79
+ ## Web 别名
80
+
81
+ `dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。
82
+
83
+ ```sh
84
+ dsh web
85
+ dsh web --no-open
86
+ dsh web --patch ./extra.cordis.yml
87
+ dsh web --dump-config
88
+ dsh web --help
89
+ ```
90
+
91
+ 生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`;本机启动时,只在完整 Loader 配置树结算后才用默认浏览器打开该规范宿主机 URL。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有;宿主机 URL 仍会打印。CLI 有意不支持 `--host 0.0.0.0`,并会以用法错误退出。本机交接前会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 诊断会说明原因、给出 URL 供手动访问,服务器仍继续运行。`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
92
+
93
+ 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
94
+
95
+ 基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录,但刻意省略文件系统工具、指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose 所有实时监视器。
96
+
97
+ 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。
98
+
99
+ `DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合按平台选择的持久 shell。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
100
+
101
+ ## 共享部署行为
102
+
103
+ 基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次���继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
104
+
105
+ 反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;将其 `enabled` 配置设为 `false` 可关闭上传。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
106
+
107
+ 通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。
108
+
109
+ <a id="source-execution"></a>
110
+ ## 源码执行
111
+
112
+ 请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>`。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境,且 `runProfile` 会在任何 entry 挂载之前从该快照解析出站代理,因此 `HTTP_PROXY`/`HTTPS_PROXY`(以及写在 `.env` 层中的代理)无需任何额外开关即可生效。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。
apps/cli/tests/agent-team-headless.e2e.ts ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import { execa } from 'execa'
6
+ import { describe, expect, it } from 'vitest'
7
+ import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
8
+
9
+ const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
10
+ const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
11
+ const fixturePlugin = pathToFileURL(fileURLToPath(
12
+ new URL('./profiles/headless/tests/fixtures/team-llm.mjs', import.meta.url),
13
+ )).href
14
+
15
+ function records(content: string): Record<string, unknown>[] {
16
+ return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as Record<string, unknown>)
17
+ }
18
+
19
+ describe('dsh run with Agent Teams enabled', () => {
20
+ it('runs two teammates, durable peer mail, dependent tasks, waiting, and final aggregation', async () => {
21
+ const cwd = await mkdtemp(join(tmpdir(), 'dsh-agent-team-headless-'))
22
+ try {
23
+ const home = join(cwd, '.dsh')
24
+ const sessions = join(home, 'sessions')
25
+ const profileDir = join(home, 'profiles', 'headless')
26
+ await mkdir(profileDir, { recursive: true })
27
+ await writeFile(join(profileDir, 'package.json'), JSON.stringify({
28
+ name: 'dsh-profile-headless',
29
+ private: true,
30
+ dependencies: {
31
+ '@deepseek-ai/dsh-experimental-agent-team-profile': 'workspace:^',
32
+ },
33
+ dsh: {
34
+ profile: {
35
+ bundles: [
36
+ '@deepseek-ai/dsh-base',
37
+ '@deepseek-ai/dsh-headless',
38
+ '@deepseek-ai/dsh-experimental-agent-team-profile',
39
+ ],
40
+ },
41
+ },
42
+ }, undefined, 2) + '\n')
43
+ await writeFile(join(profileDir, 'cordis.patch.yml'), [
44
+ '- id: llm-deepseek',
45
+ ' disabled: true',
46
+ '- id: session-persistence-jsonl',
47
+ ' config:',
48
+ ` root: '${sessions}'`,
49
+ ' compression: none',
50
+ '- insert:',
51
+ ' - id: team-fixture-llm',
52
+ ` name: '${fixturePlugin}'`,
53
+ '',
54
+ ].join('\n'))
55
+ const launch = resolveExampleLaunch({
56
+ srcBin: dshBinScript,
57
+ configArgs: ['--profile', 'headless', '请先运行 workflow 检查,再使用 Agent Teams 把调研和实现拆给两个 teammate,等待完成后汇总。'],
58
+ tsconfigPath,
59
+ env: {
60
+ DSH_HOME: home,
61
+ DSH_AGENTS_HOME: join(cwd, '.agents'),
62
+ DSH_TELEMETRY_DISABLED: '1',
63
+ DEEPSEEK_API_KEY: '',
64
+ NODE_OPTIONS: [
65
+ process.env.NODE_OPTIONS,
66
+ '--disable-warning=ExperimentalWarning',
67
+ '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
68
+ ].filter(Boolean).join(' '),
69
+ },
70
+ })
71
+ const result = await execa(launch.command, launch.args, {
72
+ cwd,
73
+ env: launch.env,
74
+ input: '',
75
+ timeout: 90_000,
76
+ killSignal: 'SIGKILL',
77
+ reject: false,
78
+ })
79
+ expect(
80
+ result.exitCode,
81
+ `dsh headless profile exited unexpectedly.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
82
+ ).toBe(0)
83
+ expect(result.stderr).toBe('')
84
+ expect(result.stdout).toContain('TEAM_WORKFLOW_OK')
85
+
86
+ const files = (await readdir(sessions, { recursive: true }))
87
+ .filter(file => file.endsWith('.jsonl'))
88
+ expect(files).toHaveLength(4)
89
+ const logs = await Promise.all(files.map(file => readFile(join(sessions, file), 'utf8')))
90
+ const parsed = logs.map(records)
91
+ const workflowChild = parsed.find(log => log.some(record => record.type === 'subagent/descriptor'
92
+ && (record.data as { mode: string }).mode === 'one-shot'))
93
+ expect(workflowChild).toBeDefined()
94
+ expect(workflowChild!.find(record => record.type === 'subagent/descriptor')?.data)
95
+ .toMatchObject({ mode: 'one-shot', provider: 'spawn' })
96
+ expect(workflowChild!.filter(record => record.type === 'user/message'
97
+ && (record.data as { source: { kind: string } }).source.kind === 'user').map(record => record.data))
98
+ .toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }] })])
99
+ const root = parsed.find((log) => {
100
+ const header = log[0]
101
+ return header?.type === 'session' && typeof header.parentSession !== 'string'
102
+ })
103
+ expect(root).toBeDefined()
104
+ const eventTypes = root!.map(record => record.type)
105
+ expect(eventTypes.filter(type => type === 'team/member')).toHaveLength(4)
106
+ expect(eventTypes).toContain('team/message/queued')
107
+ expect(eventTypes).toContain('team/message/delivered')
108
+ const taskEvents = root!.filter(record => record.type === 'team/task')
109
+ expect(taskEvents.filter((record) => {
110
+ const data = record.data as { task?: { status?: string } } | undefined
111
+ return data?.task?.status === 'completed'
112
+ })).toHaveLength(2)
113
+ const toolNames = root!.filter(record => record.type === 'tool/call')
114
+ .map(record => (record.data as { name?: string } | undefined)?.name)
115
+ expect(toolNames).toContain('wait_agent')
116
+ expect(toolNames).toContain('team_task_list')
117
+ expect(toolNames).toContain('list_agents')
118
+ expect(toolNames).toContain('workflow')
119
+ expect(root!.find(record => record.type === 'tool-workflow/run-end')?.data)
120
+ .toMatchObject({ stopReason: 'completed' })
121
+ } finally {
122
+ await rm(cwd, { recursive: true, force: true })
123
+ }
124
+ }, 105_000)
125
+ })
apps/cli/tests/args.spec.ts ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { parseDshArgs } from '../src/args.ts'
3
+
4
+ const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
5
+
6
+ /** Capture the process exit code while muting Commander's output. */
7
+ function exitCode(argv: string[]): number {
8
+ const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
9
+ vi.spyOn(process.stdout, 'write').mockReturnValue(true)
10
+ vi.spyOn(process.stderr, 'write').mockReturnValue(true)
11
+ try {
12
+ parse(argv)
13
+ throw new Error(`expected ${JSON.stringify(argv)} to exit`)
14
+ } catch {
15
+ return exit.mock.calls.at(-1)?.[0] as number
16
+ } finally {
17
+ vi.restoreAllMocks()
18
+ }
19
+ }
20
+
21
+ afterEach(() => { vi.restoreAllMocks() })
22
+
23
+ describe('parseDshArgs', () => {
24
+ it('routes profile boots and the web alias, handing the rest to the app', () => {
25
+ expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] })
26
+ expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml']))
27
+ .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] })
28
+ expect(parse(['--profile', 'rescue', '--from-default-profile', 'web']))
29
+ .toEqual({ mode: 'profile', profile: 'rescue', fromDefaultProfile: 'web', patches: [], args: [] })
30
+ expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] })
31
+ expect(parse(['web', '--patch', 'web.yml']))
32
+ .toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] })
33
+ })
34
+
35
+ it('ends the launcher flags at the first token it does not own', () => {
36
+ // App flags, including its -h, and positionals reach the app verbatim.
37
+ expect(parse(['--profile', 'tui', '--resume', 'abc']))
38
+ .toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] })
39
+ expect(parse(['--profile', 'web', '-h']))
40
+ .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] })
41
+ expect(parse(['web', '--host', '127.0.0.1', '--port', '8080', '--no-open', '--future-web-flag']))
42
+ .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '127.0.0.1', '--port', '8080', '--no-open', '--future-web-flag'] })
43
+ expect(parse(['--profile', 'headless', 'run', 'the', 'tests']))
44
+ .toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] })
45
+ // Launcher flags placed after that boundary belong to the app too.
46
+ expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml']))
47
+ .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] })
48
+ expect(parse(['--profile', 'rescue', '--resume', 'abc', '--from-default-profile', 'web']))
49
+ .toEqual({
50
+ mode: 'profile',
51
+ profile: 'rescue',
52
+ patches: [],
53
+ args: ['--resume', 'abc', '--from-default-profile', 'web'],
54
+ })
55
+ expect(parse(['web', '--from-default-profile', 'web']))
56
+ .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--from-default-profile', 'web'] })
57
+ })
58
+
59
+ it('routes the plugin pnpm forwarder', () => {
60
+ expect(parse(['plugin', '--profile', 'tui', 'add', 'turtle-ui']))
61
+ .toEqual({ mode: 'plugin', profile: 'tui', args: ['add', 'turtle-ui'] })
62
+ expect(parse(['plugin', '--profile', 'tui', 'remove', 'turtle-ui']))
63
+ .toEqual({ mode: 'plugin', profile: 'tui', args: ['remove', 'turtle-ui'] })
64
+ expect(parse(['plugin', '--profile', 'tui', 'why', '@deepseek-ai/cordis']))
65
+ .toEqual({ mode: 'plugin', profile: 'tui', args: ['why', '@deepseek-ai/cordis'] })
66
+ // Unknown pnpm flags forward verbatim.
67
+ expect(parse(['plugin', '--profile', 'tui', 'add', '--save-dev', 'x']))
68
+ .toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] })
69
+ })
70
+
71
+ it('routes profile and web config dumps', () => {
72
+ expect(parse(['--profile', 'web', '--dump-config']))
73
+ .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: false, patches: [] })
74
+ expect(parse(['--profile', 'web', '--dump-default-config']))
75
+ .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
76
+ expect(parse(['--profile', 'rescue', '--from-default-profile', 'web', '--dump-config']))
77
+ .toEqual({
78
+ mode: 'dump-config',
79
+ profile: 'rescue',
80
+ fromDefaultProfile: 'web',
81
+ defaultOnly: false,
82
+ patches: [],
83
+ })
84
+ expect(parse(['--profile', 'tui', '--dump-config', '--patch', 'x.yml']))
85
+ .toEqual({ mode: 'dump-config', profile: 'tui', defaultOnly: false, patches: ['x.yml'] })
86
+ expect(parse(['web', '--dump-config']))
87
+ .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: false, patches: [] })
88
+ expect(parse(['web', '--dump-default-config']))
89
+ .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
90
+ })
91
+
92
+ it('rejects missing profile, removed flags, and contradictory inputs', () => {
93
+ expect(exitCode([])).toBe(1)
94
+ expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach
95
+ expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
96
+ expect(exitCode(['-p', 'task'])).toBe(1) // removed
97
+ expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand
98
+ expect(exitCode(['--profile', ''])).toBe(1)
99
+ expect(exitCode(['--profile', 'x', '--from-default-profile='])).toBe(1)
100
+ expect(exitCode(['--profile', 'x', '--from-default-profile'])).toBe(1)
101
+ expect(exitCode(['--profile', 'x', '--patch='])).toBe(1)
102
+ expect(exitCode(['--dump-config'])).toBe(1)
103
+ expect(exitCode(['--profile', 'x', '--dump-config', '--dump-default-config'])).toBe(1)
104
+ expect(exitCode(['--profile', 'x', '--dump-default-config', '--patch', 'p.yml'])).toBe(1)
105
+ expect(exitCode(['--profile', 'x', '--dump-config', 'task'])).toBe(1)
106
+ expect(exitCode(['--bogus'])).toBe(1)
107
+ expect(exitCode(['--profile', 'x', 'web'])).toBe(1)
108
+ expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
109
+ expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
110
+ expect(exitCode(['web', '--patch='])).toBe(1)
111
+ // A dump never runs app command-line providers, so it cannot show what
112
+ // those flags would decide; printing a tree that differs from the same
113
+ // invocation's boot would mislead.
114
+ expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1)
115
+ expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1)
116
+ expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
117
+ expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
118
+ expect(exitCode(['plugin', '--profile', ''])).toBe(1)
119
+ expect(exitCode(['--profile', 'desktop'])).toBe(1)
120
+ expect(exitCode(['--profile', 'Desktop'])).toBe(1)
121
+ expect(exitCode(['--profile', 'DESKTOP'])).toBe(1)
122
+ expect(exitCode(['--profile', 'desktop', '--dump-config'])).toBe(1)
123
+ expect(exitCode(['plugin', '--profile', 'desktop', 'add', 'x'])).toBe(1)
124
+ expect(exitCode(['plugin', '--profile', 'Desktop', 'add', 'x'])).toBe(1)
125
+ expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
126
+ expect(exitCode(['--from-default-profile', 'web', 'plugin', '--profile', 'x', 'add', 'y'])).toBe(1)
127
+ })
128
+
129
+ it('keeps its own help for an invocation with no app to hand it to', () => {
130
+ expect(exitCode(['--help'])).toBe(0)
131
+ expect(exitCode(['-h'])).toBe(0)
132
+ expect(exitCode(['--version'])).toBe(0)
133
+ })
134
+ })
apps/cli/tests/built-bin.e2e.ts ADDED
@@ -0,0 +1,1151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { createInterface } from 'node:readline'
5
+ import { Readable, Writable } from 'node:stream'
6
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
+ import {
8
+ client as createAcpClientApp,
9
+ methods,
10
+ ndJsonStream,
11
+ PROTOCOL_VERSION,
12
+ type SessionNotification,
13
+ } from '@agentclientprotocol/sdk'
14
+ import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
15
+ import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
16
+ import { execa } from 'execa'
17
+ import * as yaml from 'js-yaml'
18
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
19
+
20
+ /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
21
+ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
22
+ // The dsh built bin cold-starts slowly on the contended self-hosted Windows pool; the
23
+ // execa deadline, its error text, the outer vitest case budget, and waitForFile all
24
+ // share this value so a widening cannot leave a stale 25s diagnostic behind.
25
+ const SPAWN_TIMEOUT_MS = 60_000
26
+ // The release version, including a prerelease such as 0.0.1-rc.1: `--version`
27
+ // prints what this manifest carries, so no test may pin it to a literal.
28
+ const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
29
+ const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
30
+ const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
31
+ const webReadyExitHook = new URL('./fixtures/web-browser-open/register.mjs', import.meta.url).href
32
+
33
+ async function runBuiltBin(
34
+ args: readonly string[] = [],
35
+ env: Readonly<Record<string, string | undefined>> = {},
36
+ cwd?: string,
37
+ ): Promise<{ stdout: string; code: number; stderr: string }> {
38
+ const childEnv = Object.fromEntries(
39
+ Object.entries({ ...process.env, ...env })
40
+ .filter((entry): entry is [string, string] => entry[1] !== undefined),
41
+ )
42
+ const result = await execa(process.execPath, [dshBin, ...args], {
43
+ input: '',
44
+ timeout: SPAWN_TIMEOUT_MS,
45
+ killSignal: 'SIGKILL',
46
+ reject: false,
47
+ env: childEnv,
48
+ extendEnv: false,
49
+ ...cwd === undefined ? {} : { cwd },
50
+ })
51
+ if (result.timedOut) {
52
+ throw new Error(`dsh built bin did not exit within ${SPAWN_TIMEOUT_MS / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
53
+ }
54
+ return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
55
+ }
56
+
57
+ async function waitForFile(file: string): Promise<void> {
58
+ const deadline = Date.now() + SPAWN_TIMEOUT_MS
59
+ while (!existsSync(file)) {
60
+ if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
61
+ await new Promise(resolve => setTimeout(resolve, 20))
62
+ }
63
+ }
64
+
65
+ interface ProfileLifecycleFixture {
66
+ home: string
67
+ ready: string
68
+ settled: string
69
+ disposed: string
70
+ interrupt: string
71
+ }
72
+
73
+ /**
74
+ * A minimal custom profile: one lifecycle-marker plugin bundle listed in
75
+ * dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without
76
+ * booting the entire product tree.
77
+ */
78
+ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
79
+ const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-'))
80
+ const ready = join(home, 'ready')
81
+ const settled = join(home, 'settled')
82
+ const disposed = join(home, 'disposed')
83
+ const interrupt = join(home, 'interrupt')
84
+ const bundleDir = join(home, 'lifecycle-bundle')
85
+ mkdirSync(bundleDir, { recursive: true })
86
+ writeFileSync(join(bundleDir, 'plugin.mjs'), [
87
+ "import { existsSync, writeFileSync } from 'node:fs'",
88
+ "import { join } from 'node:path'",
89
+ "export const name = 'profile-lifecycle-fixture'",
90
+ 'export function apply(ctx, config = {}) {',
91
+ ' let active = true',
92
+ ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
93
+ ' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
94
+ ' let interrupted = false',
95
+ ' const heartbeat = setInterval(() => {',
96
+ ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
97
+ ' interrupted = true',
98
+ " process.emit('SIGTERM')",
99
+ ' }, 20)',
100
+ ' // Echo the mounted generation so the hot-reload e2e can assert both an',
101
+ ' // applied override and its removal reverting to this bundle default.',
102
+ " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
103
+ " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
104
+ ' void ctx.loader.await().then(() => {',
105
+ " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
106
+ ' })',
107
+ ' ctx.effect(() => () => {',
108
+ ' active = false',
109
+ ' clearInterval(heartbeat)',
110
+ " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
111
+ ' })',
112
+ '}',
113
+ '',
114
+ ].join('\n'))
115
+ writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
116
+ '- insert:',
117
+ ' - id: profile-lifecycle-fixture',
118
+ ` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
119
+ '',
120
+ ].join('\n'))
121
+ writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
122
+ name: 'dsh-lifecycle-bundle',
123
+ version: '0.0.0',
124
+ type: 'module',
125
+ dsh: { bundle: { patch: './cordis.patch.yml' } },
126
+ }, undefined, 2))
127
+ const profileDir = join(home, 'profiles', 'lifecycle')
128
+ mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
129
+ writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
130
+ name: 'dsh-profile-lifecycle',
131
+ private: true,
132
+ dependencies: {},
133
+ dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } },
134
+ }, undefined, 2))
135
+ // Hand-place the "installed" bundle where profile resolution finds it.
136
+ writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
137
+ const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle')
138
+ mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
139
+ try {
140
+ rmSync(linkTarget, { recursive: true, force: true })
141
+ } catch { /* fresh dir */ }
142
+ // Copy-free: a package.json redirecting via a relative main is enough for require.resolve.
143
+ mkdirSync(linkTarget, { recursive: true })
144
+ for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
145
+ writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
146
+ }
147
+ return { home, ready, settled, disposed, interrupt }
148
+ }
149
+
150
+ function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) {
151
+ return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
152
+ cwd: fixture.home,
153
+ input: '',
154
+ timeout: SPAWN_TIMEOUT_MS,
155
+ killSignal: 'SIGKILL',
156
+ reject: false,
157
+ env: {
158
+ DSH_HOME: fixture.home,
159
+ RAW_READY_FILE: fixture.ready,
160
+ RAW_SETTLED_FILE: fixture.settled,
161
+ RAW_DISPOSED_FILE: fixture.disposed,
162
+ RAW_INTERRUPT_FILE: fixture.interrupt,
163
+ },
164
+ })
165
+ }
166
+
167
+ function requestProfileShutdown(
168
+ child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>,
169
+ fixture: Pick<ProfileLifecycleFixture, 'interrupt'>,
170
+ ): void {
171
+ if (process.platform === 'win32') {
172
+ writeFileSync(fixture.interrupt, 'interrupt')
173
+ return
174
+ }
175
+ child.kill('SIGTERM')
176
+ }
177
+
178
+ function createEnvironmentProbeProfile(home: string, project: string): void {
179
+ const pluginFile = join(project, 'environment-probe.mjs')
180
+ writeFileSync(pluginFile, [
181
+ "export const name = 'environment-probe'",
182
+ "export const inject = ['llm']",
183
+ 'export function apply(ctx) {',
184
+ ' void ctx.loader.await().then(async () => {',
185
+ " let text = ''",
186
+ ' for await (const chunk of ctx.llm.stream({',
187
+ " provider: 'deepseek-official',",
188
+ " model: 'deepseek-v4-flash',",
189
+ ' messages: [],',
190
+ ' maxTokens: 32,',
191
+ ' })) {',
192
+ " if (chunk.type === 'text-delta') text += chunk.text",
193
+ ' }',
194
+ ' process.stdout.write(`${text}\\n`)',
195
+ " if (process.platform === 'win32') process.emit('SIGTERM')",
196
+ " else process.kill(process.pid, 'SIGTERM')",
197
+ ' })',
198
+ '}',
199
+ '',
200
+ ].join('\n'))
201
+ const profileDir = join(home, 'profiles', 'environment-probe')
202
+ mkdirSync(profileDir, { recursive: true })
203
+ writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
204
+ name: 'dsh-profile-environment-probe',
205
+ private: true,
206
+ dependencies: {},
207
+ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
208
+ }, undefined, 2))
209
+ writeFileSync(join(profileDir, 'cordis.patch.yml'), [
210
+ '- insert:',
211
+ ' - id: environment-probe',
212
+ ` name: ${pathToFileURL(pluginFile).href}`,
213
+ '',
214
+ ].join('\n'))
215
+ }
216
+
217
+ interface StartupFixture {
218
+ home: string
219
+ ready: string
220
+ echo: string
221
+ interrupt: string
222
+ /** An always-running row's echo, used to observe that a user patch reload landed. */
223
+ witness: string
224
+ }
225
+
226
+ /**
227
+ * A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus
228
+ * a row that reads its app-owned service through a `!!js` config expression.
229
+ * Both plugin modules resolve
230
+ * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
231
+ * fallback, exactly as an installed out-of-tree bundle does.
232
+ */
233
+ function createStartupFixture(): StartupFixture {
234
+ const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
235
+ const profileDir = join(home, 'profiles', 'startup')
236
+ // Written straight into the installed location: a row module resolves its
237
+ // own imports from where it is installed, and only inside the profile does
238
+ // Node's parent walk reach the installation fallback these plugins need.
239
+ const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
240
+ mkdirSync(bundleDir, { recursive: true })
241
+ writeFileSync(join(bundleDir, 'startup.mjs'), [
242
+ "import { Command } from 'commander'",
243
+ "import { parseCmdline } from '@deepseek-ai/dsh-cmdline'",
244
+ "export const name = 'fixture-startup'",
245
+ "export const inject = ['cmdlineArgs']",
246
+ 'export function apply(ctx) {',
247
+ " const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
248
+ " program.action(() => ctx.provide('fixtureStartup', { generation: program.opts().generation }))",
249
+ ' parseCmdline(ctx, program)',
250
+ '}',
251
+ '',
252
+ ].join('\n'))
253
+ writeFileSync(join(bundleDir, 'waiting.mjs'), [
254
+ "import { existsSync, writeFileSync } from 'node:fs'",
255
+ "import { join } from 'node:path'",
256
+ "export const name = 'startup-fixture'",
257
+ 'export function apply(ctx, config = {}) {',
258
+ ' let interrupted = false',
259
+ ' const heartbeat = setInterval(() => {',
260
+ ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
261
+ ' interrupted = true',
262
+ " process.emit('SIGTERM')",
263
+ ' }, 20)',
264
+ " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
265
+ " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
266
+ ' ctx.effect(() => () => { clearInterval(heartbeat) })',
267
+ '}',
268
+ '',
269
+ ].join('\n'))
270
+ writeFileSync(join(bundleDir, 'witness.mjs'), [
271
+ "import { writeFileSync } from 'node:fs'",
272
+ "import { join } from 'node:path'",
273
+ "export const name = 'reload-witness'",
274
+ 'export function apply(ctx, config = {}) {',
275
+ " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
276
+ '}',
277
+ '',
278
+ ].join('\n'))
279
+ writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
280
+ '- insert:',
281
+ ' - id: startup-fixture',
282
+ ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
283
+ ' inject: [fixtureStartup]',
284
+ ' config:',
285
+ // Lazy interpolation runs only after the provider's service is injected.
286
+ " generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'",
287
+ ' - id: fixture-startup',
288
+ ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
289
+ ' - id: reload-witness',
290
+ ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
291
+ '',
292
+ ].join('\n'))
293
+ writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
294
+ name: 'dsh-startup-bundle',
295
+ version: '0.0.0',
296
+ type: 'module',
297
+ dsh: { bundle: { patch: './cordis.patch.yml' } },
298
+ }, undefined, 2))
299
+ writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
300
+ name: 'dsh-profile-startup',
301
+ private: true,
302
+ dependencies: {},
303
+ dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
304
+ }, undefined, 2))
305
+ writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
306
+ return {
307
+ home,
308
+ ready: join(home, 'ready'),
309
+ echo: join(home, 'config-echo'),
310
+ interrupt: join(home, 'interrupt'),
311
+ witness: join(home, 'witness'),
312
+ }
313
+ }
314
+
315
+ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
316
+ return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
317
+ cwd: fixture.home,
318
+ input: '',
319
+ reject: false,
320
+ timeout: SPAWN_TIMEOUT_MS,
321
+ killSignal: 'SIGKILL',
322
+ env: {
323
+ DSH_HOME: fixture.home,
324
+ RAW_READY_FILE: fixture.ready,
325
+ RAW_INTERRUPT_FILE: fixture.interrupt,
326
+ },
327
+ })
328
+ }
329
+
330
+ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
331
+ it('requires --profile and rejects removed commands', async () => {
332
+ const bare = await runBuiltBin()
333
+ expect(bare.code).toBe(1)
334
+ expect(bare.stdout).toBe('')
335
+ expect(bare.stderr).toContain('--profile <name> is required')
336
+ const help = await runBuiltBin(['--help'])
337
+ expect(help.code).toBe(0)
338
+ expect(help.stdout).toContain('dsh --profile web')
339
+ expect(help.stdout).toContain('dsh plugin --profile')
340
+ expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
341
+ for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
342
+ const result = await runBuiltBin(removed)
343
+ expect(result.code).toBe(1)
344
+ }
345
+ }, SPAWN_TIMEOUT_MS * 3 + 30_000)
346
+
347
+ it('routes help and usage errors without activating startup-dependent rows', async () => {
348
+ const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
349
+ try {
350
+ const web = await runBuiltBin(['--profile', 'web', '--help'], {
351
+ DSH_HOME: home,
352
+ DSH_TELEMETRY_DISABLED: '1',
353
+ })
354
+ expect(web.code).toBe(0)
355
+ expect(web.stderr).toBe('')
356
+ expect(web.stdout).toContain('Usage: dsh --profile web')
357
+ expect(web.stdout).toContain('--port <port>')
358
+ expect(web.stdout).not.toContain('dsh web: http://')
359
+
360
+ const wildcardHost = await runBuiltBin(['web', '--host', '0.0.0.0'], {
361
+ DSH_HOME: home,
362
+ DSH_TELEMETRY_DISABLED: '1',
363
+ })
364
+ expect(wildcardHost.code).toBe(1)
365
+ expect(wildcardHost.stdout).toBe('')
366
+ expect(wildcardHost.stderr).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
367
+ expect(wildcardHost.stderr).not.toContain('dsh web: http://')
368
+
369
+ const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
370
+ DSH_HOME: home,
371
+ DSH_TELEMETRY_DISABLED: '1',
372
+ })
373
+ expect(headlessHelp.code).toBe(0)
374
+ expect(headlessHelp.stderr).toBe('')
375
+ expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
376
+
377
+ const sdkHelp = await runBuiltBin(['--profile', 'sdk', '--help'], {
378
+ DSH_HOME: home,
379
+ DSH_TELEMETRY_DISABLED: '1',
380
+ })
381
+ expect(sdkHelp.code).toBe(0)
382
+ expect(sdkHelp.stderr).toBe('')
383
+ expect(sdkHelp.stdout).toContain('Usage: dsh --profile sdk')
384
+
385
+ const acpHelp = await runBuiltBin(['--profile', 'acp', '--help'], {
386
+ DSH_HOME: home,
387
+ DSH_TELEMETRY_DISABLED: '1',
388
+ })
389
+ expect(acpHelp.code).toBe(0)
390
+ expect(acpHelp.stderr).toBe('')
391
+ expect(acpHelp.stdout).toContain('Usage: dsh --profile acp')
392
+
393
+ const missingTask = await runBuiltBin(['--profile', 'headless'], {
394
+ DSH_HOME: home,
395
+ DSH_TELEMETRY_DISABLED: '1',
396
+ })
397
+ expect(missingTask.code).toBe(1)
398
+ expect(missingTask.stderr).toContain('a task is required')
399
+ } finally {
400
+ rmSync(home, { recursive: true, force: true })
401
+ }
402
+ }, SPAWN_TIMEOUT_MS * 3 + 30_000)
403
+
404
+ it('ignores an optional SDK plugin import failure before stdin reaches EOF', async () => {
405
+ const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
406
+ const patch = join(home, 'broken-sdk.cordis.yml')
407
+ writeFileSync(patch, [
408
+ '- insert:',
409
+ ' - id: missing-sdk-startup-plugin',
410
+ ' name: "@deepseek-ai/dsh-missing-sdk-startup-plugin"',
411
+ '',
412
+ ].join('\n'))
413
+ try {
414
+ const result = await runBuiltBin(['--profile', 'sdk', '--patch', patch], {
415
+ DSH_HOME: home,
416
+ DSH_TELEMETRY_DISABLED: '1',
417
+ DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
418
+ }, home)
419
+ expect(result.code).toBe(0)
420
+ expect(result.stdout).toBe('')
421
+ expect(result.stderr).toContain('warning: 1 entry did not activate')
422
+ expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin')
423
+ } finally {
424
+ rmSync(home, { recursive: true, force: true })
425
+ }
426
+ }, SPAWN_TIMEOUT_MS + 30_000)
427
+
428
+ it('serves the SDK protocol with an absolute-path overlay plugin and exits after shutdown', async () => {
429
+ const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
430
+ const pluginPath = join(home, 'plugin #100%.mjs')
431
+ const marker = join(home, 'plugin-loaded')
432
+ writeFileSync(pluginPath, [
433
+ "import { writeFileSync } from 'node:fs'",
434
+ 'export function apply(ctx, config) { writeFileSync(config.marker, "loaded") }',
435
+ '',
436
+ ].join('\n'))
437
+ const patch = join(home, 'absolute.patch.yml')
438
+ writeFileSync(patch, JSON.stringify([{ insert: [
439
+ { id: 'absolute-plugin', name: pluginPath, config: { marker } },
440
+ ] }]))
441
+ const child = execa(process.execPath, [dshBin, '--profile', 'sdk', '--patch', patch], {
442
+ cwd: home,
443
+ reject: false,
444
+ timeout: SPAWN_TIMEOUT_MS,
445
+ killSignal: 'SIGKILL',
446
+ env: {
447
+ ...process.env,
448
+ DSH_HOME: home,
449
+ DSH_TELEMETRY_DISABLED: '1',
450
+ DEEPSEEK_API_KEY: 'built-sdk-profile-no-call',
451
+ },
452
+ extendEnv: false,
453
+ })
454
+ const stdoutLines = createInterface({ input: child.stdout, crlfDelay: Infinity })[Symbol.asyncIterator]()
455
+ let stderr = ''
456
+ child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
457
+ const response = async (id: number): Promise<Record<string, unknown>> => {
458
+ for (;;) {
459
+ const line = await stdoutLines.next()
460
+ if (line.done) throw new Error(`SDK profile stdout closed before response ${String(id)}; stderr=${stderr}`)
461
+ let value: Record<string, unknown>
462
+ try {
463
+ value = JSON.parse(line.value) as Record<string, unknown>
464
+ } catch {
465
+ throw new Error(`SDK profile wrote non-JSON stdout: ${line.value}`)
466
+ }
467
+ if (value.id === id) return value
468
+ }
469
+ }
470
+ try {
471
+ child.stdin.write(`${JSON.stringify({
472
+ jsonrpc: '2.0',
473
+ id: 1,
474
+ method: 'initialize',
475
+ params: { cwd: home, provider: 'deepseek-official', model: 'deepseek-v4-flash' },
476
+ })}\n`)
477
+ const initialized = await response(1)
478
+ expect(initialized, `${JSON.stringify(initialized)}\n${stderr}`).toMatchObject({
479
+ jsonrpc: '2.0',
480
+ id: 1,
481
+ result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
482
+ })
483
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'shutdown' })}\n`)
484
+ expect(await response(2)).toEqual({ jsonrpc: '2.0', id: 2, result: {} })
485
+ const result = await child
486
+ expect(result.timedOut, stderr).toBe(false)
487
+ expect(result.signal, stderr).toBeUndefined()
488
+ expect(result.exitCode, stderr).toBe(0)
489
+ expect(stderr).toBe('')
490
+ expect(readFileSync(marker, 'utf8')).toBe('loaded')
491
+ } finally {
492
+ child.kill('SIGKILL')
493
+ await child
494
+ rmSync(home, { recursive: true, force: true })
495
+ }
496
+ }, SPAWN_TIMEOUT_MS + 30_000)
497
+
498
+ it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => {
499
+ const apiKey = 'built-acp-profile-key'
500
+ const server = await startMockLlmServer({
501
+ sequence: ['success'],
502
+ apiKey,
503
+ successText: 'ACP BUILT PROFILE OK',
504
+ })
505
+ const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-'))
506
+ writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
507
+ const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
508
+ cwd: home,
509
+ reject: false,
510
+ timeout: SPAWN_TIMEOUT_MS,
511
+ killSignal: 'SIGKILL',
512
+ env: {
513
+ ...process.env,
514
+ DSH_HOME: home,
515
+ DSH_TELEMETRY_DISABLED: '1',
516
+ DEEPSEEK_API_KEY: apiKey,
517
+ DEEPSEEK_BASE_URL: server.baseURL,
518
+ DSH_PERMISSION_MODE: 'danger-full-access',
519
+ },
520
+ extendEnv: false,
521
+ })
522
+ const rawOut: string[] = []
523
+ const passthrough = new Readable({ read() {} })
524
+ child.stdout.on('data', (chunk: Buffer) => {
525
+ rawOut.push(chunk.toString('utf8'))
526
+ passthrough.push(chunk)
527
+ })
528
+ child.stdout.on('end', () => { passthrough.push(null) })
529
+ const stream = ndJsonStream(
530
+ Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
531
+ Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
532
+ )
533
+ const updates: SessionNotification['update'][] = []
534
+ const clientApp = createAcpClientApp({ name: 'dsh-built-acp-profile' })
535
+ .onNotification(methods.client.session.update, ({ params }) => {
536
+ updates.push(params.update)
537
+ return Promise.resolve()
538
+ })
539
+ .onRequest(methods.client.session.requestPermission, () => {
540
+ return Promise.resolve({ outcome: { outcome: 'cancelled' } })
541
+ })
542
+ const client = clientApp.connect(stream).agent
543
+ try {
544
+ const initialized = await client.request(methods.agent.initialize, {
545
+ protocolVersion: PROTOCOL_VERSION,
546
+ clientCapabilities: {},
547
+ })
548
+ expect(initialized.agentInfo).toMatchObject({ name: 'deepseek-harness-acp' })
549
+ expect(initialized.agentCapabilities).toEqual({
550
+ mcpCapabilities: { http: true },
551
+ promptCapabilities: { image: false, audio: false, embeddedContext: false },
552
+ sessionCapabilities: { close: {}, list: {}, resume: {} },
553
+ })
554
+ expect('_meta' in initialized).toBe(false)
555
+ const session = await client.request(methods.agent.session.new, { cwd: home, mcpServers: [] })
556
+ expect(session.sessionId).toBeTruthy()
557
+ expect(await client.request(methods.agent.session.prompt, {
558
+ sessionId: session.sessionId,
559
+ prompt: [{ type: 'text', text: 'reply from the built ACP profile' }],
560
+ })).toEqual({ stopReason: 'end_turn' })
561
+ expect(updates).toContainEqual(expect.objectContaining({
562
+ sessionUpdate: 'agent_message_chunk',
563
+ content: { type: 'text', text: 'ACP BUILT PROFILE OK' },
564
+ }))
565
+ const message = updates.find(update => update.sessionUpdate === 'agent_message_chunk')
566
+ expect(message !== undefined && 'messageId' in message && typeof message.messageId === 'string').toBe(true)
567
+ expect(server.requests).toHaveLength(1)
568
+ child.stdin.end()
569
+ const result = await child
570
+ expect(result.exitCode, `signal=${String(result.signal)}; stderr=${result.stderr}`).toBe(0)
571
+ expect(result.stderr).toBe('')
572
+ for (const line of rawOut.join('').split('\n').filter(value => value.trim() !== '')) {
573
+ expect(() => JSON.parse(line) as unknown).not.toThrow()
574
+ }
575
+ } finally {
576
+ child.kill('SIGKILL')
577
+ await child
578
+ await server.close()
579
+ rmSync(home, { recursive: true, force: true })
580
+ }
581
+ }, SPAWN_TIMEOUT_MS + 30_000)
582
+
583
+ it('runs the headless profile through its app-owned task positional', async () => {
584
+ const apiKey = 'built-dsh-headless-key'
585
+ const server = await startMockLlmServer({
586
+ sequence: ['reasoning_success'],
587
+ apiKey,
588
+ reasoningText: 'Inspecting the published entry.',
589
+ successText: 'published headless profile reached the mock',
590
+ })
591
+ const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
592
+ writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
593
+ try {
594
+ const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
595
+ DSH_HOME: home,
596
+ DSH_TELEMETRY_DISABLED: '1',
597
+ DEEPSEEK_API_KEY: apiKey,
598
+ DEEPSEEK_BASE_URL: server.baseURL,
599
+ })
600
+ expect(result.code, result.stderr).toBe(0)
601
+ expect(result.stdout).toBe('published headless profile reached the mock')
602
+ expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.')
603
+ expect(server.requests.length).toBeGreaterThan(0)
604
+ expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
605
+ expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
606
+ } finally {
607
+ await server.close()
608
+ rmSync(home, { recursive: true, force: true })
609
+ }
610
+ }, SPAWN_TIMEOUT_MS + 30_000)
611
+
612
+ it('does not load a project environment for --version', async () => {
613
+ const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
614
+ writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
615
+ try {
616
+ const result = await runBuiltBin(['--version'], {}, project)
617
+ expect(result).toEqual({ code: 0, stdout: cliVersion, stderr: '' })
618
+ } finally {
619
+ rmSync(project, { recursive: true, force: true })
620
+ }
621
+ })
622
+
623
+ it.skipIf(process.platform === 'win32')('runs through an installed-style symlink', async () => {
624
+ const installation = mkdtempSync(join(tmpdir(), 'dsh-bin-link-'))
625
+ const installedBin = join(installation, 'dsh')
626
+ symlinkSync(dshBin, installedBin)
627
+ try {
628
+ const result = await execa(process.execPath, [installedBin, '--version'], {
629
+ input: '',
630
+ timeout: SPAWN_TIMEOUT_MS,
631
+ killSignal: 'SIGKILL',
632
+ reject: false,
633
+ })
634
+ expect(result.exitCode).toBe(0)
635
+ expect(result.stdout).toBe(cliVersion)
636
+ expect(result.stderr).toBe('')
637
+ } finally {
638
+ rmSync(installation, { recursive: true, force: true })
639
+ }
640
+ })
641
+
642
+ it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
643
+ const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
644
+ try {
645
+ const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
646
+ expect(result.code).toBe(1)
647
+ expect(result.stderr).toContain('profile "nope" does not exist')
648
+ expect(result.stderr).toContain('dsh plugin --profile nope add')
649
+ } finally {
650
+ rmSync(home, { recursive: true, force: true })
651
+ }
652
+ }, SPAWN_TIMEOUT_MS + 30_000)
653
+
654
+ it('creates a custom profile from the shipped web template before booting it', async () => {
655
+ const home = mkdtempSync(join(tmpdir(), 'dsh-from-default-profile-'))
656
+ try {
657
+ const created = await runBuiltBin(
658
+ ['--profile', 'rescue', '--from-default-profile', 'web', '--help'],
659
+ { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
660
+ )
661
+ expect(created.code).toBe(0)
662
+ expect(created.stderr).toBe('')
663
+ expect(created.stdout).toContain('Usage: dsh --profile web')
664
+
665
+ const dir = join(home, 'profiles', 'rescue')
666
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
667
+ dependencies: Record<string, string>
668
+ dsh: { profile: { bundles: string[]; patchReload: string } }
669
+ }
670
+ expect(manifest.dependencies).toEqual({})
671
+ expect(manifest.dsh.profile).toEqual({
672
+ bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
673
+ patchReload: 'live',
674
+ })
675
+ expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('[]')
676
+ expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
677
+
678
+ const repeated = await runBuiltBin(
679
+ ['--profile', 'rescue', '--from-default-profile', 'web', '--help'],
680
+ { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
681
+ )
682
+ expect(repeated.code).toBe(1)
683
+ expect(repeated.stdout).toBe('')
684
+ expect(repeated.stderr).toContain('profile "rescue" already exists')
685
+ expect(repeated.stderr).toContain('omit --from-default-profile to use it')
686
+
687
+ const reopened = await runBuiltBin(
688
+ ['--profile', 'rescue', '--help'],
689
+ { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
690
+ )
691
+ expect(reopened.code).toBe(0)
692
+ expect(reopened.stderr).toBe('')
693
+ expect(reopened.stdout).toContain('Usage: dsh --profile web')
694
+ } finally {
695
+ rmSync(home, { recursive: true, force: true })
696
+ }
697
+ }, SPAWN_TIMEOUT_MS * 3 + 30_000)
698
+
699
+ it('keeps a newly created profile when application boot rejects its arguments', async () => {
700
+ const home = mkdtempSync(join(tmpdir(), 'dsh-from-default-profile-failed-boot-'))
701
+ try {
702
+ const failed = await runBuiltBin(
703
+ ['--profile', 'rescue', '--from-default-profile', 'web', '--port', 'not-a-number'],
704
+ { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
705
+ )
706
+ expect(failed.code).toBe(1)
707
+ expect(failed.stderr).toContain('--port must be a number')
708
+ expect(existsSync(join(home, 'profiles', 'rescue', 'package.json'))).toBe(true)
709
+
710
+ const retried = await runBuiltBin(
711
+ ['--profile', 'rescue', '--help'],
712
+ { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
713
+ )
714
+ expect(retried.code).toBe(0)
715
+ expect(retried.stderr).toBe('')
716
+ expect(retried.stdout).toContain('Usage: dsh --profile web')
717
+ } finally {
718
+ rmSync(home, { recursive: true, force: true })
719
+ }
720
+ }, SPAWN_TIMEOUT_MS * 2 + 30_000)
721
+
722
+ it('uses the launching endpoint and managed credential through the published entry', async () => {
723
+ const apiKey = 'built-home-layer-key'
724
+ const server = await startMockLlmServer({
725
+ sequence: ['success'],
726
+ apiKey,
727
+ successText: 'launching endpoint reached the mock',
728
+ })
729
+ const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
730
+ writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n')
731
+ const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
732
+ writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
733
+ createEnvironmentProbeProfile(home, project)
734
+ try {
735
+ const result = await runBuiltBin(
736
+ ['--profile', 'environment-probe'],
737
+ {
738
+ DSH_HOME: home,
739
+ DSH_TELEMETRY_DISABLED: '1',
740
+ DEEPSEEK_API_KEY: undefined,
741
+ DEEPSEEK_BASE_URL: server.baseURL,
742
+ },
743
+ project,
744
+ )
745
+ expect(
746
+ result.code,
747
+ `${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
748
+ ).toBe(0)
749
+ expect(result.stdout).toBe('launching endpoint reached the mock')
750
+ expect(result.stdout).not.toContain(apiKey)
751
+ expect(result.stderr).not.toContain(apiKey)
752
+ expect(server.requests).toHaveLength(1)
753
+ expect(server.requests[0]?.path).toBe('/chat/completions')
754
+ expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
755
+ expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
756
+ } finally {
757
+ await server.close()
758
+ rmSync(home, { recursive: true, force: true })
759
+ rmSync(project, { recursive: true, force: true })
760
+ }
761
+ }, SPAWN_TIMEOUT_MS + 30_000)
762
+
763
+ it('keeps serving when an optional patch-overlay plugin fails', async () => {
764
+ const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
765
+ try {
766
+ const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider, '--port', '0', '--no-open'], {
767
+ DSH_HOME: home,
768
+ DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1',
769
+ DEEPSEEK_API_KEY: 'keyless-invalid-config',
770
+ DSH_TELEMETRY_DISABLED: '1',
771
+ NODE_OPTIONS: `--import=${webReadyExitHook}`,
772
+ })
773
+ expect(result.code, result.stderr).toBe(0)
774
+ expect(result.stdout).toMatch(/^dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u)
775
+ expect(result.stderr).toContain('llm-pi-ai')
776
+ } finally {
777
+ rmSync(home, { recursive: true, force: true })
778
+ }
779
+ }, SPAWN_TIMEOUT_MS + 30_000)
780
+
781
+ it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
782
+ const fixture = createProfileLifecycleFixture()
783
+ const child = startProfileLifecycle(fixture, ['--unclaimed'])
784
+ try {
785
+ await waitForFile(fixture.ready)
786
+ requestProfileShutdown(child, fixture)
787
+ const result = await child
788
+ expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
789
+ expect(result.signal).toBeUndefined()
790
+ expect(existsSync(fixture.disposed)).toBe(true)
791
+ } finally {
792
+ child.kill('SIGKILL')
793
+ rmSync(fixture.home, { recursive: true, force: true })
794
+ }
795
+ }, SPAWN_TIMEOUT_MS + 30_000)
796
+
797
+ it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
798
+ const fixture = createProfileLifecycleFixture()
799
+ const child = startProfileLifecycle(fixture)
800
+ const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
801
+ const configFile = join(fixture.home, 'config-echo')
802
+ try {
803
+ await waitForFile(fixture.settled)
804
+ // The live profile layer: even without an hmr row in the composition,
805
+ // the launcher mounts a config-only watcher, so an edited
806
+ // cordis.patch.yml lands in the running tree (the reload disposes the
807
+ // patched row's old fiber — observable as the disposed marker — and
808
+ // mounts the new config, which echoes its generation and re-writes the
809
+ // ready marker).
810
+ rmSync(fixture.ready)
811
+ writeFileSync(profilePatch, [
812
+ '- id: profile-lifecycle-fixture',
813
+ ' config:',
814
+ ' generation: 2',
815
+ '',
816
+ ].join('\n'))
817
+ await waitForFile(fixture.ready)
818
+ expect(readFileSync(configFile, 'utf8')).toBe('2')
819
+ // Unlink exercises layer removal without racing Chokidar's change-event
820
+ // suppression window after the preceding edit. The bundle default must return.
821
+ rmSync(fixture.ready)
822
+ rmSync(profilePatch)
823
+ await waitForFile(fixture.ready)
824
+ expect(existsSync(profilePatch)).toBe(false)
825
+ expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
826
+ // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
827
+ // and outranks the per-profile layer.
828
+ rmSync(fixture.ready)
829
+ writeFileSync(join(fixture.home, 'cordis.patch.yml'), [
830
+ '- id: profile-lifecycle-fixture',
831
+ ' config:',
832
+ ' generation: home',
833
+ '',
834
+ ].join('\n'))
835
+ await waitForFile(fixture.ready)
836
+ expect(readFileSync(configFile, 'utf8')).toBe('home')
837
+ requestProfileShutdown(child, fixture)
838
+ const result = await child
839
+ expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
840
+ expect(result.signal).toBeUndefined()
841
+ expect(existsSync(fixture.disposed)).toBe(true)
842
+ } finally {
843
+ child.kill('SIGKILL')
844
+ await child
845
+ rmSync(fixture.home, { recursive: true, force: true })
846
+ }
847
+ }, SPAWN_TIMEOUT_MS + 30_000)
848
+
849
+ it('hands the app arguments to the profile, which applies them before its rows start', async () => {
850
+ const fixture = createStartupFixture()
851
+ const child = startStartupProfile(fixture, ['--generation', 'flagged'])
852
+ try {
853
+ await waitForFile(fixture.ready)
854
+ // The consumer started once, already carrying the flag value: the
855
+ // launcher never saw --generation, and the app provider resolved it first.
856
+ expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
857
+ requestProfileShutdown(child, fixture)
858
+ expect((await child).exitCode).toBe(0)
859
+ } finally {
860
+ child.kill('SIGKILL')
861
+ rmSync(fixture.home, { recursive: true, force: true })
862
+ }
863
+ }, SPAWN_TIMEOUT_MS + 30_000)
864
+
865
+ it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
866
+ const fixture = createStartupFixture()
867
+ const child = startStartupProfile(fixture, [])
868
+ try {
869
+ await waitForFile(fixture.ready)
870
+ expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
871
+ expect(existsSync(join(fixture.home, 'profiles', 'node_modules'))).toBe(true)
872
+ requestProfileShutdown(child, fixture)
873
+ expect((await child).exitCode).toBe(0)
874
+ } finally {
875
+ child.kill('SIGKILL')
876
+ rmSync(fixture.home, { recursive: true, force: true })
877
+ }
878
+ }, SPAWN_TIMEOUT_MS + 30_000)
879
+
880
+ it('keeps the app arguments across a user patch reload', async () => {
881
+ // A live edit recomposes every row while the provider service remains
882
+ // active, so each config expression reads the same invocation value (a
883
+ // served port does not move back to its composed fallback).
884
+ const fixture = createStartupFixture()
885
+ const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
886
+ const child = startStartupProfile(fixture, ['--generation', 'flagged'])
887
+ try {
888
+ // Both rows: the waiting one carries the flag value, and the witness is
889
+ // what a reload will re-mount. They start independently, so neither
890
+ // marker implies the other.
891
+ await waitForFile(fixture.ready)
892
+ await waitForFile(fixture.witness)
893
+ expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
894
+ // An edit to an unrelated row: the witness re-mounts, which is how this
895
+ // test knows the whole tree was recomposed.
896
+ rmSync(fixture.witness)
897
+ writeFileSync(profilePatch, [
898
+ '- id: reload-witness',
899
+ ' config:',
900
+ ' generation: reloaded',
901
+ '',
902
+ ].join('\n'))
903
+ await waitForFile(fixture.witness)
904
+ expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
905
+ expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
906
+ requestProfileShutdown(child, fixture)
907
+ expect((await child).exitCode).toBe(0)
908
+ } finally {
909
+ child.kill('SIGKILL')
910
+ rmSync(fixture.home, { recursive: true, force: true })
911
+ }
912
+ }, SPAWN_TIMEOUT_MS + 30_000)
913
+
914
+ it("prints the app's own help, starts none of its rows, and exits", async () => {
915
+ const fixture = createStartupFixture()
916
+ try {
917
+ const result = await startStartupProfile(fixture, ['--help'])
918
+ expect(result.exitCode).toBe(0)
919
+ expect(result.stdout).toContain('Usage: fixture')
920
+ expect(result.stdout).toContain('--generation')
921
+ expect(existsSync(fixture.ready)).toBe(false)
922
+ } finally {
923
+ rmSync(fixture.home, { recursive: true, force: true })
924
+ }
925
+ }, SPAWN_TIMEOUT_MS + 30_000)
926
+
927
+ it('anchors a relative add spec to the invoking directory, not the profile', async () => {
928
+ // `dsh plugin --profile x add .` from a plugin checkout must install THAT
929
+ // checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
930
+ // would self-link the profile.
931
+ const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-'))
932
+ const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-'))
933
+ try {
934
+ writeFileSync(join(checkout, 'package.json'), JSON.stringify({
935
+ name: 'anchored-bundle',
936
+ version: '1.0.0',
937
+ dsh: { bundle: { patch: './cordis.patch.yml' } },
938
+ }))
939
+ writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n')
940
+ const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
941
+ cwd: checkout,
942
+ input: '',
943
+ timeout: SPAWN_TIMEOUT_MS,
944
+ killSignal: 'SIGKILL',
945
+ reject: false,
946
+ env: { DSH_HOME: home },
947
+ })
948
+ expect(result.exitCode).toBe(0)
949
+ const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as {
950
+ dependencies: Record<string, string>
951
+ dsh: { profile: { bundles: string[] } }
952
+ }
953
+ expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle'])
954
+ expect(manifest.dsh.profile.bundles).toContain('anchored-bundle')
955
+
956
+ const removed = await runBuiltBin(
957
+ ['plugin', '--profile', 'anchor', 'remove', 'anchored-bundle'],
958
+ { DSH_HOME: home },
959
+ checkout,
960
+ )
961
+ expect(removed.code).toBe(0)
962
+ const afterRemove = JSON.parse(
963
+ readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8'),
964
+ ) as {
965
+ dependencies?: Record<string, string>
966
+ dsh: { profile: { bundles: string[] } }
967
+ }
968
+ expect(Object.keys(afterRemove.dependencies ?? {})).toEqual([])
969
+ expect(afterRemove.dsh.profile.bundles).not.toContain('anchored-bundle')
970
+ } finally {
971
+ rmSync(home, { recursive: true, force: true })
972
+ rmSync(checkout, { recursive: true, force: true })
973
+ }
974
+ }, SPAWN_TIMEOUT_MS * 2 + 30_000)
975
+
976
+ it('activates a dependency that gained dsh.bundle in a later update', async () => {
977
+ // Reconcile runs against the INSTALLED state on every successful pnpm
978
+ // run, so `update` (not only `add`) activates a package whose newer
979
+ // version declares dsh.bundle. Simulated without a registry: hand-place
980
+ // the installed package, flip its manifest, and run a benign pnpm verb.
981
+ const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
982
+ try {
983
+ const profileDir = join(home, 'profiles', 'up')
984
+ const installed = join(profileDir, 'node_modules', 'late-bundle')
985
+ mkdirSync(installed, { recursive: true })
986
+ writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
987
+ name: 'dsh-profile-up',
988
+ private: true,
989
+ dependencies: { 'late-bundle': 'file:./late-bundle' },
990
+ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
991
+ }))
992
+ writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
993
+ // v1: no dsh manifest — a plain dependency.
994
+ writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' }))
995
+ const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
996
+ expect(first.code).toBe(0)
997
+ let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
998
+ expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
999
+ // v2: the installed package now declares dsh.bundle (an update landed).
1000
+ writeFileSync(join(installed, 'package.json'), JSON.stringify({
1001
+ name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
1002
+ }))
1003
+ writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n')
1004
+ const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
1005
+ expect(second.code).toBe(0)
1006
+ manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
1007
+ expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
1008
+ } finally {
1009
+ rmSync(home, { recursive: true, force: true })
1010
+ }
1011
+ }, SPAWN_TIMEOUT_MS * 2 + 30_000)
1012
+
1013
+ describe('config dump', () => {
1014
+ let home: string
1015
+ beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
1016
+ afterEach(() => { rmSync(home, { recursive: true, force: true }) })
1017
+
1018
+ it('prints the web profile bundle layers without a user layer', async () => {
1019
+ const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
1020
+ expect(code).toBe(0)
1021
+ expect(stderr).toBe('')
1022
+ expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
1023
+ expect(stdout).toContain('agents: []')
1024
+ expect(stdout).toContain('# == @deepseek-ai/dsh-base')
1025
+ expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
1026
+ expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false)
1027
+ }, SPAWN_TIMEOUT_MS + 30_000)
1028
+
1029
+ it('creates a custom profile from a shipped template before printing it', async () => {
1030
+ const { stdout, code, stderr } = await runBuiltBin(
1031
+ ['--profile', 'rescue', '--from-default-profile', 'web', '--dump-default-config'],
1032
+ { DSH_HOME: home },
1033
+ )
1034
+ expect(code).toBe(0)
1035
+ expect(stderr).toBe('')
1036
+ expect(stdout).toContain('# == @deepseek-ai/dsh-web-app')
1037
+ expect(existsSync(join(home, 'profiles', 'rescue', 'package.json'))).toBe(true)
1038
+ }, SPAWN_TIMEOUT_MS + 30_000)
1039
+
1040
+ it('rejects an unknown source before creating the target profile', async () => {
1041
+ const { stdout, code, stderr } = await runBuiltBin(
1042
+ ['--profile', 'rescue', '--from-default-profile', 'unknown', '--dump-default-config'],
1043
+ { DSH_HOME: home },
1044
+ )
1045
+ expect(code).toBe(1)
1046
+ expect(stdout).toBe('')
1047
+ expect(stderr).toContain('unknown default profile "unknown"')
1048
+ expect(stderr).toContain('"web"')
1049
+ expect(existsSync(join(home, 'profiles', 'rescue'))).toBe(false)
1050
+ }, SPAWN_TIMEOUT_MS + 30_000)
1051
+
1052
+ it('prints the headless profile without Host or browser layers', async () => {
1053
+ const { stdout, code, stderr } = await runBuiltBin(
1054
+ ['--profile', 'headless', '--dump-default-config'],
1055
+ { DSH_HOME: home },
1056
+ )
1057
+ expect(code).toBe(0)
1058
+ expect(stderr).toBe('')
1059
+ expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
1060
+ expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
1061
+ expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
1062
+ expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
1063
+ }, SPAWN_TIMEOUT_MS + 30_000)
1064
+
1065
+ it('prints the exact standalone sdk-minimal tree without dsh-base', async () => {
1066
+ const { stdout, code, stderr } = await runBuiltBin(
1067
+ ['--profile', 'sdk-minimal', '--dump-default-config'],
1068
+ { DSH_HOME: home },
1069
+ )
1070
+ expect(code).toBe(0)
1071
+ expect(stderr).toBe('')
1072
+ const rows = yaml.load(stdout, { schema: entryListSchema }) as Array<{ id?: string; name?: string }>
1073
+ expect(rows.map(row => [row.id, row.name])).toEqual([
1074
+ ['sdk-app-startup', '@deepseek-ai/dsh-sdk-app'],
1075
+ ['sdk-jsonrpc-server', '@deepseek-ai/dsh-sdk-jsonrpc-server'],
1076
+ ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'],
1077
+ ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'],
1078
+ ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'],
1079
+ ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'],
1080
+ ['sandbox', '@deepseek-ai/dsh-sandbox-local'],
1081
+ ['session-projection', '@deepseek-ai/dsh-session-projection'],
1082
+ ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'],
1083
+ ['subprocess', '@deepseek-ai/dsh-subprocess-local'],
1084
+ ['pty', '@deepseek-ai/dsh-terminal'],
1085
+ ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'],
1086
+ ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'],
1087
+ ['timer', '@deepseek-ai/cordis-plugin-timer'],
1088
+ ['llm', '@deepseek-ai/dsh-llm'],
1089
+ ['session', '@deepseek-ai/dsh-session'],
1090
+ ['session-title', '@deepseek-ai/dsh-session-title'],
1091
+ ['system-prompt', '@deepseek-ai/dsh-system-prompt'],
1092
+ ['tools', '@deepseek-ai/dsh-tools'],
1093
+ ['mcp-resources', '@deepseek-ai/dsh-mcp-resources'],
1094
+ ['agent', '@deepseek-ai/dsh-agent'],
1095
+ ['llm-retry', '@deepseek-ai/dsh-llm-retry'],
1096
+ ['jobs', '@deepseek-ai/dsh-jobs-local'],
1097
+ ['invariants', '@deepseek-ai/dsh-invariants'],
1098
+ ['session-invariant', '@deepseek-ai/dsh-session/invariant'],
1099
+ ['agent-invariant', '@deepseek-ai/dsh-agent/invariant'],
1100
+ ['scope-invariant', '@deepseek-ai/dsh-scope/invariant'],
1101
+ ['agent-loop-invariant', '@deepseek-ai/dsh-agent-loop/invariant'],
1102
+ ['agent-loop', '@deepseek-ai/dsh-agent-loop'],
1103
+ ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'],
1104
+ ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'],
1105
+ ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'],
1106
+ ])
1107
+ expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal')
1108
+ expect(stdout).not.toContain('@deepseek-ai/dsh-base')
1109
+ expect(stdout).not.toContain('@deepseek-ai/dsh-web-app')
1110
+ }, SPAWN_TIMEOUT_MS * 2 + 30_000)
1111
+
1112
+ it('composes the profile user layer and a --patch overlay in order', async () => {
1113
+ // Auto-init the web profile first, then write its user layer.
1114
+ const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
1115
+ expect(init.code).toBe(0)
1116
+ const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
1117
+ writeFileSync(profilePatch, [
1118
+ '- id: agent-loop',
1119
+ ' config:',
1120
+ ' agents:',
1121
+ ' - id: personal',
1122
+ ' provider: personal-provider',
1123
+ ' model: personal-model',
1124
+ '- id: absent-row',
1125
+ ' config:',
1126
+ ' x: 1',
1127
+ '',
1128
+ ].join('\n'))
1129
+ const overlay = join(home, 'overlay.cordis.yml')
1130
+ writeFileSync(overlay, [
1131
+ '- id: agent-loop',
1132
+ ' config:',
1133
+ ' agents:',
1134
+ ' - id: configured',
1135
+ ' provider: configured-provider',
1136
+ ' model: configured-model',
1137
+ '',
1138
+ ].join('\n'))
1139
+ const { stdout, code, stderr } = await runBuiltBin(
1140
+ ['--profile', 'web', '--patch', overlay, '--dump-config'],
1141
+ { DSH_HOME: home },
1142
+ )
1143
+ expect(code).toBe(0)
1144
+ expect(stdout).toContain('provider: configured-provider')
1145
+ expect(stdout).not.toContain('personal-provider')
1146
+ // Both layers patched the row; the comment lists them in application order.
1147
+ expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
1148
+ expect(stderr).toContain('patch: entry "absent-row" not found')
1149
+ }, SPAWN_TIMEOUT_MS * 2 + 30_000)
1150
+ })
1151
+ })
apps/cli/tests/dsh-badge.expected.e2e.ts ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { fileURLToPath } from 'node:url'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
4
+ const binScript = fileURLToPath(new URL('./fixtures/dsh-badge/snapshot.ts', import.meta.url))
5
+ const configPath = fileURLToPath(new URL('./fixtures/dsh-badge/cordis.yml', import.meta.url))
6
+ const defaultConfigPath = fileURLToPath(new URL('./fixtures/dsh-badge/default.cordis.yml', import.meta.url))
7
+ const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
8
+ const badgeAssetsPath = fileURLToPath(new URL('../../../packages/skill/skill-badge/assets/', import.meta.url))
9
+
10
+ describe('dsh badge assembled snapshot', () => {
11
+ it('advertises and loads the opt-in bundled skill through the shipped app', async () => {
12
+ const disabled = await runLoaderSmoke({
13
+ label: 'disabled dsh badge skill snapshot',
14
+ tempDirPrefix: 'headless-snapshot-dsh-badge-disabled-',
15
+ binScript,
16
+ libBinScript: binScript,
17
+ configPath: defaultConfigPath,
18
+ tsconfigPath,
19
+ })
20
+ const enabled = await runLoaderSmoke({
21
+ label: 'dsh badge skill snapshot',
22
+ tempDirPrefix: 'headless-snapshot-dsh-badge-',
23
+ binScript,
24
+ libBinScript: binScript,
25
+ configPath,
26
+ tsconfigPath,
27
+ })
28
+ const disabledSnapshot = JSON.parse(disabled.stdout) as unknown
29
+ const enabledSnapshot = JSON.parse(
30
+ enabled.stdout.replaceAll(badgeAssetsPath, '{{badgeAssetsPath}}'),
31
+ ) as unknown
32
+
33
+ expect(disabled.stderr).toBe('')
34
+ expect(enabled.stderr).toBe('')
35
+ expect(disabledSnapshot).toMatchInlineSnapshot(`
36
+ {
37
+ "catalog": null,
38
+ "result": {
39
+ "content": [
40
+ {
41
+ "text": "Error: skill "dsh-badge" is unknown or no longer available",
42
+ "type": "text",
43
+ },
44
+ ],
45
+ "error": {
46
+ "message": "skill "dsh-badge" is unknown or no longer available",
47
+ },
48
+ "isError": true,
49
+ },
50
+ "summary": null,
51
+ }
52
+ `)
53
+ expect(enabledSnapshot).toMatchInlineSnapshot(`
54
+ {
55
+ "catalog": [
56
+ {
57
+ "text": "<system-reminder>
58
+ A skill is a reusable set of task-specific instructions. The following skills are available in this session:
59
+
60
+ <available_skills>
61
+ - \`dsh-badge\`: Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.
62
+ </available_skills>
63
+
64
+ If the user names a skill, or the task clearly matches a skill's description, call the \`skill\` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.
65
+ A user may also invoke a skill directly; its <skill_content> block then appears in this conversation. Follow it, and do not call the \`skill\` tool again for that skill.
66
+ </system-reminder>",
67
+ "type": "text",
68
+ },
69
+ ],
70
+ "result": {
71
+ "content": [
72
+ {
73
+ "text": "<skill_content name="dsh-badge">
74
+ <skill_resources>
75
+ Base directory for this skill: {{badgeAssetsPath}}
76
+ Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.
77
+ </skill_resources>
78
+
79
+ <skill_instructions>
80
+ # dsh Badge
81
+
82
+ Add the official “powered by dsh” badge without recreating or restyling it.
83
+
84
+ ## Assets
85
+
86
+ - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
87
+ - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
88
+ - Project URL: \`https://github.com/deepseek-ai/deepseek-harness\`
89
+
90
+ ## Markdown
91
+
92
+ Use this linked badge in Markdown:
93
+
94
+ \`\`\`markdown
95
+ [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness)
96
+ \`\`\`
97
+
98
+ If attribution should not be linked, use:
99
+
100
+ \`\`\`markdown
101
+ ![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)
102
+ \`\`\`
103
+
104
+ ## Usage rules
105
+
106
+ - For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image.
107
+ - For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge.
108
+ - Preserve the badge's 121×20 dimensions and aspect ratio.
109
+ - Place the badge at the end of the attributed document or section unless the user specifies another position.
110
+ - Do not substitute another color, logo, label, or project URL.
111
+
112
+ </skill_instructions>
113
+ </skill_content>",
114
+ "type": "text",
115
+ },
116
+ ],
117
+ "isError": false,
118
+ "value": {
119
+ "content": "# dsh Badge
120
+
121
+ Add the official “powered by dsh” badge without recreating or restyling it.
122
+
123
+ ## Assets
124
+
125
+ - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
126
+ - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
127
+ - Project URL: \`https://github.com/deepseek-ai/deepseek-harness\`
128
+
129
+ ## Markdown
130
+
131
+ Use this linked badge in Markdown:
132
+
133
+ \`\`\`markdown
134
+ [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness)
135
+ \`\`\`
136
+
137
+ If attribution should not be linked, use:
138
+
139
+ \`\`\`markdown
140
+ ![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)
141
+ \`\`\`
142
+
143
+ ## Usage rules
144
+
145
+ - For GitHub or GitLab Markdown, use the Shields.io URL and link it to the project URL unless the user asks for an unlinked image.
146
+ - For Feishu and other systems that import remote images unreliably, upload \`dsh-badge.png\` from this skill directory instead of generating another badge.
147
+ - Preserve the badge's 121×20 dimensions and aspect ratio.
148
+ - Place the badge at the end of the attributed document or section unless the user specifies another position.
149
+ - Do not substitute another color, logo, label, or project URL.
150
+ ",
151
+ "name": "dsh-badge",
152
+ "provider": "dsh-badge",
153
+ "resourceBase": {
154
+ "kind": "directory",
155
+ "path": "{{badgeAssetsPath}}",
156
+ },
157
+ },
158
+ },
159
+ "summary": {
160
+ "description": "Add the official “powered by dsh” badge to documents, pull requests, merge requests, and other content produced with DeepSeek Harness. Use whenever creating a pull request or merge request. Also use when the user asks for a dsh badge, powered-by-dsh attribution, or a reusable dsh badge asset or snippet.",
161
+ "invocation": {
162
+ "modelInvocable": true,
163
+ "userInvocable": true,
164
+ },
165
+ "name": "dsh-badge",
166
+ "provider": "dsh-badge",
167
+ "resourceBase": {
168
+ "kind": "directory",
169
+ "path": "{{badgeAssetsPath}}",
170
+ },
171
+ "source": "bundled",
172
+ },
173
+ }
174
+ `)
175
+ }, LOADER_SMOKE_TEST_TIMEOUT_MS * 2)
176
+ })
apps/cli/tests/github-webhook-real.e2e.ts ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Real CLI and DeepSeek evidence for a GitHub webhook-created Session. */
2
+
3
+ import type { ChildProcess } from 'node:child_process'
4
+ import { spawn } from 'node:child_process'
5
+ import { createHmac, randomUUID } from 'node:crypto'
6
+ import { existsSync } from 'node:fs'
7
+ import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'
8
+ import { createServer } from 'node:net'
9
+ import type { AddressInfo } from 'node:net'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { setTimeout as delay } from 'node:timers/promises'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { describe, expect, it } from 'vitest'
15
+ import WebSocket from 'ws'
16
+
17
+ const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
18
+ const BUILT_BIN = join(REPO_ROOT, 'apps/cli/lib/bin.js')
19
+ const OVERLAY = fileURLToPath(new URL(
20
+ './fixtures/github-webhook/cordis.yml',
21
+ import.meta.url,
22
+ ))
23
+ const SECRET = 'github-webhook-real-e2e-secret'
24
+ const DELIVERY = 'github-webhook-real-e2e-delivery'
25
+ const MARKER = 'DSH_GITHUB_WEBHOOK_REAL_E2E_OK'
26
+ const TITLE = 'GitHub webhook real e2e'
27
+ const authenticatedCookies = new Map<string, Promise<{ origin: string; cookie: string }>>()
28
+
29
+ /** Exchange the printed process token once for Node-side API probes. */
30
+ function authenticatedWeb(launchUrl: string): Promise<{ origin: string; cookie: string }> {
31
+ const existing = authenticatedCookies.get(launchUrl)
32
+ if (existing !== undefined) return existing
33
+ const exchange = (async () => {
34
+ const response = await fetch(launchUrl, { redirect: 'manual' })
35
+ const setCookie = response.headers.get('set-cookie')
36
+ if (response.status !== 303 || setCookie === null) {
37
+ throw new Error(`dsh web authentication returned HTTP ${String(response.status)}`)
38
+ }
39
+ return { origin: new URL(launchUrl).origin, cookie: setCookie.split(';', 1)[0]! }
40
+ })()
41
+ authenticatedCookies.set(launchUrl, exchange)
42
+ return exchange
43
+ }
44
+
45
+ interface SessionList {
46
+ items: Array<{
47
+ sessionId: string
48
+ cwd?: string
49
+ blank: boolean
50
+ projections?: { values: { agentPreset?: string | null } }
51
+ }>
52
+ }
53
+
54
+ interface WorkspaceBaseline {
55
+ items: Array<{
56
+ path: string
57
+ sessionIds: string[]
58
+ }>
59
+ }
60
+
61
+ interface HistoryPage {
62
+ records: Array<{ type: 'event'; event: HistoryEvent }>
63
+ hasMore: boolean
64
+ }
65
+
66
+ interface HistoryEvent {
67
+ type: string
68
+ data: unknown
69
+ }
70
+
71
+ interface ProcessObservation {
72
+ readonly ready: Promise<string>
73
+ readonly text: () => string
74
+ }
75
+
76
+ function isRecord(value: unknown): value is Record<string, unknown> {
77
+ return typeof value === 'object' && value !== null
78
+ }
79
+
80
+ /** Capture bounded process output and resolve the public Web URL after settled boot. */
81
+ function observeProcess(child: ChildProcess): ProcessObservation {
82
+ let output = ''
83
+ let settled = false
84
+ let resolveReady!: (url: string) => void
85
+ let rejectReady!: (error: Error) => void
86
+ const ready = new Promise<string>((resolve, reject) => {
87
+ resolveReady = resolve
88
+ rejectReady = reject
89
+ })
90
+ const timer = setTimeout(() => {
91
+ if (!settled) rejectReady(new Error(`dsh web did not become ready within 90s:\n${output}`))
92
+ }, 90_000)
93
+ timer.unref()
94
+ const append = (chunk: Buffer | string): void => {
95
+ output = `${output}${String(chunk)}`.slice(-100_000)
96
+ const match = /dsh web: (http:\/\/[^\s]+)/u.exec(output)
97
+ if (settled || match?.[1] === undefined) return
98
+ settled = true
99
+ clearTimeout(timer)
100
+ resolveReady(match[1].replace('0.0.0.0', '127.0.0.1'))
101
+ }
102
+ child.stdout?.on('data', append)
103
+ child.stderr?.on('data', append)
104
+ child.once('error', (error) => {
105
+ if (!settled) rejectReady(error)
106
+ })
107
+ child.once('exit', (code) => {
108
+ if (!settled) rejectReady(new Error(`dsh web exited before readiness (code ${String(code)}):\n${output}`))
109
+ })
110
+ return { ready, text: () => output }
111
+ }
112
+
113
+ /** Reserve and release one loopback port for the isolated webhook listener. */
114
+ async function freePort(): Promise<number> {
115
+ const server = createServer()
116
+ await new Promise<void>((resolve, reject) => {
117
+ server.once('error', reject)
118
+ server.listen(0, '127.0.0.1', resolve)
119
+ })
120
+ const port = (server.address() as AddressInfo).port
121
+ await new Promise<void>((resolve, reject) => {
122
+ server.close((error) => {
123
+ if (error === undefined) resolve()
124
+ else reject(error)
125
+ })
126
+ })
127
+ return port
128
+ }
129
+
130
+ /** Invoke one public Remote method over its HTTP carrier. */
131
+ async function remoteRpc<T>(baseUrl: string, endpoint: string, args: object): Promise<T> {
132
+ const authenticated = await authenticatedWeb(baseUrl)
133
+ const response = await fetch(`${authenticated.origin}/api/${endpoint}`, {
134
+ method: 'POST',
135
+ headers: { 'content-type': 'application/json', cookie: authenticated.cookie },
136
+ body: JSON.stringify({
137
+ type: 'client-request',
138
+ rpcId: `github-webhook-real-${endpoint}-${randomUUID()}`,
139
+ method: endpoint,
140
+ payload: { args },
141
+ }),
142
+ })
143
+ if (!response.ok) {
144
+ throw new Error(`${endpoint} returned HTTP ${String(response.status)}: ${await response.text()}`)
145
+ }
146
+ const envelope = await response.json() as {
147
+ result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
148
+ }
149
+ if (!envelope.result.ok) {
150
+ throw new Error(`${endpoint} failed: ${envelope.result.error.code}: ${envelope.result.error.message}`)
151
+ }
152
+ return envelope.result.value
153
+ }
154
+
155
+ /** Read one opening item from a public Remote stream. */
156
+ async function openingStreamItem(
157
+ baseUrl: string,
158
+ endpoint: string,
159
+ args: object,
160
+ accepts: (value: unknown) => boolean,
161
+ ): Promise<Record<string, unknown>> {
162
+ const authenticated = await authenticatedWeb(baseUrl)
163
+ const socket = new WebSocket(`${authenticated.origin.replace(/^http/u, 'ws')}/api/remote.mux`, {
164
+ headers: { cookie: authenticated.cookie },
165
+ })
166
+ const streamId = `github-webhook-real-${endpoint}-${randomUUID()}`
167
+ try {
168
+ await new Promise<void>((resolve, reject) => {
169
+ const cleanup = (): void => {
170
+ socket.removeEventListener('open', opened)
171
+ socket.removeEventListener('error', failed)
172
+ socket.removeEventListener('close', closed)
173
+ }
174
+ const opened = (): void => {
175
+ cleanup()
176
+ resolve()
177
+ }
178
+ const failed = (): void => {
179
+ cleanup()
180
+ reject(new Error(`${endpoint} carrier failed before opening`))
181
+ }
182
+ const closed = (): void => {
183
+ cleanup()
184
+ reject(new Error(`${endpoint} carrier closed before opening`))
185
+ }
186
+ socket.addEventListener('open', opened)
187
+ socket.addEventListener('error', failed)
188
+ socket.addEventListener('close', closed)
189
+ })
190
+ return await new Promise<Record<string, unknown>>((resolve, reject) => {
191
+ const timer = setTimeout(() => { finish(new Error(`${endpoint} did not publish its opening item`)) }, 10_000)
192
+ const cleanup = (): void => {
193
+ clearTimeout(timer)
194
+ socket.removeEventListener('message', message)
195
+ socket.removeEventListener('error', failed)
196
+ socket.removeEventListener('close', closed)
197
+ }
198
+ const finish = (error: Error | undefined, value?: Record<string, unknown>): void => {
199
+ cleanup()
200
+ if (error !== undefined) reject(error)
201
+ else if (value === undefined) reject(new Error(`${endpoint} opening item was absent`))
202
+ else resolve(value)
203
+ }
204
+ const message = (event: WebSocket.MessageEvent): void => {
205
+ try {
206
+ const text = typeof event.data === 'string'
207
+ ? event.data
208
+ : Buffer.isBuffer(event.data) ? event.data.toString('utf8') : undefined
209
+ if (text === undefined) throw new Error(`${endpoint} published a non-text frame`)
210
+ const frame: unknown = JSON.parse(text)
211
+ if (!isRecord(frame) || frame.streamId !== streamId) return
212
+ if (frame.type === 'error') {
213
+ finish(new Error(`${endpoint} failed: ${JSON.stringify(frame.error)}`))
214
+ return
215
+ }
216
+ if (frame.type === 'end') {
217
+ finish(new Error(`${endpoint} ended before its opening item`))
218
+ return
219
+ }
220
+ if (frame.type === 'item' && isRecord(frame.value) && accepts(frame.value)) {
221
+ finish(undefined, frame.value)
222
+ }
223
+ } catch (error) {
224
+ finish(error instanceof Error ? error : new Error(String(error)))
225
+ }
226
+ }
227
+ const failed = (): void => { finish(new Error(`${endpoint} carrier failed before its opening item`)) }
228
+ const closed = (): void => { finish(new Error(`${endpoint} carrier closed before its opening item`)) }
229
+ socket.addEventListener('message', message)
230
+ socket.addEventListener('error', failed)
231
+ socket.addEventListener('close', closed)
232
+ socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } }))
233
+ })
234
+ } finally {
235
+ socket.close()
236
+ }
237
+ }
238
+
239
+ /** Read the current Workspace baseline from a fresh follow generation. */
240
+ async function workspaceBaseline(baseUrl: string): Promise<WorkspaceBaseline> {
241
+ const frame = await openingStreamItem(
242
+ baseUrl,
243
+ 'workspace/follow',
244
+ {},
245
+ value => isRecord(value) && value.type === 'baseline' && isRecord(value.value),
246
+ )
247
+ return frame.value as WorkspaceBaseline
248
+ }
249
+
250
+ /** Read the complete opening page from a fresh Session follow generation. */
251
+ async function history(baseUrl: string, sessionId: string): Promise<HistoryPage> {
252
+ const frame = await openingStreamItem(
253
+ baseUrl,
254
+ 'session/follow',
255
+ { request: { address: { kind: 'session', sessionId }, maxMessages: 100 } },
256
+ value => isRecord(value)
257
+ && value.type === 'snapshot'
258
+ && Array.isArray(value.records)
259
+ && typeof value.hasMore === 'boolean',
260
+ )
261
+ return { records: frame.records as HistoryPage['records'], hasMore: frame.hasMore as boolean }
262
+ }
263
+
264
+ /** Poll a public observation until it satisfies the test's behavior predicate. */
265
+ async function eventually<T>(
266
+ child: ChildProcess,
267
+ processOutput: () => string,
268
+ label: string,
269
+ probe: () => Promise<T>,
270
+ accepts: (value: T) => boolean,
271
+ timeoutMs: number,
272
+ ): Promise<T> {
273
+ const deadline = Date.now() + timeoutMs
274
+ let lastValue: T | undefined
275
+ let lastError: unknown
276
+ while (Date.now() < deadline) {
277
+ if (child.exitCode !== null) {
278
+ throw new Error(`dsh web exited while waiting for ${label} (code ${String(child.exitCode)}):\n${processOutput()}`)
279
+ }
280
+ try {
281
+ lastValue = await probe()
282
+ if (accepts(lastValue)) return lastValue
283
+ } catch (error) {
284
+ lastError = error
285
+ }
286
+ await delay(300)
287
+ }
288
+ throw new Error(
289
+ `timed out waiting for ${label}; last value=${JSON.stringify(lastValue)}; `
290
+ + `last error=${String(lastError)}; process output:\n${processOutput()}`,
291
+ )
292
+ }
293
+
294
+ /** Return every text block from durable assistant messages. */
295
+ function assistantText(page: HistoryPage): string {
296
+ const text: string[] = []
297
+ for (const event of historyEvents(page)) {
298
+ if (event.type !== 'assistant/message' || !isRecord(event.data) || !isRecord(event.data.message)) continue
299
+ const content = event.data.message.content
300
+ if (!Array.isArray(content)) continue
301
+ for (const block of content) {
302
+ if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') text.push(block.text)
303
+ }
304
+ }
305
+ return text.join('\n')
306
+ }
307
+
308
+ /** Read scalar v2 history records for assertions over the public event stream. */
309
+ function historyEvents(page: HistoryPage): HistoryEvent[] {
310
+ return page.records.map(record => record.event)
311
+ }
312
+
313
+ /** Stop the spawned CLI through its normal signal path, escalating only on a stuck teardown. */
314
+ async function stop(child: ChildProcess): Promise<void> {
315
+ if (child.exitCode !== null) return
316
+ let resolveClosed!: () => void
317
+ const closed = new Promise<void>((resolve) => { resolveClosed = resolve })
318
+ child.once('close', resolveClosed)
319
+ child.kill('SIGTERM')
320
+ if (await Promise.race([closed.then(() => true), delay(10_000, false, { ref: false })])) return
321
+ if (child.exitCode === null) child.kill('SIGKILL')
322
+ await Promise.race([closed, delay(5_000, undefined, { ref: false })])
323
+ }
324
+
325
+ /** Send the sole synthetic external interaction: one signed GitHub delivery. */
326
+ async function sendGitHubDelivery(origin: string): Promise<Response> {
327
+ const body = JSON.stringify({
328
+ action: 'ready_for_review',
329
+ number: 4242,
330
+ repository: { full_name: 'deepseek-ai/deepseek-harness' },
331
+ pull_request: {
332
+ title: 'Real CLI webhook e2e',
333
+ html_url: 'https://github.com/deepseek-ai/deepseek-harness/pull/4242',
334
+ draft: false,
335
+ user: { login: 'octocat' },
336
+ base: { ref: 'master', sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
337
+ head: { ref: 'webhook-e2e', sha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' },
338
+ },
339
+ })
340
+ const signature = `sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}`
341
+ return await fetch(`${origin}/github`, {
342
+ method: 'POST',
343
+ headers: {
344
+ 'content-type': 'application/json',
345
+ 'x-github-delivery': DELIVERY,
346
+ 'x-github-event': 'pull_request',
347
+ 'x-hub-signature-256': signature,
348
+ },
349
+ body,
350
+ })
351
+ }
352
+
353
+ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real dsh CLI and model', () => {
354
+ it('creates, attaches, prompts, and completes a Workspace Session', async () => {
355
+ expect(existsSync(BUILT_BIN), `missing built CLI ${BUILT_BIN}; run pnpm run build:official`).toBe(true)
356
+ const root = await mkdtemp(join(tmpdir(), 'dsh-github-webhook-real-'))
357
+ const workspacePath = join(root, 'workspace')
358
+ await mkdir(workspacePath)
359
+ const canonicalWorkspacePath = await realpath(workspacePath)
360
+ const webhookPort = await freePort()
361
+ const child = spawn(process.execPath, [
362
+ BUILT_BIN,
363
+ 'web',
364
+ '--patch', OVERLAY,
365
+ '--no-open',
366
+ '--host', '127.0.0.1',
367
+ '--port', '0',
368
+ ], {
369
+ cwd: root,
370
+ env: {
371
+ ...process.env,
372
+ DSH_AGENTS_HOME: join(root, '.agents'),
373
+ DSH_GITHUB_E2E_MARKER: MARKER,
374
+ DSH_GITHUB_E2E_WORKSPACE: workspacePath,
375
+ DSH_GITHUB_WEBHOOK_PORT: String(webhookPort),
376
+ DSH_GITHUB_WEBHOOK_SECRET: SECRET,
377
+ DSH_HOME: join(root, '.dsh'),
378
+ DSH_TELEMETRY_DISABLED: '1',
379
+ },
380
+ stdio: ['ignore', 'pipe', 'pipe'],
381
+ })
382
+ const observation = observeProcess(child)
383
+
384
+ try {
385
+ const baseUrl = await observation.ready
386
+ const webhookOrigin = `http://127.0.0.1:${String(webhookPort)}`
387
+
388
+ expect((await fetch(`${webhookOrigin}/api`)).status).toBe(404)
389
+ expect((await sendGitHubDelivery(new URL(baseUrl).origin)).status).not.toBe(202)
390
+ expect((await sendGitHubDelivery(webhookOrigin)).status).toBe(202)
391
+
392
+ const workspaces = await eventually(
393
+ child,
394
+ observation.text,
395
+ 'one Workspace-attached Session',
396
+ async () => await workspaceBaseline(baseUrl),
397
+ value => value.items.some(workspace =>
398
+ workspace.path === canonicalWorkspacePath && workspace.sessionIds.length === 1),
399
+ 30_000,
400
+ )
401
+ const workspace = workspaces.items.find(item => item.path === canonicalWorkspacePath)
402
+ const sessionId = workspace?.sessionIds[0]
403
+ if (sessionId === undefined) throw new Error('workspace/follow did not expose the webhook Session')
404
+
405
+ const sessions = await remoteRpc<SessionList>(baseUrl, 'session/list', { _request: {} })
406
+ expect(sessions.items.find(session => session.sessionId === sessionId)).toMatchObject({
407
+ blank: false,
408
+ cwd: canonicalWorkspacePath,
409
+ projections: { values: { agentPreset: 'minimal' } },
410
+ })
411
+
412
+ const admitted = await eventually(
413
+ child,
414
+ observation.text,
415
+ 'webhook source, title, and permission events',
416
+ async () => await history(baseUrl, sessionId),
417
+ (page) => {
418
+ const events = historyEvents(page)
419
+ const title = events.find(event => event.type === 'session/title')
420
+ const permission = events.find(event =>
421
+ event.type === 'permission/preset'
422
+ && isRecord(event.data)
423
+ && event.data.preset === 'read-only')
424
+ const message = events.find(event =>
425
+ event.type === 'user/message'
426
+ && isRecord(event.data)
427
+ && isRecord(event.data.source)
428
+ && event.data.source.kind === 'webhook')
429
+ return isRecord(title?.data) && title.data.title === TITLE
430
+ && permission !== undefined
431
+ && isRecord(message?.data) && isRecord(message.data.source)
432
+ && message.data.source.provider === 'github'
433
+ && message.data.source.deliveryId === DELIVERY
434
+ },
435
+ 30_000,
436
+ )
437
+ const webhookMessage = historyEvents(admitted)
438
+ .find(event => event.type === 'user/message'
439
+ && isRecord(event.data)
440
+ && isRecord(event.data.source)
441
+ && event.data.source.kind === 'webhook')
442
+ expect(webhookMessage?.data).toMatchObject({
443
+ content: [{ type: 'text', text: `Reply with exactly ${MARKER} and no other text. Do not call tools.` }],
444
+ source: {
445
+ kind: 'webhook',
446
+ provider: 'github',
447
+ deliveryId: DELIVERY,
448
+ ruleId: 'github-real-e2e',
449
+ source: 'github-real-e2e',
450
+ },
451
+ })
452
+
453
+ const completed = await eventually(
454
+ child,
455
+ observation.text,
456
+ 'a real DeepSeek assistant response',
457
+ async () => await history(baseUrl, sessionId),
458
+ page => assistantText(page).includes(MARKER),
459
+ 150_000,
460
+ )
461
+ expect(assistantText(completed)).toContain(MARKER)
462
+ } finally {
463
+ await stop(child)
464
+ await rm(root, { recursive: true, force: true })
465
+ }
466
+ }, 330_000)
467
+ })
apps/cli/tests/headless-shutdown.e2e.ts ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import { execa } from 'execa'
6
+ import { describe, expect, it } from 'vitest'
7
+ import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
8
+
9
+ const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
10
+ const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
11
+ const neverDisposePlugin = pathToFileURL(
12
+ fileURLToPath(new URL('./fixtures/never-dispose.mjs', import.meta.url)),
13
+ ).href
14
+
15
+ const POSIX_HEADLESS_PTY_DRIVER = String.raw`
16
+ import errno, json, os, pty, select, signal, sys, time
17
+ node, launch_args_json, launch_env_json, cwd, timeout_seconds = sys.argv[1:]
18
+ env = os.environ.copy()
19
+ env.update(json.loads(launch_env_json))
20
+ pid, fd = pty.fork()
21
+ if pid == 0:
22
+ os.chdir(cwd)
23
+ os.execvpe(node, [node, *json.loads(launch_args_json)], env)
24
+
25
+ markers = [b"dsh-test: never-dispose ready", b"dsh-test: never-dispose started"]
26
+ output = bytearray()
27
+ marker_index = 0
28
+ deadline = time.monotonic() + float(timeout_seconds)
29
+ status = None
30
+ while time.monotonic() < deadline:
31
+ ready, _, _ = select.select([fd], [], [], 0.05)
32
+ if ready:
33
+ try:
34
+ chunk = os.read(fd, 65536)
35
+ except OSError as error:
36
+ if error.errno != errno.EIO:
37
+ raise
38
+ chunk = b""
39
+ if chunk:
40
+ output.extend(chunk)
41
+ while marker_index < len(markers) and markers[marker_index] in output:
42
+ if marker_index == 0:
43
+ open(os.path.join(cwd, "shutdown-armed"), "w").close()
44
+ os.write(fd, b"\x03")
45
+ marker_index += 1
46
+ waited, candidate = os.waitpid(pid, os.WNOHANG)
47
+ if waited == pid:
48
+ status = candidate
49
+ break
50
+
51
+ if status is None:
52
+ os.kill(pid, signal.SIGKILL)
53
+ _, status = os.waitpid(pid, 0)
54
+ sys.stdout.buffer.write(output)
55
+ if marker_index != len(markers):
56
+ sys.stderr.write(f"completed {marker_index}/{len(markers)} PTY actions before timeout\n")
57
+ sys.exit(124)
58
+ actual_exit = os.waitstatus_to_exitcode(status)
59
+ if actual_exit != 130:
60
+ sys.stderr.write(f"expected exit 130, got {actual_exit}\n")
61
+ sys.exit(125)
62
+ `
63
+
64
+ async function runHeadlessPtySmoke(): Promise<string> {
65
+ const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-'))
66
+ try {
67
+ const home = join(cwd, '.dsh')
68
+ // Pre-initialize the headless profile with the never-dispose row in its
69
+ // user patch layer (the same file a long-lived profile boot hot-reloads).
70
+ const profileDir = join(home, 'profiles', 'headless')
71
+ await mkdir(profileDir, { recursive: true })
72
+ await writeFile(join(profileDir, 'package.json'), JSON.stringify({
73
+ name: 'dsh-profile-headless',
74
+ private: true,
75
+ dependencies: {},
76
+ dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'] } },
77
+ }, undefined, 2))
78
+ await writeFile(join(profileDir, 'cordis.patch.yml'), [
79
+ '- insert:',
80
+ ' - id: never-dispose',
81
+ ` name: '${neverDisposePlugin}'`,
82
+ '',
83
+ ].join('\n'))
84
+ const launch = resolveExampleLaunch({
85
+ srcBin: dshBinScript,
86
+ configArgs: ['--profile', 'headless', 'never complete'],
87
+ tsconfigPath,
88
+ env: {
89
+ DSH_HOME: home,
90
+ DSH_AGENTS_HOME: join(cwd, '.agents'),
91
+ DEEPSEEK_API_KEY: 'keyless-shutdown-no-call',
92
+ DSH_TELEMETRY_DISABLED: '1',
93
+ DSH_TEST_SHUTDOWN_ARM_FILE: join(cwd, 'shutdown-armed'),
94
+ },
95
+ })
96
+ const timeoutMs = 15_000
97
+ const result = await execa('python3', [
98
+ '-c',
99
+ POSIX_HEADLESS_PTY_DRIVER,
100
+ launch.command,
101
+ JSON.stringify(launch.args),
102
+ JSON.stringify(launch.env),
103
+ cwd,
104
+ String(timeoutMs / 1_000),
105
+ ], {
106
+ stdin: 'ignore',
107
+ timeout: timeoutMs + 5_000,
108
+ killSignal: 'SIGKILL',
109
+ reject: false,
110
+ stripFinalNewline: false,
111
+ })
112
+ if (result.timedOut) {
113
+ throw new Error(`dsh headless PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
114
+ }
115
+ if (result.failed) {
116
+ throw new Error(`dsh headless PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
117
+ }
118
+ return result.stdout
119
+ } finally {
120
+ await rm(cwd, { recursive: true, force: true })
121
+ }
122
+ }
123
+
124
+ describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
125
+ it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
126
+ const output = await runHeadlessPtySmoke()
127
+ expect(output).not.toContain('dsh: observing at ')
128
+ expect(output).toContain('dsh-test: never-dispose ready')
129
+ expect(output).toContain('dsh-test: never-dispose started')
130
+ }, LOADER_SMOKE_TEST_TIMEOUT_MS)
131
+ })
apps/cli/tests/lazy-search-startup.compat.spec.ts ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Node 22 startup-output smoke for the shipped Web CLI composition.
3
+ *
4
+ * Only the dedicated Node compatibility gate opts this test in after building
5
+ * both artifacts; ordinary Vitest inventory deterministically skips it.
6
+ * The child runs built artifacts under plain Node with the real shipped
7
+ * web profile (dsh-base + dsh-web-app bundle patches, auto-initialized).
8
+ * Its URL line follows the settled profile boot; SIGTERM then exercises the
9
+ * shipped quiescent disposer.
10
+ */
11
+
12
+ import { spawn } from 'node:child_process'
13
+ import { existsSync } from 'node:fs'
14
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
15
+ import { tmpdir } from 'node:os'
16
+ import { join, resolve } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+ import yaml from 'js-yaml'
19
+ import { describe, expect, it } from 'vitest'
20
+
21
+ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
22
+ const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
23
+ const webDist = join(repoRoot, 'apps/web/dist/index.html')
24
+ // Full-text session search ships off (`openAt: never` on both layers): the
25
+ // base patch carries the default, and the web restatement must not re-enable it.
26
+ const baseConfigPath = join(repoRoot, 'packages/bundle/base/cordis.patch.yml')
27
+ const webConfigPath = join(repoRoot, 'packages/bundle/web-app/cordis.patch.yml')
28
+ const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1'
29
+
30
+ interface ConfigRow {
31
+ id?: string
32
+ disabled?: unknown
33
+ config?: { openAt?: unknown }
34
+ }
35
+
36
+ interface PatchEntry extends ConfigRow {
37
+ insert?: ConfigRow[]
38
+ }
39
+
40
+ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
41
+ kind: 'scalar',
42
+ construct: value => String(value),
43
+ })
44
+ const configSchema = yaml.JSON_SCHEMA.extend(jsExprType)
45
+
46
+ /** Boot the built Web CLI, wait for its settled URL, then dispose through SIGTERM. */
47
+ function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
48
+ return new Promise((resolveRun, rejectRun) => {
49
+ const env: NodeJS.ProcessEnv = {
50
+ ...process.env,
51
+ DEEPSEEK_API_KEY: 'dsh-cli-smoke-dummy-key',
52
+ DSH_HOME: join(cwd, '.dsh'),
53
+ }
54
+ delete env.DEEPSEEK_BASE_URL
55
+ delete env.NODE_OPTIONS
56
+ delete env.NODE_NO_WARNINGS
57
+ const child = spawn(process.execPath, [
58
+ builtBin,
59
+ 'web',
60
+ '--no-open',
61
+ '--host',
62
+ '127.0.0.1',
63
+ '--port',
64
+ '0',
65
+ ], {
66
+ cwd,
67
+ env,
68
+ stdio: ['ignore', 'pipe', 'pipe'],
69
+ })
70
+ let stdout = ''
71
+ let stderr = ''
72
+ let settled = false
73
+ child.stdout.setEncoding('utf8')
74
+ child.stderr.setEncoding('utf8')
75
+ child.stdout.on('data', (chunk: string) => {
76
+ stdout += chunk
77
+ if (!settled && /dsh web: http:\/\/127\.0\.0\.1:\d+/u.test(stdout)) {
78
+ settled = true
79
+ child.kill('SIGTERM')
80
+ }
81
+ })
82
+ child.stderr.on('data', (chunk: string) => { stderr += chunk })
83
+ const timer = setTimeout(() => {
84
+ child.kill('SIGKILL')
85
+ rejectRun(new Error(`built Web CLI did not settle and dispose within 60s\nstdout:\n${stdout}\nstderr:\n${stderr}`))
86
+ }, 60_000)
87
+ child.on('error', (error) => {
88
+ clearTimeout(timer)
89
+ rejectRun(error)
90
+ })
91
+ child.on('close', (code) => {
92
+ clearTimeout(timer)
93
+ if (!settled) {
94
+ rejectRun(new Error(`built Web CLI exited before settled startup (code ${String(code)})\nstdout:\n${stdout}\nstderr:\n${stderr}`))
95
+ return
96
+ }
97
+ resolveRun({ stdout, stderr, code: code ?? -1 })
98
+ })
99
+ })
100
+ }
101
+
102
+ describe.skipIf(!requireBuiltArtifacts)('built CLI lazy-search startup', () => {
103
+ it('boots and disposes the shipped composition with full-text search off by default', async () => {
104
+ expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true)
105
+ expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true)
106
+ const baseRows = (yaml.load(await readFile(baseConfigPath, 'utf8'), { schema: configSchema }) as PatchEntry[])
107
+ .flatMap(entry => entry.insert ?? [entry])
108
+ const webRows = (yaml.load(await readFile(webConfigPath, 'utf8'), { schema: configSchema }) as PatchEntry[])
109
+ .flatMap(entry => entry.insert ?? [entry])
110
+ const baseRow = baseRows.find(row => row.id === 'session-query-sqlite')
111
+ const webRow = webRows.find(row => row.id === 'session-query-sqlite')
112
+ expect(baseRow?.config?.openAt).toBe('never')
113
+ expect(baseRow?.disabled).toBeUndefined()
114
+ // The web restatement keeps the shipped default; opting in is a later layer's override.
115
+ expect(webRow?.config?.openAt).toBe('never')
116
+ expect(webRow?.disabled).toBeUndefined()
117
+
118
+ const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-'))
119
+ try {
120
+ const result = await runBuiltWeb(cwd)
121
+ expect(result.stdout).toMatch(/dsh web: http:\/\/127\.0\.0\.1:\d+/u)
122
+ expect(result.code).toBe(0)
123
+ expect(result.stderr).not.toMatch(/ExperimentalWarning: SQLite/u)
124
+ } finally {
125
+ await rm(cwd, { recursive: true, force: true })
126
+ }
127
+ }, 70_000)
128
+ })
apps/cli/tests/memory-mcp-configs.spec.ts ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The third-party memory examples stay config-only. This suite parses every
3
+ * checked-in overlay, verifies its package pin, transport, and secret handling, then replaces
4
+ * only the upstream endpoint with the package-owned keyless MCP fixture and
5
+ * proves the real Cordis Loader discovers a tool through the generic bridge.
6
+ */
7
+
8
+ import { readFileSync } from 'node:fs'
9
+ import { resolve } from 'node:path'
10
+ import { afterEach, describe, expect, it } from 'vitest'
11
+ import type { Context } from '@deepseek-ai/cordis'
12
+ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
13
+ import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
14
+ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
15
+ import ToolRuntime from '@deepseek-ai/dsh-tools'
16
+ import * as McpClient from '@deepseek-ai/dsh-mcp-client/src/index.ts'
17
+
18
+ interface ExampleContract {
19
+ file: string
20
+ id: string
21
+ serverName: string
22
+ transport: 'stdio' | 'streamable-http'
23
+ pin: string
24
+ }
25
+
26
+ interface InsertedRow {
27
+ id?: string
28
+ name?: string
29
+ config?: Record<string, unknown>
30
+ }
31
+
32
+ const root = resolve(import.meta.dirname, '../../..')
33
+ const exampleDir = resolve(root, 'apps/cli/config/examples/mcp-memory')
34
+ const baseConfig = resolve(import.meta.dirname, 'fixtures/memory-mcp-base.cordis.yml')
35
+ const fixtureServer = resolve(root, 'packages/mcp/mcp-client/tests/fixture-server.ts')
36
+
37
+ const examples: ExampleContract[] = [
38
+ {
39
+ file: 'memorix.cordis.yml',
40
+ id: 'memory-memorix',
41
+ serverName: 'memorix',
42
+ transport: 'stdio',
43
+ pin: '1.3.0',
44
+ },
45
+ {
46
+ file: 'mcp-reference-memory.cordis.yml',
47
+ id: 'memory-mcp-reference',
48
+ serverName: 'reference_memory',
49
+ transport: 'stdio',
50
+ pin: '2026.7.4',
51
+ },
52
+ {
53
+ file: 'engram.cordis.yml',
54
+ id: 'memory-engram',
55
+ serverName: 'engram',
56
+ transport: 'stdio',
57
+ pin: '1.20.0',
58
+ },
59
+ ]
60
+
61
+ const liveContexts = new Set<Context>()
62
+
63
+ afterEach(async () => {
64
+ await Promise.all([...liveContexts].map(async ctx => ctx.fiber.dispose()))
65
+ liveContexts.clear()
66
+ })
67
+
68
+ function insertedRow(patches: PatchOptions[]): InsertedRow {
69
+ expect(patches).toHaveLength(1)
70
+ const insert = patches[0]?.insert
71
+ expect(insert).toHaveLength(1)
72
+ return insert?.[0] as InsertedRow
73
+ }
74
+
75
+ async function waitForTool(ctx: Context, name: string): Promise<void> {
76
+ const deadline = Date.now() + 10_000
77
+ while (!ctx.tools.schemas().some(schema => schema.name === name)) {
78
+ if (Date.now() >= deadline) throw new Error(`timed out waiting for ${name}`)
79
+ await new Promise(resolveWait => setTimeout(resolveWait, 25))
80
+ }
81
+ }
82
+
83
+ describe('third-party memory MCP example overlays', () => {
84
+ it.each(examples)('parses $file with the documented generic plugin fields', (contract) => {
85
+ const file = resolve(exampleDir, contract.file)
86
+ const source = readFileSync(file, 'utf8')
87
+ const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file))
88
+
89
+ expect(row.id).toBe(contract.id)
90
+ expect(row.name).toBe('@deepseek-ai/dsh-mcp-client')
91
+ expect(row.config?.serverName).toBe(contract.serverName)
92
+ expect(row.config?.transport).toBe(contract.transport)
93
+ expect(source.split('\n', 1)[0]).toContain(contract.pin)
94
+ expect(source).not.toMatch(/\bsk-[A-Za-z0-9_-]{8,}\b/)
95
+ expect(source).not.toContain('DEEPSEEK_API_KEY')
96
+ })
97
+
98
+ it.each(examples)('loads $file and discovers a keyless fixture tool', async (contract) => {
99
+ const patches = loadOverlayPatches(
100
+ 'memory-mcp-config-test',
101
+ resolve(exampleDir, contract.file),
102
+ )
103
+ // The static config gate verifies the checked-in bare package specifier.
104
+ // The unit test maps it to the source module so a clean checkout needs no
105
+ // prebuilt `lib/` artifacts before proving the Loader/MCP behavior.
106
+ insertedRow(patches).name = 'cordis:memory-test-mcp-client'
107
+ const fixturePatch: PatchOptions = {
108
+ id: contract.id,
109
+ config: {
110
+ serverName: contract.serverName,
111
+ transport: 'stdio',
112
+ command: process.execPath,
113
+ args: [fixtureServer],
114
+ env: {},
115
+ cwd: root,
116
+ toolCallTimeoutMs: 5_000,
117
+ },
118
+ }
119
+ const ctx = await boot(
120
+ 'memory-mcp-config-test',
121
+ baseConfig,
122
+ [...patches, fixturePatch],
123
+ (ctx) => {
124
+ liveContexts.add(ctx)
125
+ ctx.loader.builtins['memory-test-system-prompt'] = SystemPrompt
126
+ ctx.loader.builtins['memory-test-tools'] = ToolRuntime
127
+ ctx.loader.builtins['memory-test-mcp-client'] = McpClient
128
+ },
129
+ )
130
+ await waitForTool(ctx, `mcp__${contract.serverName}__greet`)
131
+ }, 15_000)
132
+ })
apps/cli/tests/process-shutdown.spec.ts ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ createProcessShutdown,
4
+ PROCESS_SHUTDOWN_TIMEOUT_MS,
5
+ } from '../src/process-shutdown.ts'
6
+
7
+ function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
8
+ let resolve!: () => void
9
+ let reject!: (error: Error) => void
10
+ const promise = new Promise<void>((accept, fail) => {
11
+ resolve = accept
12
+ reject = fail
13
+ })
14
+ return { promise, resolve, reject }
15
+ }
16
+
17
+ afterEach(() => {
18
+ vi.useRealTimers()
19
+ vi.restoreAllMocks()
20
+ })
21
+
22
+ describe('process shutdown', () => {
23
+ it('completes naturally after disposal resolves and forces exit when it rejects', async () => {
24
+ const resolvedExit = vi.fn()
25
+ const resolvedComplete = vi.fn()
26
+ const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit, resolvedComplete)
27
+ await resolved.shutdown(0)
28
+ expect(resolvedComplete).toHaveBeenCalledOnce()
29
+ expect(resolvedComplete).toHaveBeenCalledWith(0)
30
+ expect(resolvedExit).not.toHaveBeenCalled()
31
+
32
+ const rejectedExit = vi.fn()
33
+ const rejectedComplete = vi.fn()
34
+ const rejected = createProcessShutdown(
35
+ () => Promise.reject(new Error('dispose failed')),
36
+ rejectedExit,
37
+ rejectedComplete,
38
+ )
39
+ await rejected.shutdown(1)
40
+ expect(rejectedExit).toHaveBeenCalledOnce()
41
+ expect(rejectedExit).toHaveBeenCalledWith(1)
42
+ expect(rejectedComplete).not.toHaveBeenCalled()
43
+ })
44
+
45
+ it('uses process.exitCode for default normal completion', async () => {
46
+ const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
47
+ const originalExitCode = process.exitCode
48
+ process.exitCode = undefined
49
+ const shutdown = createProcessShutdown(() => Promise.resolve())
50
+
51
+ try {
52
+ await shutdown.shutdown(7)
53
+
54
+ expect(process.exitCode).toBe(7)
55
+ expect(exit).not.toHaveBeenCalled()
56
+ } finally {
57
+ process.exitCode = originalExitCode
58
+ }
59
+ })
60
+
61
+ it('forces exit when graceful disposal reaches its bound', async () => {
62
+ vi.useFakeTimers()
63
+ const disposal = deferred()
64
+ const exit = vi.fn()
65
+ const complete = vi.fn()
66
+ const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
67
+ const pending = shutdown.shutdown(0)
68
+
69
+ await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
70
+ expect(exit).not.toHaveBeenCalled()
71
+ await vi.advanceTimersByTimeAsync(1)
72
+ expect(exit).toHaveBeenCalledOnce()
73
+ expect(exit).toHaveBeenCalledWith(0)
74
+
75
+ disposal.resolve()
76
+ await pending
77
+ expect(exit).toHaveBeenCalledOnce()
78
+ expect(complete).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it('honors a caller-supplied grace period', async () => {
82
+ vi.useFakeTimers()
83
+ const disposal = deferred()
84
+ const exit = vi.fn()
85
+ const shutdown = createProcessShutdown(() => disposal.promise, exit, vi.fn(), 25)
86
+ const pending = shutdown.shutdown(0)
87
+
88
+ await vi.advanceTimersByTimeAsync(24)
89
+ expect(exit).not.toHaveBeenCalled()
90
+ await vi.advanceTimersByTimeAsync(1)
91
+ expect(exit).toHaveBeenCalledOnce()
92
+
93
+ disposal.resolve()
94
+ await pending
95
+ })
96
+
97
+ it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
98
+ const disposal = deferred()
99
+ const exit = vi.fn()
100
+ const complete = vi.fn()
101
+ const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
102
+ const pending = shutdown.shutdown(0)
103
+
104
+ shutdown.interrupt(130)
105
+ expect(exit).toHaveBeenCalledOnce()
106
+ expect(exit).toHaveBeenCalledWith(130)
107
+
108
+ disposal.resolve()
109
+ await pending
110
+ expect(exit).toHaveBeenCalledOnce()
111
+ expect(complete).not.toHaveBeenCalled()
112
+ })
113
+
114
+ it('forces exit after disposal started by a signal', async () => {
115
+ const disposal = deferred()
116
+ const exit = vi.fn()
117
+ const complete = vi.fn()
118
+ const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
119
+
120
+ shutdown.interrupt(143)
121
+ disposal.resolve()
122
+ await shutdown.shutdown(0)
123
+
124
+ expect(exit).toHaveBeenCalledOnce()
125
+ expect(exit).toHaveBeenCalledWith(143)
126
+ expect(complete).not.toHaveBeenCalled()
127
+ })
128
+
129
+ it('drains on the first signal and forces on the second signal', async () => {
130
+ const disposal = deferred()
131
+ const dispose = vi.fn(() => disposal.promise)
132
+ const exit = vi.fn()
133
+ const shutdown = createProcessShutdown(dispose, exit, vi.fn())
134
+
135
+ shutdown.interrupt(143)
136
+ await Promise.resolve()
137
+ expect(dispose).toHaveBeenCalledOnce()
138
+ expect(exit).not.toHaveBeenCalled()
139
+
140
+ shutdown.interrupt(130)
141
+ expect(exit).toHaveBeenCalledOnce()
142
+ expect(exit).toHaveBeenCalledWith(130)
143
+
144
+ disposal.resolve()
145
+ await shutdown.shutdown(0)
146
+ expect(exit).toHaveBeenCalledOnce()
147
+ })
148
+
149
+ it('coalesces normal shutdown calls without treating them as escalation', async () => {
150
+ const disposal = deferred()
151
+ const exit = vi.fn()
152
+ const complete = vi.fn()
153
+ const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
154
+
155
+ const first = shutdown.shutdown(0)
156
+ const second = shutdown.shutdown(1)
157
+ expect(second).toBe(first)
158
+ expect(exit).not.toHaveBeenCalled()
159
+
160
+ disposal.resolve()
161
+ await first
162
+ expect(complete).toHaveBeenCalledOnce()
163
+ expect(complete).toHaveBeenCalledWith(0)
164
+ expect(exit).not.toHaveBeenCalled()
165
+ })
166
+
167
+ it('lets a signal force exit while natural completion drains remaining handles', async () => {
168
+ const exit = vi.fn()
169
+ const complete = vi.fn()
170
+ const shutdown = createProcessShutdown(() => Promise.resolve(), exit, complete)
171
+
172
+ await shutdown.shutdown(0)
173
+ shutdown.interrupt(130)
174
+
175
+ expect(complete).toHaveBeenCalledOnce()
176
+ expect(exit).toHaveBeenCalledOnce()
177
+ expect(exit).toHaveBeenCalledWith(130)
178
+ })
179
+ })
apps/cli/tests/profile-hmr.spec.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Module-HMR ownership across the real shipped profile bundle layers. */
2
+
3
+ import { join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { describe, expect, it } from 'vitest'
6
+ import { composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
7
+ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
8
+
9
+ const REPOSITORY_ROOT = fileURLToPath(new URL('../../../', import.meta.url))
10
+
11
+ /** Load one shipped bundle patch through the same parser as profile boot. */
12
+ function bundle(name: 'acp-app' | 'base' | 'headless' | 'sdk-app' | 'sdk-minimal' | 'web-app'): PatchOptions[] {
13
+ return loadOverlayPatches('profile-hmr test', join(REPOSITORY_ROOT, 'packages', 'bundle', name, 'cordis.patch.yml'))
14
+ }
15
+
16
+ /** Resolve the effective HMR row after the supplied layers. */
17
+ function hmr(layers: PatchOptions[][]) {
18
+ const row = composeEntries(layers).find(entry => entry.id === 'hmr')
19
+ if (row === undefined) throw new Error('the base bundle must insert the hmr row')
20
+ return row
21
+ }
22
+
23
+ describe('profile module-HMR policy', () => {
24
+ it.each(['web-app', 'headless', 'sdk-app', 'acp-app'] as const)(
25
+ '%s inherits the disabled base row without a mode override',
26
+ (mode) => {
27
+ const modePatches = bundle(mode)
28
+ expect(modePatches.some(patch => patch.id === 'hmr')).toBe(false)
29
+ expect(hmr([bundle('base'), modePatches])).toMatchObject({
30
+ disabled: true,
31
+ config: { root: ['.'] },
32
+ })
33
+ },
34
+ )
35
+
36
+ it('requires an explicit later layer to enable source-module reload', () => {
37
+ expect(hmr([bundle('base'), [{ id: 'hmr', disabled: false }]])).toMatchObject({
38
+ disabled: false,
39
+ config: { root: ['.'] },
40
+ })
41
+ })
42
+
43
+ it('keeps the standalone sdk-minimal tree free of module HMR', () => {
44
+ expect(composeEntries([bundle('sdk-minimal')]).find(entry => entry.id === 'hmr')).toBeUndefined()
45
+ })
46
+ })
apps/cli/tests/profile-initialization.spec.ts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** One-time custom-profile initialization from shipped templates. */
2
+
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import {
8
+ initProfile,
9
+ PROFILE_PATCH_FILENAME,
10
+ PROFILE_TEMPLATES,
11
+ readProfileManifest,
12
+ resolveProfileDir,
13
+ writeProfileManifest,
14
+ } from '@deepseek-ai/dsh-app-boot'
15
+ import { describe, expect, it } from 'vitest'
16
+ import { execa } from 'execa'
17
+ import { initializeProfileFromDefault } from '../src/profile-boot.ts'
18
+
19
+ const childEntry = fileURLToPath(new URL('./fixtures/initialize-profile-from-default.ts', import.meta.url))
20
+ const tsxLoader = import.meta.resolve('tsx/esm')
21
+ const CHILD_TIMEOUT_MS = 30_000
22
+
23
+ /** Wait until a child has reached the shared creation barrier. */
24
+ async function waitForFile(file: string): Promise<void> {
25
+ const deadline = Date.now() + CHILD_TIMEOUT_MS
26
+ while (!existsSync(file)) {
27
+ if (Date.now() >= deadline) throw new Error(`profile initialization marker did not appear: ${file}`)
28
+ await new Promise(resolve => setTimeout(resolve, 20))
29
+ }
30
+ }
31
+
32
+ /** Run one assertion against a private Harness home and remove it afterwards. */
33
+ function withHome(assertion: (home: string) => void): void {
34
+ const home = mkdtempSync(join(tmpdir(), 'dsh-profile-from-default-'))
35
+ try {
36
+ assertion(home)
37
+ } finally {
38
+ rmSync(home, { recursive: true, force: true })
39
+ }
40
+ }
41
+
42
+ describe('initializeProfileFromDefault', () => {
43
+ it.each(Object.entries(PROFILE_TEMPLATES))(
44
+ 'copies the %s template metadata into an independent profile',
45
+ (source, template) => {
46
+ withHome((home) => {
47
+ initializeProfileFromDefault('custom', source, home)
48
+ const dir = resolveProfileDir('custom', home)
49
+ const manifest = readProfileManifest('test', dir)
50
+ expect(manifest).toEqual({
51
+ name: 'dsh-profile-custom',
52
+ private: true,
53
+ dependencies: {},
54
+ dsh: { profile: { bundles: [...template.bundles], patchReload: template.patchReload } },
55
+ })
56
+ expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
57
+ expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
58
+ })
59
+ },
60
+ )
61
+
62
+ it('does not copy the local source profile dependencies or user patch', () => {
63
+ withHome((home) => {
64
+ const sourceDir = resolveProfileDir('web', home)
65
+ initProfile(sourceDir, ['local-bundle'], 'startup')
66
+ const sourceManifest = readProfileManifest('test', sourceDir)
67
+ sourceManifest.dependencies = { 'local-bundle': '1.0.0' }
68
+ writeProfileManifest(sourceDir, sourceManifest)
69
+ writeFileSync(join(sourceDir, PROFILE_PATCH_FILENAME), '- id: local-only\n disabled: true\n')
70
+
71
+ initializeProfileFromDefault('rescue', 'web', home)
72
+
73
+ const targetDir = resolveProfileDir('rescue', home)
74
+ const target = readProfileManifest('test', targetDir)
75
+ expect(target.dependencies).toEqual({})
76
+ expect(target.dsh?.profile).toEqual({
77
+ bundles: [...PROFILE_TEMPLATES.web!.bundles],
78
+ patchReload: PROFILE_TEMPLATES.web!.patchReload,
79
+ })
80
+ expect(readFileSync(join(targetDir, PROFILE_PATCH_FILENAME), 'utf8')).not.toContain('local-only')
81
+ })
82
+ })
83
+
84
+ it('rejects an existing target without changing its files', () => {
85
+ withHome((home) => {
86
+ const dir = resolveProfileDir('rescue', home)
87
+ initProfile(dir, ['existing-bundle'], 'startup')
88
+ writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: existing\n disabled: true\n')
89
+ const paths = ['package.json', PROFILE_PATCH_FILENAME, 'pnpm-workspace.yaml'].map(file => join(dir, file))
90
+ const before = paths.map(path => readFileSync(path))
91
+
92
+ expect(() => {
93
+ initializeProfileFromDefault('rescue', 'web', home)
94
+ })
95
+ .toThrow('profile "rescue" already exists')
96
+ expect(paths.map(path => readFileSync(path))).toEqual(before)
97
+ })
98
+ })
99
+
100
+ it('rejects a residual target directory without changing its contents', () => {
101
+ withHome((home) => {
102
+ const dir = resolveProfileDir('rescue', home)
103
+ mkdirSync(dir, { recursive: true })
104
+ const residual = join(dir, PROFILE_PATCH_FILENAME)
105
+ writeFileSync(residual, '- id: residual\n disabled: true\n')
106
+ const before = readFileSync(residual)
107
+
108
+ expect(() => {
109
+ initializeProfileFromDefault('rescue', 'web', home)
110
+ })
111
+ .toThrow('profile directory')
112
+ expect(readFileSync(residual)).toEqual(before)
113
+ expect(existsSync(join(dir, 'package.json'))).toBe(false)
114
+ })
115
+ })
116
+
117
+ it.each(Object.keys(PROFILE_TEMPLATES))('rejects shipped target name %s without creating it', (name) => {
118
+ withHome((home) => {
119
+ expect(() => {
120
+ initializeProfileFromDefault(name, 'web', home)
121
+ })
122
+ .toThrow(`profile ${JSON.stringify(name)} is shipped`)
123
+ expect(existsSync(resolveProfileDir(name, home))).toBe(false)
124
+ })
125
+ })
126
+
127
+ it.each(['unknown', 'toString'])('rejects unknown template %s without creating the target', (source) => {
128
+ withHome((home) => {
129
+ expect(() => {
130
+ initializeProfileFromDefault('rescue', source, home)
131
+ })
132
+ .toThrow(`unknown default profile ${JSON.stringify(source)}`)
133
+ expect(existsSync(resolveProfileDir('rescue', home))).toBe(false)
134
+ })
135
+ })
136
+
137
+ it('allows only one of two synchronized processes to create the target', async () => {
138
+ const home = mkdtempSync(join(tmpdir(), 'dsh-profile-from-default-race-'))
139
+ const gate = join(home, 'start')
140
+ const ready = [join(home, 'ready-1'), join(home, 'ready-2')]
141
+ const children = ready.map(marker => execa(
142
+ process.execPath,
143
+ ['--import', tsxLoader, childEntry, home, 'rescue', 'web', marker, gate],
144
+ { reject: false, timeout: CHILD_TIMEOUT_MS },
145
+ ))
146
+ try {
147
+ await Promise.all(ready.map(waitForFile))
148
+ writeFileSync(gate, '')
149
+ const results = await Promise.all(children)
150
+ expect(results.map(result => result.exitCode).sort()).toEqual([0, 1])
151
+ expect(readProfileManifest('test', resolveProfileDir('rescue', home)).dsh?.profile)
152
+ .toEqual(PROFILE_TEMPLATES.web)
153
+ } finally {
154
+ for (const child of children) child.kill('SIGKILL')
155
+ rmSync(home, { recursive: true, force: true })
156
+ }
157
+ }, CHILD_TIMEOUT_MS + 10_000)
158
+ })
apps/cli/tests/profile-mcp.spec.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** MCP resource ownership across the resolved shipped profile templates. */
2
+
3
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { describe, expect, it } from 'vitest'
8
+ import { composeEntries, loadProfile, PROFILE_TEMPLATES } from '@deepseek-ai/dsh-app-boot'
9
+
10
+ const installAnchor = fileURLToPath(new URL('../package.json', import.meta.url))
11
+ const resourcePackage = '@deepseek-ai/dsh-mcp-resources'
12
+
13
+ describe('shipped MCP resource composition', () => {
14
+ it.each(Object.keys(PROFILE_TEMPLATES))('%s carries one shared resource consumer without a server', (name) => {
15
+ const home = mkdtempSync(join(tmpdir(), 'dsh-profile-mcp-'))
16
+ try {
17
+ const profile = loadProfile('dsh', name, installAnchor, home)
18
+ const warnings: string[] = []
19
+ const rows = composeEntries([
20
+ ...profile.layers.map(layer => layer.patches),
21
+ profile.patches,
22
+ ], message => warnings.push(message))
23
+
24
+ expect(rows.filter(row => row.name === resourcePackage)).toEqual([
25
+ { id: 'mcp-resources', name: resourcePackage },
26
+ ])
27
+ expect(rows.filter(row => row.name === '@deepseek-ai/dsh-mcp-client')).toEqual([])
28
+ expect(warnings).toEqual([])
29
+
30
+ const owners = profile.layers.filter((layer) => {
31
+ const manifest = JSON.parse(readFileSync(join(layer.packageDir, 'package.json'), 'utf8')) as {
32
+ dependencies?: Record<string, string>
33
+ }
34
+ return manifest.dependencies?.[resourcePackage] !== undefined
35
+ })
36
+ expect(owners.map(owner => owner.packageName)).toEqual([
37
+ name === 'sdk-minimal' ? '@deepseek-ai/dsh-sdk-minimal' : '@deepseek-ai/dsh-base',
38
+ ])
39
+ } finally {
40
+ rmSync(home, { recursive: true, force: true })
41
+ }
42
+ })
43
+ })
apps/cli/tests/source-launch.compat.spec.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFile } from 'node:fs/promises'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { execa } from 'execa'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ /**
7
+ * Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts`
8
+ * with the exact production runtime vector (`node --import tsx/esm`, the
9
+ * vector the root `dsh` script invokes directly) and assert the
10
+ * required-config diagnostic. The Node compatibility matrix runs this
11
+ * WHOLE file, so a Node release changing module hooks or TypeScript handling
12
+ * breaks this gate instead of every developer's `pnpm dsh`; the built-bin
13
+ * suite covers the published `lib/` entry, not this source chain.
14
+ */
15
+
16
+ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
17
+ const dshSourceBin = 'apps/cli/src/bin.ts'
18
+
19
+ describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
20
+ it('launches the source CLI without building', async () => {
21
+ const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as {
22
+ readonly scripts?: Record<string, string>
23
+ }
24
+ expect(rootPackage.scripts?.dsh).toBe('node --import tsx/esm apps/cli/src/bin.ts')
25
+ })
26
+
27
+ it('boots the source entry and requires a profile', async () => {
28
+ const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], {
29
+ cwd: repoRoot,
30
+ input: '',
31
+ timeout: 25_000,
32
+ killSignal: 'SIGKILL',
33
+ reject: false,
34
+ })
35
+ if (result.timedOut) {
36
+ throw new Error(`dsh source launch did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
37
+ }
38
+ expect(result.exitCode).not.toBe(0)
39
+ expect(result.stderr).toContain('--profile <name> is required')
40
+ expect(result.stdout).toBe('')
41
+ }, 30_000)
42
+ })
apps/cli/tests/telemetry-switch.spec.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import { resolveTelemetryPatch } from '../src/profile-boot.ts'
3
+
4
+ describe('resolveTelemetryPatch', () => {
5
+ it('preserves the configured telemetry mode when the hard-disable switch is unset or empty', () => {
6
+ expect(resolveTelemetryPatch(undefined, true)).toBeUndefined()
7
+ expect(resolveTelemetryPatch('', true)).toBeUndefined()
8
+ })
9
+
10
+ it('disables on ANY non-empty value, including falsy-looking ones', () => {
11
+ for (const value of ['1', '0', 'false', 'no']) {
12
+ expect(resolveTelemetryPatch(value, true)).toEqual({ id: 'session-telemetry-otel', disabled: true })
13
+ }
14
+ })
15
+
16
+ it('is trivially satisfied by a composition without the telemetry row', () => {
17
+ // A custom profile need not mount telemetry: nothing exports, so the
18
+ // privacy switch has nothing to disable and generates no patch.
19
+ expect(resolveTelemetryPatch('1', false)).toBeUndefined()
20
+ expect(resolveTelemetryPatch(undefined, false)).toBeUndefined()
21
+ })
22
+ })
apps/cli/tests/web-agent-presets.e2e.ts ADDED
@@ -0,0 +1,995 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdir, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dirname, join } from 'node:path'
6
+ import { Context } from '@deepseek-ai/cordis'
7
+ import {
8
+ boot,
9
+ createProfileResolutionGeneration,
10
+ loadOverlayPatches,
11
+ loadProfile,
12
+ PluginPackages,
13
+ type Profile,
14
+ } from '@deepseek-ai/dsh-app-boot'
15
+ import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
16
+ import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
17
+ import type { Agent } from '@deepseek-ai/dsh-agent'
18
+ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
19
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
20
+ import { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
21
+ import { SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets'
22
+ import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
23
+ import { ToolCallId } from '@deepseek-ai/dsh-llm'
24
+ import type {} from '@deepseek-ai/dsh-compaction-basic'
25
+ import type {} from '@deepseek-ai/dsh-skill'
26
+ import type {} from '@deepseek-ai/dsh-tools'
27
+ // Type-only: resolves `ctx.get('sessionProjections')` and `ctx.get('tokenMeter')`.
28
+ import type {} from '@deepseek-ai/dsh-session-projection'
29
+ import type {} from '@deepseek-ai/dsh-token-meter'
30
+
31
+ const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
32
+ /** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */
33
+ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
34
+ const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
35
+ const CODEX_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-codex')
36
+ const CLAUDE_CODE_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-claude-code')
37
+ /** The installation anchor whose dependency surface the preset module fallback mirrors. */
38
+ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
39
+ const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.'
40
+ const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
41
+ * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
42
+ * Network access depends on the task environment. Prefer configured mirrors/proxies when they are available.
43
+ * State is persistent across command calls and discussions with the user.
44
+ * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.
45
+ * Please avoid commands that may produce a very large amount of output.
46
+ * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.`
47
+
48
+ /**
49
+ * Boot the shipped Web composition, minus the rows that would bind a port,
50
+ * touch the network, or write outside the test. Everything that decides an
51
+ * agent's capabilities is the real thing, including both shipped presets.
52
+ */
53
+ async function bootWeb(
54
+ settingsFile: string,
55
+ extra: PatchOptions[] = [],
56
+ profilePackages: readonly string[] = [],
57
+ profileBundles?: readonly string[],
58
+ ): Promise<Context> {
59
+ const storageRoot = join(dirname(settingsFile), 'storages')
60
+ const overrides: PatchOptions[] = [
61
+ // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it
62
+ // reads the developer's own document — and since the default preset is a
63
+ // setting, a stored `agent-presets.default` would decide this file's
64
+ // outcome. Point it at a temp file for the same reason the roster row
65
+ // below pins `includeUserRoot` off.
66
+ { id: 'settings', config: { path: settingsFile, watch: false } },
67
+ // storage-json's root is anchored to the real $DSH_HOME. Unpinned, this
68
+ // file writes the developer's own `~/.dsh/storages/` — and then reads it
69
+ // back on the next run, so a stored document from any other build decides
70
+ // this test's boot. Same reason the settings row above is pinned.
71
+ { id: 'storage-json', config: { root: storageRoot } },
72
+ // Fixed Session IDs must stay inside this boot's temporary profile root.
73
+ { id: 'session-persistence-jsonl', config: { root: join(dirname(settingsFile), 'sessions') } },
74
+ // Host rows with side effects outside this process: a bound port, a served
75
+ // asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
76
+ // stay ENABLED on purpose — the api-proxy is the host row that injects
77
+ // `subagents`, `workspace`, and the rest of the agent plane, so disabling
78
+ // it would hide exactly the breakage this file exists to catch: a service
79
+ // moved into the presets that a host row still waits for. The boot audit
80
+ // is that assertion.
81
+ { id: 'webserver', disabled: true },
82
+ // The web bundle's runtime row injects `webServer`, so it cannot
83
+ // activate without the bound port disabled above. It owns dist serving
84
+ // and the URL prompt line — surface glue, not anything that decides an
85
+ // agent's capabilities, which is all this file asserts.
86
+ { id: 'web-runtime', disabled: true },
87
+ { id: 'session-telemetry-otel', disabled: true },
88
+ // A deployment-level skill on the host registry's GLOBAL layer — the same
89
+ // registration shape a repository plugin's skill root uses. The layered
90
+ // skills test below proves it reaches preset-composed agents.
91
+ { id: 'skill-badge', disabled: false },
92
+ { id: 'modules', disabled: true },
93
+ // The physical Connection row owns the disabled HTTP server. bootWeb
94
+ // supplies only its in-process registries so Host services still prove
95
+ // their shipped dependency graph without binding a port.
96
+ { id: 'connection', disabled: true },
97
+ // Export owns a Connection Fetch route, so this Host-only composition
98
+ // disables it with the transport service above.
99
+ { id: 'session-log-download', disabled: true },
100
+ // The open-in-app host routes wait for the webserver and connection
101
+ // rows disabled above (connection's trust fence guards every route).
102
+ { id: 'open-in-app', disabled: true },
103
+ // The always-on reload chain waits for the browser roster and bound port
104
+ // disabled above.
105
+ { id: 'client-hmr', disabled: true },
106
+ // The shipped `-auto` chooser resolves its interaction from a running
107
+ // host and so waits for the webserver disabled above; the browse variant
108
+ // supplies `directoryPicker` without one.
109
+ { id: 'directory-picker', disabled: true },
110
+ { insert: [
111
+ { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
112
+ { id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' },
113
+ ] },
114
+ // Pin the roster away from the developer's machine: `includeUserRoot`
115
+ // false keeps `~/.dsh/.agent-presets` from changing a test's outcome.
116
+ // `default` here is the COMPOSITION default — the base layer the settings
117
+ // document overrides. No `roots` entry: the plugin bundles the shipped
118
+ // presets itself and prepends their root.
119
+ { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } },
120
+ ...extra,
121
+ ]
122
+ const home = dirname(settingsFile)
123
+ const profileDir = join(home, 'profiles', 'spec')
124
+ await mkdir(profileDir, { recursive: true })
125
+ // Product Bundles are installed into the Profile, not the dsh app. Model
126
+ // pnpm's package link for only the selected products; their own production
127
+ // dependencies resolve from the linked workspace packages, while shared
128
+ // peers still resolve through the installation fallback above.
129
+ for (const packageDir of profilePackages) {
130
+ const manifest = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')) as { name: string }
131
+ const link = join(profileDir, 'node_modules', manifest.name)
132
+ await mkdir(dirname(link), { recursive: true })
133
+ await symlink(packageDir, link, 'junction')
134
+ }
135
+ let profile: Profile = {
136
+ name: 'spec',
137
+ dir: profileDir,
138
+ layers: [],
139
+ patchPath: join(profileDir, 'cordis.patch.yml'),
140
+ patches: [],
141
+ patchReload: 'startup',
142
+ }
143
+ let bundlePatches: PatchOptions[] = [
144
+ ...loadOverlayPatches('dsh-test', BASE_PATCH),
145
+ ...loadOverlayPatches('dsh-test', WEB_PATCH),
146
+ ]
147
+ if (profileBundles !== undefined) {
148
+ await writeFile(join(profileDir, 'package.json'), JSON.stringify({
149
+ private: true,
150
+ dependencies: Object.fromEntries(profileBundles.map(name => [name, 'workspace:*'])),
151
+ dsh: { profile: { bundles: profileBundles } },
152
+ }, null, 2) + '\n')
153
+ profile = loadProfile('dsh-test', 'spec', INSTALL_ANCHOR, home, { userLayer: false })
154
+ bundlePatches = profile.layers.flatMap(layer => layer.patches)
155
+ }
156
+ const resolution = await createProfileResolutionGeneration({ installAnchor: INSTALL_ANCHOR, home, profile })
157
+ const rootConfig = join(profileDir, 'cordis.yml')
158
+ await writeFile(rootConfig, '[]\n')
159
+ return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], async (bootCtx) => {
160
+ await bootCtx.plugin(PluginPackages, { generation: resolution })
161
+ bootCtx.provide('connection', {
162
+ fetch: { register: () => () => {} },
163
+ rpc: { intercept: () => () => {} },
164
+ } as never)
165
+ provideCmdline(bootCtx, { args: [], exit: () => {} })
166
+ })
167
+ }
168
+
169
+ const toolNames = (ctx: Context, agent?: Agent): string[] =>
170
+ ctx.tools.schemas(agent).map(schema => schema.name).sort()
171
+
172
+ function toolParameterNames(ctx: Context, agent: Agent, toolName: string): string[] {
173
+ const schema = ctx.tools.schemas(agent).find(tool => tool.name === toolName)
174
+ if (schema === undefined) throw new Error(`missing tool schema ${toolName}`)
175
+ const properties = schema.parameters.properties
176
+ if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) {
177
+ throw new Error(`${toolName} has invalid parameter properties`)
178
+ }
179
+ return Object.keys(properties).sort()
180
+ }
181
+
182
+ function enablePresetTool(composition: string, id: string): string {
183
+ const row = ` - id: ${id}\n`
184
+ const start = composition.indexOf(row)
185
+ if (start < 0) throw new Error(`missing preset row ${id}`)
186
+ const end = composition.indexOf('\n - id:', start + row.length)
187
+ const disabled = composition.indexOf(' disabled: true\n', start)
188
+ if (disabled < 0 || (end >= 0 && disabled > end)) {
189
+ throw new Error(`preset row ${id} is not disabled`)
190
+ }
191
+ return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length)
192
+ }
193
+
194
+ let ctx: Context
195
+ beforeAll(async () => {
196
+ const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
197
+ await writeFile(settingsFile, '{}\n')
198
+ ctx = await bootWeb(settingsFile)
199
+ }, 120_000)
200
+
201
+ describe('the shipped Web composition', () => {
202
+ it('leaves the global tool layer empty', () => {
203
+ // Every model-facing tool belongs to a preset, `ask_user_question`
204
+ // included: a tool in the global layer reaches EVERY agent regardless of
205
+ // which preset composed it, expanding that preset's tool list.
206
+ expect(toolNames(ctx)).toEqual([])
207
+ })
208
+
209
+ it('keeps the token meter and its context-meter projections on the host plane', async () => {
210
+ // Read before any preset in this file mounts, which is what makes this an
211
+ // ownership assertion rather than a mount-order coincidence: a preset-side
212
+ // meter sits behind an `isolate` realm and is invisible to `ctx.get`.
213
+ //
214
+ // The projection registry is process-wide rather than scope-layered, so a
215
+ // preset-side meter would also make the browser's context meter appear for
216
+ // a `minimal` session the moment some OTHER session mounted a preset that
217
+ // carries one, and vanish entirely in a process that only ever ran
218
+ // `minimal`. Host ownership is what makes the meter a per-session fact.
219
+ expect(ctx.get('tokenMeter')).toBeDefined()
220
+ const projections = ctx.get('sessionProjections')
221
+ if (projections === undefined) throw new Error('the Web composition must compose a projection registry')
222
+ const handle = await ctx.agents.create({
223
+ sessionId: SessionId('preset-minimal-meter'),
224
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
225
+ })
226
+ try {
227
+ // A subset assertion: `tasks`, `goal`, and the rest register into the
228
+ // same process-wide table, and this is about the meter's three units.
229
+ expect(Object.keys(projections.snapshot(handle.agent.session).values))
230
+ .toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage']))
231
+ } finally {
232
+ await handle.dispose()
233
+ }
234
+ })
235
+
236
+ it('supplies both shipped presets, and only those, from the system root', async () => {
237
+ const listed = await ctx.agentPresets.list()
238
+
239
+ expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard'])
240
+ expect(listed.every(preset => preset.trust === 'system')).toBe(true)
241
+ expect(ctx.agentPresets.defaultId).toBe('standard')
242
+ })
243
+
244
+ it('composes the full agent from `standard`', async () => {
245
+ const handle = await ctx.agents.create({
246
+ sessionId: SessionId('preset-standard'),
247
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
248
+ })
249
+ try {
250
+ // The EXACT catalog, not a spot-check: an omission is this design's
251
+ // quietest failure mode, because a row that registers into the wrong
252
+ // layer mounts cleanly and simply contributes nothing. `glob`/`grep` are
253
+ // excluded for the reason the TUI composition e2e excludes them — they
254
+ // depend on ripgrep being present on the machine.
255
+ expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
256
+ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
257
+ 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'present', 'read', 'read_image', 'send_message', 'skill',
258
+ 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search',
259
+ 'workflow', 'write',
260
+ ])
261
+ expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined()
262
+ } finally {
263
+ await handle.dispose()
264
+ }
265
+ })
266
+
267
+ it('applies the default-off subagent model allowlist only to new sessions', async () => {
268
+ await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
269
+ enabled: false,
270
+ allowedModels: [],
271
+ })
272
+ const disabled = await ctx.agents.create({
273
+ sessionId: SessionId('preset-model-selection-disabled'),
274
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
275
+ })
276
+ await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {
277
+ enabled: true,
278
+ allowedModels: [{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }],
279
+ })
280
+ const enabled = await ctx.agents.create({
281
+ sessionId: SessionId('preset-model-selection-enabled'),
282
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
283
+ })
284
+ try {
285
+ expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models')
286
+ expect(toolParameterNames(ctx, disabled.agent, 'subagent')).not.toEqual(expect.arrayContaining([
287
+ 'model', 'provider', 'reasoning_effort',
288
+ ]))
289
+ expect(toolNames(ctx, enabled.agent)).toContain('list_subagent_models')
290
+ expect(toolParameterNames(ctx, enabled.agent, 'subagent')).toEqual(expect.arrayContaining([
291
+ 'model', 'provider', 'reasoning_effort',
292
+ ]))
293
+ expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models')
294
+ } finally {
295
+ await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
296
+ await enabled.dispose()
297
+ await disabled.dispose()
298
+ }
299
+ })
300
+
301
+ it('composes the exact RL prompt and persistent shell from `minimal`', async () => {
302
+ const handle = await ctx.agents.create({
303
+ sessionId: SessionId('preset-minimal'),
304
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
305
+ })
306
+ try {
307
+ const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
308
+ expect(assembly.sections).toEqual([
309
+ { name: 'deployment:persona-prefix', text: MINIMAL_PROMPT },
310
+ ])
311
+ expect(assembly.tools.map(tool => tool.name)).toEqual(['bash'])
312
+ expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION)
313
+ expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
314
+ // serviceFor reports preset-owned providers; unisolated consumers inherit the host fs.
315
+ expect(ctx.agentPresets.serviceFor(handle.agent, 'fs')).toBeUndefined()
316
+ expect(ctx.get('fs')?.sandboxMode).toBeDefined()
317
+ expect(handle.agent.ctx.get('fs')?.sandboxMode).toBe(ctx.get('fs')?.sandboxMode)
318
+ expect(ctx.agentPresets.serviceFor(handle.agent, 'compaction')).toBeUndefined()
319
+ expect(handle.agent.ctx.get('compaction')).toBeUndefined()
320
+ } finally {
321
+ await handle.dispose()
322
+ }
323
+ })
324
+
325
+ it('keeps two differently composed sessions independent', async () => {
326
+ const full = await ctx.agents.create({
327
+ sessionId: SessionId('preset-both-full'),
328
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
329
+ })
330
+ const minimal = await ctx.agents.create({
331
+ sessionId: SessionId('preset-both-minimal'),
332
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
333
+ })
334
+ try {
335
+ expect(toolNames(ctx, minimal.agent)).toEqual(['bash'])
336
+ expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
337
+
338
+ await minimal.dispose()
339
+
340
+ // Tearing the minimal session down leaves the full one whole.
341
+ expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
342
+ expect(toolNames(ctx)).toEqual([])
343
+ } finally {
344
+ await full.dispose()
345
+ }
346
+ })
347
+
348
+ it('composes the cordis agent with its own toolset', async () => {
349
+ const handle = await ctx.agents.create({
350
+ sessionId: SessionId('preset-cordis'),
351
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined),
352
+ })
353
+ try {
354
+ const tools = toolNames(ctx, handle.agent)
355
+ // The self-referential toolset is what distinguishes this preset.
356
+ expect(tools).toEqual(expect.arrayContaining([
357
+ 'cordis_inspect_list', 'cordis_inspect_query', 'cordis_inspect_self',
358
+ 'cordis_define', 'cordis_run', 'cordis_stop', 'cordis_undefine',
359
+ ]))
360
+ // And it keeps the standard agent's own tools rather than replacing them.
361
+ expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
362
+ expect(tools).not.toContain('str_replace_editor')
363
+ expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined()
364
+
365
+ // The preset's own authoring skill registers into ITS layer of the host
366
+ // registry: the cordis agent's view carries it, the global view does not.
367
+ const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)
368
+ expect(scoped).toContain('editing-cordis-compositions')
369
+ expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions')
370
+ } finally {
371
+ await handle.dispose()
372
+ }
373
+ })
374
+
375
+ it('presents `ptc` as PTC mode without disturbing a native session beside it', async () => {
376
+ const coded = await ctx.agents.create({
377
+ sessionId: SessionId('preset-ptc'),
378
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'ptc').then(() => undefined),
379
+ })
380
+ const native = await ctx.agents.create({
381
+ sessionId: SessionId('preset-ptc-native'),
382
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
383
+ })
384
+ try {
385
+ // One tool reaches the MODEL: the transport. The registry's catalog for
386
+ // this agent is unchanged — PTC mode collapses the presentation, not
387
+ // the capabilities — so the assembly is what carries the claim.
388
+ const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
389
+ expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
390
+ expect(toolNames(ctx, coded.agent)).not.toContain('str_replace_editor')
391
+ expect(ctx.commands.find(coded.agent, 'goal')).toBeDefined()
392
+ const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
393
+ expect(sdk).not.toContain('str_replace_editor')
394
+ expect(sdk).toContain('web_search')
395
+
396
+ // The presentation is this agent's alone: the deployment default is
397
+ // native, and the session composed from `standard` still sees it.
398
+ const nativeAssembly = await ctx.systemPrompt.assemble({ scope: native.agent })
399
+ expect(nativeAssembly.tools.map(tool => tool.name)).toContain('bash')
400
+ expect(nativeAssembly.tools.map(tool => tool.name)).not.toContain('run_code')
401
+ expect(nativeAssembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
402
+ } finally {
403
+ await native.dispose()
404
+ await coded.dispose()
405
+ }
406
+ })
407
+
408
+ it('keeps the self-referential toolset out of every other preset', async () => {
409
+ const handle = await ctx.agents.create({
410
+ sessionId: SessionId('preset-no-cordis'),
411
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
412
+ })
413
+ try {
414
+ // Editing the live runtime is opt-in per session, not ambient.
415
+ expect(toolNames(ctx, handle.agent)).not.toContain('cordis_define')
416
+ } finally {
417
+ await handle.dispose()
418
+ }
419
+ })
420
+
421
+ it('ships the composition-authoring skill inside the preset directory', async () => {
422
+ // The preset's skill root is derived from its own `baseUrl`, so the skill
423
+ // travels with the directory wherever the preset is installed.
424
+ const skill = join(
425
+ SHIPPED_PRESET_ROOT, 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md',
426
+ )
427
+
428
+ expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true)
429
+ })
430
+
431
+ it('merges the global skill layer into a preset agent\'s catalog, keeping local discovery preset-side', async () => {
432
+ const proj = await mkdtemp(join(tmpdir(), 'dsh-preset-skill-proj-'))
433
+ await mkdir(join(proj, '.dsh', 'skills', 'project-proof'), { recursive: true })
434
+ await writeFile(join(proj, '.dsh', 'skills', 'project-proof', 'SKILL.md'), [
435
+ '---',
436
+ 'name: project-proof',
437
+ 'description: Proves the preset layer discovers project skills beside global ones.',
438
+ '---',
439
+ '',
440
+ 'Project proof body.',
441
+ '',
442
+ ].join('\n'))
443
+
444
+ const handle = await ctx.agents.create({
445
+ // Unique per run: the composition persists into the ambient DSH home,
446
+ // and a fixed id would collide with a log an earlier run left there.
447
+ sessionId: SessionId(`preset-skills-standard-${randomUUID()}`),
448
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
449
+ })
450
+ try {
451
+ // The host (global) view carries the deployment-level provider alone:
452
+ // local discovery moved behind the presets with `skill-filesystem`.
453
+ expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual(['dsh-badge'])
454
+
455
+ // The standard agent's view merges the global layer with its preset's
456
+ // own local discovery over the session cwd.
457
+ const scoped = (await ctx.skills.list({ cwd: proj, scope: handle.agent })).map(skill => skill.name)
458
+ expect(scoped).toContain('dsh-badge')
459
+ expect(scoped).toContain('project-proof')
460
+
461
+ // The preset's own loader tool resolves the global-layer skill.
462
+ const loaded = await ctx.tools.execute({
463
+ callId: ToolCallId('preset-skills-load'),
464
+ name: 'skill',
465
+ arguments: { name: 'dsh-badge' },
466
+ signal: new AbortController().signal,
467
+ agent: handle.agent,
468
+ })
469
+ expect(loaded.isError).toBe(false)
470
+ expect(JSON.stringify(loaded.content)).toContain('powered by dsh')
471
+ } finally {
472
+ await handle.dispose()
473
+ }
474
+ })
475
+
476
+ it('shows a minimal agent the global layer but no loader tool', async () => {
477
+ const handle = await ctx.agents.create({
478
+ sessionId: SessionId(`preset-skills-minimal-${randomUUID()}`),
479
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
480
+ })
481
+ try {
482
+ // Layer visibility is the registry's; whether an agent can USE skills
483
+ // stays the preset's choice — minimal mounts no `tool-skill`, so its
484
+ // tool table has no loader even though the global layer is readable.
485
+ expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge')
486
+ expect(toolNames(ctx, handle.agent)).toEqual(['bash'])
487
+ } finally {
488
+ await handle.dispose()
489
+ }
490
+ })
491
+
492
+ it('never rewrites the preset file it composed from', async () => {
493
+ // The Loader persists a tree whose plugin self-disposed, and tearing an
494
+ // agent down disposes its whole subtree. Inherited, that rewrote the
495
+ // shipped composition — truncating it to `[]` the first time a session
496
+ // ended — so `PresetTree` refuses to write at all.
497
+ const path = join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml')
498
+ const before = await readFile(path, 'utf8')
499
+
500
+ const handle = await ctx.agents.create({
501
+ sessionId: SessionId('preset-readonly'),
502
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
503
+ })
504
+ await handle.dispose()
505
+ // Slack, not a race the number has to win. The write is driven by the
506
+ // Loader's fiber-unload listener, which fires as the subtree's fibers
507
+ // settle rather than when `dispose()` resolves, and the Loader exposes no
508
+ // flush to await. A regression writes synchronously inside that listener,
509
+ // so any wait past settlement fails; a longer one only slows the test.
510
+ await new Promise(resolve => setTimeout(resolve, 50))
511
+
512
+ expect(await readFile(path, 'utf8')).toBe(before)
513
+ })
514
+ })
515
+
516
+ describe('product Bundle and user-preset intersection', () => {
517
+ const presetIds = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const
518
+ type Product = 'codex' | 'claude-code'
519
+ type PresetId = typeof presetIds[number]
520
+
521
+ async function bootProducts(installed: readonly Product[]): Promise<Context> {
522
+ const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-'))
523
+ const userRoot = join(root, 'presets')
524
+ const settingsFile = join(root, 'settings.yaml')
525
+ const standard = await readFile(join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml'), 'utf8')
526
+ await writeFile(settingsFile, '{}\n')
527
+ for (const id of presetIds) {
528
+ let composition = standard
529
+ if (id === 'products-codex' || id === 'products-both') {
530
+ composition = enablePresetTool(composition, 'tool-subagent-codex')
531
+ }
532
+ if (id === 'products-claude' || id === 'products-both') {
533
+ composition = enablePresetTool(composition, 'tool-subagent-claude-code')
534
+ }
535
+ const directory = join(userRoot, id)
536
+ await mkdir(directory, { recursive: true })
537
+ await writeFile(join(directory, 'agent.cordis.yml'), composition)
538
+ }
539
+ const packageDir = (product: Product): string => (
540
+ product === 'codex' ? CODEX_PACKAGE_DIR : CLAUDE_CODE_PACKAGE_DIR
541
+ )
542
+ const packageName = (product: Product): string => (
543
+ product === 'codex'
544
+ ? '@deepseek-ai/dsh-subagent-codex'
545
+ : '@deepseek-ai/dsh-subagent-claude-code'
546
+ )
547
+ return await bootWeb(settingsFile, [
548
+ {
549
+ id: 'agent-presets',
550
+ config: {
551
+ default: 'standard',
552
+ // The shipped root is the plugin's own, prepended before this.
553
+ roots: [{ path: userRoot, trust: 'user' }],
554
+ includeUserRoot: false,
555
+ },
556
+ },
557
+ ], installed.map(packageDir), [
558
+ '@deepseek-ai/dsh-base',
559
+ '@deepseek-ai/dsh-web-app',
560
+ ...installed.map(packageName),
561
+ ])
562
+ }
563
+
564
+ it('composes the intersection of installed Bundles and enabled preset rows', async () => {
565
+ const enabledByPreset: Record<PresetId, Product[]> = {
566
+ 'products-none': [],
567
+ 'products-codex': ['codex'],
568
+ 'products-claude': ['claude-code'],
569
+ 'products-both': ['codex', 'claude-code'],
570
+ }
571
+ const scenarios: Array<{ installed: Product[]; presets: readonly PresetId[] }> = [
572
+ { installed: [], presets: ['products-both'] },
573
+ { installed: ['codex'], presets: ['products-both'] },
574
+ { installed: ['claude-code'], presets: ['products-both'] },
575
+ { installed: ['codex', 'claude-code'], presets: presetIds },
576
+ ]
577
+
578
+ for (const { installed, presets } of scenarios) {
579
+ const productCtx = await bootProducts(installed)
580
+ const spawn = vi.spyOn(productCtx.subprocess, 'spawn')
581
+ try {
582
+ expect(productCtx.subagents.list()
583
+ .filter(name => name === 'codex' || name === 'claude-code')
584
+ .sort())
585
+ .toEqual([...installed].sort())
586
+ for (const id of presets) {
587
+ const handle = await productCtx.agents.create({
588
+ sessionId: SessionId(`preset-${id}-${installed.join('-') || 'none'}-${randomUUID()}`),
589
+ setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined),
590
+ })
591
+ try {
592
+ const productTools = enabledByPreset[id]
593
+ .filter(product => installed.includes(product))
594
+ .map(product => product === 'codex' ? 'subagent_codex' : 'subagent_claude_code')
595
+ .sort()
596
+ const tools = toolNames(productCtx, handle.agent)
597
+ expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code'))
598
+ .toEqual(productTools)
599
+ expect(tools).toEqual(expect.arrayContaining(['job_kill', 'job_list', 'job_output']))
600
+ for (const productTool of productTools) {
601
+ expect(toolParameterNames(productCtx, handle.agent, productTool)).toEqual([
602
+ 'description', 'prompt', 'run_in_background',
603
+ ])
604
+ }
605
+ } finally {
606
+ await handle.dispose()
607
+ }
608
+ }
609
+ expect(spawn).not.toHaveBeenCalled()
610
+ } finally {
611
+ spawn.mockRestore()
612
+ await productCtx.fiber.dispose()
613
+ }
614
+ }
615
+ }, 120_000)
616
+
617
+ it('applies a product-row edit only to later sessions on the preset', async () => {
618
+ const productCtx = await bootProducts(['codex'])
619
+ const preset = await productCtx.agentPresets.resolve('products-none')
620
+ const original = await readFile(preset.path, 'utf8')
621
+ const existing = await productCtx.agents.create({
622
+ sessionId: SessionId('preset-product-generation-existing'),
623
+ setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
624
+ })
625
+ try {
626
+ expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
627
+ await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex'))
628
+
629
+ const later = await productCtx.agents.create({
630
+ sessionId: SessionId('preset-product-generation-later'),
631
+ setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined),
632
+ })
633
+ try {
634
+ expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex')
635
+ expect(toolNames(productCtx, later.agent)).toContain('subagent_codex')
636
+ } finally {
637
+ await later.dispose()
638
+ }
639
+ } finally {
640
+ await existing.dispose()
641
+ await writeFile(preset.path, original)
642
+ await productCtx.fiber.dispose()
643
+ }
644
+ }, 120_000)
645
+ })
646
+
647
+ describe('a switch survives the session', () => {
648
+ it('records the choice so the log states what the agent runs', async () => {
649
+ const handle = await ctx.agents.create({
650
+ sessionId: SessionId('preset-switch-logged'),
651
+ meta: { agentPreset: 'standard' },
652
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
653
+ })
654
+ try {
655
+ // The api-proxy's select does exactly this pair while the session is blank.
656
+ expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined()
657
+ await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')
658
+ handle.agent.session.append('agent-preset/selected', { agentPreset: 'minimal' })
659
+ expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
660
+
661
+ // The header keeps the creation fact; the log carries what it runs.
662
+ expect(handle.agent.session.header.agentPreset).toBe('standard')
663
+ expect(ctx.sessionProjections.stateOf(handle.agent.session, 'agentPreset')).toBe('minimal')
664
+ } finally {
665
+ await handle.dispose()
666
+ }
667
+ })
668
+
669
+ })
670
+
671
+ describe('a forked session', () => {
672
+ it('inherits the composition its seeded history was produced under', async () => {
673
+ const parent = await ctx.agents.create({
674
+ sessionId: SessionId('preset-fork-parent'),
675
+ meta: { agentPreset: 'minimal' },
676
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
677
+ })
678
+ const inherited = ctx.sessionProjections.stateOf(parent.agent.session, 'agentPreset') ?? undefined
679
+ const child = await ctx.agents.create({
680
+ sessionId: SessionId('preset-fork-child'),
681
+ seed: [],
682
+ inheritedEventCount: SessionLogOffset(0),
683
+ meta: {
684
+ parentSession: SessionId('preset-fork-parent'),
685
+ isSeeded: true,
686
+ ...inherited === undefined ? {} : { agentPreset: inherited },
687
+ },
688
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined),
689
+ })
690
+ try {
691
+ // Composing nothing would leave the child empty: this layer moved every
692
+ // model-facing row out of the host plane, so there is nothing to inherit
693
+ // for free any more.
694
+ expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
695
+ expect(toolNames(ctx, child.agent).length).toBeGreaterThan(0)
696
+ } finally {
697
+ await child.dispose()
698
+ await parent.dispose()
699
+ }
700
+ })
701
+ })
702
+
703
+ describe('a delegated child', () => {
704
+ it('runs on the composition its parent runs on', async () => {
705
+ const parent = await ctx.agents.create({
706
+ sessionId: SessionId('preset-child-parent'),
707
+ meta: { agentPreset: 'standard' },
708
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
709
+ })
710
+ // Exactly what an in-process subagent driver's creation window does.
711
+ const child = await parent.agent.ctx.agents.create({
712
+ sessionId: SessionId('preset-child'),
713
+ meta: childSessionMeta(parent.agent, 1, false),
714
+ setup: (agentCtx) => {
715
+ applyChildComposition(agentCtx, parent.agent, {})
716
+ },
717
+ })
718
+ try {
719
+ expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
720
+ // The shipped `standard` preset is the whole coding agent; an empty
721
+ // child here is the defect, and equality alone would not catch it.
722
+ expect(toolNames(ctx, child.agent)).toContain('bash')
723
+ expect(child.agent.session.header.agentPreset).toBe('standard')
724
+ } finally {
725
+ await child.dispose()
726
+ await parent.dispose()
727
+ }
728
+ })
729
+
730
+ it('follows a parent that switched preset while blank', async () => {
731
+ const parent = await ctx.agents.create({
732
+ sessionId: SessionId('preset-child-switch-parent'),
733
+ meta: { agentPreset: 'standard' },
734
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
735
+ })
736
+ await ctx.agentPresets.recompose(parent.agent.ctx, 'minimal')
737
+ const child = await parent.agent.ctx.agents.create({
738
+ sessionId: SessionId('preset-child-switch'),
739
+ meta: childSessionMeta(parent.agent, 1, false),
740
+ setup: (agentCtx) => {
741
+ applyChildComposition(agentCtx, parent.agent, {})
742
+ },
743
+ })
744
+ try {
745
+ // The live scope chain is the authority, not the parent's creation
746
+ // header — which still names `standard`.
747
+ expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
748
+ expect(child.agent.session.header.agentPreset).toBe('minimal')
749
+ } finally {
750
+ await child.dispose()
751
+ await parent.dispose()
752
+ }
753
+ })
754
+ })
755
+
756
+ describe('a launcher that configures no writable root', () => {
757
+ // The claim this default exists for, asserted through the real shipped
758
+ // bundles rather than a hand-built context: `apps/cli` patches in only the
759
+ // system root, and a person's own presets are found anyway because the
760
+ // roster derives `<dshHome>/.agent-presets` itself. `$DSH_HOME` is pointed
761
+ // at a temp home BEFORE boot — the derived root is resolved when the plugin
762
+ // is constructed, and an unpinned run would read the developer's own.
763
+ let derivedCtx: Context
764
+ let previousHome: string | undefined
765
+
766
+ beforeAll(async () => {
767
+ const home = await mkdtemp(join(tmpdir(), 'dsh-preset-derived-'))
768
+ previousHome = process.env.DSH_HOME
769
+ process.env.DSH_HOME = home
770
+ await mkdir(join(home, '.agent-presets', 'derived-mine'), { recursive: true })
771
+ await writeFile(
772
+ join(home, '.agent-presets', 'derived-mine', 'agent.cordis.yml'),
773
+ '- id: tool-todo\n name: \'@deepseek-ai/dsh-tool-todo\'\n config:\n allowParallelInProgress: true\n',
774
+ )
775
+ const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml')
776
+ await writeFile(settingsFile, '{}\n')
777
+ // No configured roots: the shipped one is the plugin's own, and the
778
+ // writable one is the roster's own default rather than this patch's job.
779
+ derivedCtx = await bootWeb(settingsFile, [{
780
+ id: 'agent-presets',
781
+ config: { default: 'standard', includeUserRoot: true },
782
+ }])
783
+ }, 120_000)
784
+
785
+ afterAll(async () => {
786
+ if (previousHome === undefined) delete process.env.DSH_HOME
787
+ else process.env.DSH_HOME = previousHome
788
+ await derivedCtx.fiber.dispose()
789
+ })
790
+
791
+ it('discovers and mounts a preset the person authored under the harness home', async () => {
792
+ const listed = await derivedCtx.agentPresets.list()
793
+
794
+ const mine = listed.find(preset => preset.id === 'derived-mine')
795
+ expect(mine).toMatchObject({ trust: 'user' })
796
+ // Omitted rather than undefined: a healthy row carries no `broken` key.
797
+ expect(mine?.broken).toBeUndefined()
798
+ expect(derivedCtx.agentPresets.authorable).toBe(true)
799
+
800
+ const handle = await derivedCtx.agents.create({
801
+ sessionId: SessionId('preset-derived-root'),
802
+ setup: agentCtx => derivedCtx.agentPresets.mount(agentCtx, 'derived-mine').then(() => undefined),
803
+ })
804
+ try {
805
+ expect(toolNames(derivedCtx, handle.agent)).toContain('todo_write')
806
+ } finally {
807
+ await handle.dispose()
808
+ }
809
+ })
810
+ })
811
+
812
+ describe('authoring a preset on the shipped composition', () => {
813
+ let authorCtx: Context
814
+ let userRoot: string
815
+
816
+ beforeAll(async () => {
817
+ userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'profiles')
818
+ const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml')
819
+ await writeFile(settingsFile, '{}\n')
820
+ authorCtx = await bootWeb(settingsFile, [{
821
+ id: 'agent-presets',
822
+ config: {
823
+ default: 'standard',
824
+ // The root does not exist yet: a deployment whose user has authored
825
+ // nothing is the normal first-run state. The shipped root is the
826
+ // plugin's own, prepended before this.
827
+ roots: [{ path: userRoot, trust: 'user' }],
828
+ includeUserRoot: false,
829
+ },
830
+ }])
831
+ })
832
+
833
+ it('refuses to copy over or delete a shipped preset', async () => {
834
+ await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/)
835
+ await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/)
836
+ })
837
+
838
+ it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => {
839
+ // The id becomes a directory name under the user root, so containment is
840
+ // checked on the id rather than on the joined path afterwards.
841
+ await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow()
842
+ })
843
+
844
+ it('copies a shipped preset a session then really composes from', async () => {
845
+ await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式')
846
+
847
+ // Round-trips through the roster as a `user` row carrying the given name
848
+ // and the source's description, over the source's own composition text.
849
+ const preset = await authorCtx.agentPresets.resolve('my-agent')
850
+ const source = await authorCtx.agentPresets.resolve('minimal')
851
+ expect(preset.trust).toBe('user')
852
+ expect(preset.name).toBe('我的模式')
853
+ expect(preset.description).toBe(source.description)
854
+ expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal'))
855
+ // Owner-only, in an owner-only directory: a composition is executable
856
+ // configuration on a machine that may have other users.
857
+ expect((await stat(preset.path)).mode & 0o777).toBe(0o600)
858
+ const handle = await authorCtx.agents.create({
859
+ sessionId: SessionId('preset-authored'),
860
+ setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined),
861
+ })
862
+ try {
863
+ // The same tools the shipped `minimal` composes, from a directory copied
864
+ // through the service into a root outside the installed harness.
865
+ expect(toolNames(authorCtx, handle.agent)).toEqual(['bash'])
866
+ } finally {
867
+ await handle.dispose()
868
+ }
869
+ })
870
+
871
+ it('deletes what it copied', async () => {
872
+ await authorCtx.agentPresets.copy('minimal', 'doomed')
873
+
874
+ await authorCtx.agentPresets.remove('doomed')
875
+
876
+ expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed')
877
+ })
878
+ })
879
+
880
+ /**
881
+ * Which preset an unnamed session gets is a user setting layered over the
882
+ * composition's own default. The package suite proves the layering against a
883
+ * hand-built context; this proves it through the shipped `cordis.yml` — that
884
+ * the roster and the settings provider are actually wired to each other, and
885
+ * that the id the setting names is the one a session composes from.
886
+ */
887
+ describe('the default preset as a user setting', () => {
888
+ it('composes an unnamed session from the stored default, not the composed one', async () => {
889
+ expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true)
890
+ expect(ctx.agentPresets.defaultId).toBe('standard')
891
+
892
+ await ctx.settings.update(SETTINGS_NAMESPACE, { default: 'minimal' })
893
+ try {
894
+ expect(ctx.agentPresets.defaultId).toBe('minimal')
895
+
896
+ const handle = await ctx.agents.create({
897
+ sessionId: SessionId('preset-user-default'),
898
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
899
+ })
900
+ try {
901
+ // `mount()` with no id resolves the effective default. One tool, not
902
+ // `standard`'s catalog: the setting decided the composition.
903
+ expect(toolNames(ctx, handle.agent)).toEqual(['bash'])
904
+ } finally {
905
+ await handle.dispose()
906
+ }
907
+ } finally {
908
+ // The context is shared with the rest of the file. `replace({})` drops
909
+ // the user section wholesale so the field re-inherits the composition
910
+ // base; `update` merges, and would leave the override standing.
911
+ await ctx.settings.replace(SETTINGS_NAMESPACE, {})
912
+ }
913
+
914
+ expect(ctx.agentPresets.defaultId).toBe('standard')
915
+ })
916
+ })
917
+
918
+ describe('a session keeps the preset it was created with', () => {
919
+ it('refuses to adopt a live session under a different preset', async () => {
920
+ const handle = await ctx.agents.create({
921
+ sessionId: SessionId('preset-locked'),
922
+ meta: { agentPreset: 'minimal' },
923
+ setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
924
+ })
925
+ try {
926
+ // The api-proxy guard reads exactly this: the header records what the
927
+ // session runs, so naming anything else is a caller error rather than a
928
+ // switch. Its history was produced under `minimal`'s single tool.
929
+ expect(handle.agent.session.header.agentPreset).toBe('minimal')
930
+ } finally {
931
+ await handle.dispose()
932
+ }
933
+ })
934
+ })
935
+
936
+ describe('a composition that configures its own preset roots', () => {
937
+ let rootsCtx: Context
938
+ let teamRoot: string
939
+
940
+ beforeAll(async () => {
941
+ const home = await mkdtemp(join(tmpdir(), 'dsh-preset-roots-'))
942
+ const settingsFile = join(home, 'settings.yaml')
943
+ await writeFile(settingsFile, '{}\n')
944
+ // A workspace-shared root beside the deployment: one preset of its own,
945
+ // plus a directory that claims a shipped id.
946
+ teamRoot = join(home, 'team-presets')
947
+ const minimalComposition = await readFile(join(SHIPPED_PRESET_ROOT, 'minimal', 'agent.cordis.yml'), 'utf8')
948
+ for (const id of ['team-spec', 'minimal']) {
949
+ await mkdir(join(teamRoot, id), { recursive: true })
950
+ await writeFile(join(teamRoot, id, 'agent.cordis.yml'), minimalComposition)
951
+ }
952
+ // The user layer of the reported regression: a profile's cordis.patch.yml
953
+ // configuring a shared preset root. The plugin must EXTEND it with its
954
+ // own shipped root, never lose it.
955
+ rootsCtx = await bootWeb(settingsFile, [{
956
+ id: 'agent-presets',
957
+ config: {
958
+ default: 'standard',
959
+ roots: [{ path: teamRoot, trust: 'user' }],
960
+ includeUserRoot: false,
961
+ },
962
+ }])
963
+ }, 120_000)
964
+
965
+ afterAll(async () => {
966
+ await rootsCtx.fiber.dispose()
967
+ })
968
+
969
+ it('keeps configured roots alongside the always-prepended shipped root', async () => {
970
+ expect(rootsCtx.agentPresets.roots.map(root => root.path)).toEqual([
971
+ SHIPPED_PRESET_ROOT,
972
+ teamRoot,
973
+ ])
974
+
975
+ const listed = await rootsCtx.agentPresets.list()
976
+ expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard', 'team-spec'])
977
+ expect(listed.every(preset => preset.broken === undefined)).toBe(true)
978
+ // The shipped root comes first: a configured directory claiming a shipped
979
+ // id is shadowed, never the other way around.
980
+ expect(listed.find(preset => preset.id === 'minimal')?.trust).toBe('system')
981
+ expect(listed.find(preset => preset.id === 'team-spec')?.trust).toBe('user')
982
+ })
983
+
984
+ it('composes an agent from a configured-root preset', async () => {
985
+ const handle = await rootsCtx.agents.create({
986
+ sessionId: SessionId('preset-team-spec'),
987
+ setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined),
988
+ })
989
+ try {
990
+ expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash'])
991
+ } finally {
992
+ await handle.dispose()
993
+ }
994
+ })
995
+ })
apps/cli/tests/web-auth.e2e.ts ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Real `dsh web` authentication against a temporary Harness home. */
2
+
3
+ import type { ChildProcess } from 'node:child_process'
4
+ import { spawn } from 'node:child_process'
5
+ import { stat } from 'node:fs/promises'
6
+ import { request as httpRequest } from 'node:http'
7
+ import { createRequire } from 'node:module'
8
+ import { createServer } from 'node:net'
9
+ import type { AddressInfo } from 'node:net'
10
+ import { mkdtemp, rm } from 'node:fs/promises'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { fileURLToPath, pathToFileURL } from 'node:url'
14
+ import { describe, expect, it } from 'vitest'
15
+
16
+ const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
17
+ const DSH_SOURCE_BIN = join(REPO_ROOT, 'apps/cli/src/bin.ts')
18
+ const TSX_LOADER = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
19
+
20
+ interface RunningWeb {
21
+ readonly child: ChildProcess
22
+ readonly launchUrl: string
23
+ readonly output: () => string
24
+ }
25
+
26
+ interface HttpResult {
27
+ readonly status: number
28
+ readonly body: string
29
+ }
30
+
31
+ function redact(output: string): string {
32
+ return output.replace(/([?&]token=)[^\s)]+/gu, '$1<redacted>')
33
+ }
34
+
35
+ /** Reserve one concrete loopback port, then release it for the CLI process. */
36
+ async function freePort(): Promise<number> {
37
+ const server = createServer()
38
+ await new Promise<void>((resolve, reject) => {
39
+ server.once('error', reject)
40
+ server.listen(0, '127.0.0.1', resolve)
41
+ })
42
+ const port = (server.address() as AddressInfo).port
43
+ await new Promise<void>((resolve, reject) => {
44
+ server.close((error) => {
45
+ if (error === undefined) resolve()
46
+ else reject(error)
47
+ })
48
+ })
49
+ return port
50
+ }
51
+
52
+ function cleanEnvironment(root: string, dshHome: string): NodeJS.ProcessEnv {
53
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
54
+ !/(?:KEY|SECRET|TOKEN|PASSWORD)/iu.test(name)))
55
+ return {
56
+ ...env,
57
+ DSH_AGENTS_HOME: join(root, '.agents'),
58
+ DSH_HOME: dshHome,
59
+ DSH_TELEMETRY_DISABLED: '1',
60
+ NODE_NO_WARNINGS: '1',
61
+ SSH_CONNECTION: '',
62
+ SSH_TTY: '',
63
+ TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
64
+ }
65
+ }
66
+
67
+ /** Start the public source CLI and wait for its authenticated readiness URL. */
68
+ async function startWeb(root: string, dshHome: string, port: number): Promise<RunningWeb> {
69
+ const child = spawn(process.execPath, [
70
+ '--import', TSX_LOADER,
71
+ DSH_SOURCE_BIN,
72
+ 'web',
73
+ '--no-open',
74
+ '--port', String(port),
75
+ ], {
76
+ cwd: root,
77
+ env: cleanEnvironment(root, dshHome),
78
+ stdio: ['ignore', 'pipe', 'pipe'],
79
+ })
80
+ let output = ''
81
+ const launchUrl = await new Promise<string>((resolve, reject) => {
82
+ let settled = false
83
+ const fail = (error: Error): void => {
84
+ if (settled) return
85
+ settled = true
86
+ clearTimeout(timer)
87
+ reject(error)
88
+ }
89
+ const timer = setTimeout(() => {
90
+ fail(new Error(`dsh web did not become ready:\n${redact(output)}`))
91
+ }, 90_000)
92
+ const append = (chunk: Buffer | string): void => {
93
+ output = `${output}${String(chunk)}`.slice(-100_000)
94
+ const match = /dsh web: (http:\/\/[^\s]+)/u.exec(output)
95
+ if (settled || match?.[1] === undefined) return
96
+ settled = true
97
+ clearTimeout(timer)
98
+ resolve(match[1])
99
+ }
100
+ child.stdout?.on('data', append)
101
+ child.stderr?.on('data', append)
102
+ child.once('error', (error) => {
103
+ fail(error)
104
+ })
105
+ child.once('exit', (code) => {
106
+ fail(new Error(`dsh web exited before readiness (${String(code)}):\n${redact(output)}`))
107
+ })
108
+ })
109
+ return { child, launchUrl, output: () => output }
110
+ }
111
+
112
+ async function stopWeb(running: RunningWeb): Promise<void> {
113
+ if (running.child.exitCode !== null) return
114
+ const exited = new Promise<void>((resolve) => { running.child.once('exit', () => { resolve() }) })
115
+ running.child.kill('SIGTERM')
116
+ const forced = setTimeout(() => { running.child.kill('SIGKILL') }, 10_000)
117
+ forced.unref()
118
+ await exited
119
+ clearTimeout(forced)
120
+ }
121
+
122
+ /** POST one real Remote envelope while controlling the wire Host header. */
123
+ function describeSettings(port: number, host: string, cookie?: string): Promise<HttpResult> {
124
+ const body = JSON.stringify({
125
+ type: 'client-request',
126
+ rpcId: 'web-auth-real-cli',
127
+ method: 'settings/describe',
128
+ payload: { args: {} },
129
+ })
130
+ return new Promise((resolve, reject) => {
131
+ const req = httpRequest({
132
+ hostname: '127.0.0.1',
133
+ port,
134
+ path: '/api/settings/describe',
135
+ method: 'POST',
136
+ headers: {
137
+ host,
138
+ 'content-type': 'application/json',
139
+ 'content-length': Buffer.byteLength(body),
140
+ ...cookie === undefined ? {} : { cookie },
141
+ },
142
+ }, (res) => {
143
+ const chunks: Uint8Array[] = []
144
+ res.on('data', (chunk: Buffer) => { chunks.push(chunk) })
145
+ res.on('end', () => {
146
+ resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })
147
+ })
148
+ })
149
+ req.once('error', reject)
150
+ req.end(body)
151
+ })
152
+ }
153
+
154
+ describe('dsh web authentication through the real CLI', () => {
155
+ it('rejects a forged loopback Host and preserves the browser cookie across restart', { timeout: 180_000 }, async () => {
156
+ const root = await mkdtemp(join(tmpdir(), 'dsh-web-auth-real-cli-'))
157
+ const dshHome = join(root, '.dsh')
158
+ const port = await freePort()
159
+ let first: RunningWeb | undefined
160
+ let second: RunningWeb | undefined
161
+ try {
162
+ first = await startWeb(root, dshHome, port)
163
+ const firstUrl = new URL(first.launchUrl)
164
+ expect(firstUrl.origin).toBe(`http://127.0.0.1:${String(port)}`)
165
+ expect(firstUrl.pathname).toBe('/')
166
+ expect(firstUrl.searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{43}$/u)
167
+
168
+ expect(await describeSettings(port, `localhost:${String(port)}`)).toEqual({
169
+ status: 401,
170
+ body: 'unauthorized',
171
+ })
172
+
173
+ const exchange = await fetch(first.launchUrl, { redirect: 'manual' })
174
+ expect(exchange.status).toBe(303)
175
+ expect(exchange.headers.get('location')).toBe('/')
176
+ const setCookie = exchange.headers.get('set-cookie')
177
+ if (setCookie === null) throw new Error('real CLI token exchange omitted Set-Cookie')
178
+ expect(setCookie).toContain('HttpOnly')
179
+ expect(setCookie).toContain('SameSite=Strict')
180
+ expect(setCookie).not.toContain('Secure')
181
+ const cookie = setCookie.split(';', 1)[0]!
182
+
183
+ const authenticated = await describeSettings(port, firstUrl.host, cookie)
184
+ expect(authenticated.status).toBe(200)
185
+ const authenticatedBody = JSON.parse(authenticated.body) as unknown
186
+ expect(authenticatedBody).toMatchObject({
187
+ type: 'server-response',
188
+ rpcId: 'web-auth-real-cli',
189
+ result: { ok: true, value: { namespaces: expect.any(Array) as unknown } },
190
+ })
191
+
192
+ await stopWeb(first)
193
+ first = undefined
194
+ second = await startWeb(root, dshHome, port)
195
+ const secondUrl = new URL(second.launchUrl)
196
+ expect(secondUrl.searchParams.get('token')).not.toBe(firstUrl.searchParams.get('token'))
197
+ expect((await describeSettings(port, secondUrl.host, cookie)).status).toBe(200)
198
+
199
+ const credentialMode = (await stat(join(dshHome, '.credentials.yaml'))).mode & 0o777
200
+ expect(credentialMode).toBe(0o600)
201
+ } catch (error) {
202
+ const evidence = [first?.output(), second?.output()].filter(value => value !== undefined).join('\n')
203
+ throw new Error(`${error instanceof Error ? error.message : String(error)}\n${redact(evidence)}`, { cause: error })
204
+ } finally {
205
+ if (second !== undefined) await stopWeb(second)
206
+ if (first !== undefined) await stopWeb(first)
207
+ await rm(root, { recursive: true, force: true })
208
+ }
209
+ })
210
+ })
apps/cli/tests/web-browser-open.expected.e2e.ts ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Assembled keyless snapshot for the default `dsh web` browser handoff. */
2
+
3
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { execa } from 'execa'
8
+ import { afterEach, describe, expect, it } from 'vitest'
9
+
10
+ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
11
+ const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
12
+ const frontendIndex = join(repoRoot, 'apps/web/dist/index.html')
13
+ const openerHook = new URL('./fixtures/web-browser-open/register.mjs', import.meta.url).href
14
+ const openingMessage = 'dsh web: opening the default browser; pass --no-open to disable'
15
+ const tempRoots: string[] = []
16
+ const builtArtifactsExist = existsSync(builtBin) && existsSync(frontendIndex)
17
+
18
+ if (process.env.DSH_EXAMPLE_MODE === 'lib' && !builtArtifactsExist) {
19
+ throw new Error('dsh web browser-open snapshot requires built CLI and Web artifacts in lib mode')
20
+ }
21
+
22
+ afterEach(() => {
23
+ for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
24
+ })
25
+
26
+ interface BrowserOpenRecord {
27
+ url: string
28
+ status: number
29
+ bootManifest: boolean
30
+ apiKeyPresent: boolean
31
+ dshHomePresent: boolean
32
+ }
33
+
34
+ function normalizeLocalUrl(url: string): string {
35
+ return url
36
+ .replace(/:\d+/u, ':{{port}}')
37
+ .replace(/token=[^&]+/u, 'token={{token}}')
38
+ }
39
+
40
+ describe.skipIf(!builtArtifactsExist)('dsh web browser-open assembled snapshot', () => {
41
+ it('hands the reachable page to the default browser after the shipped tree settles', async () => {
42
+ const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-snapshot-'))
43
+ tempRoots.push(root)
44
+ const result = await execa(process.execPath, [
45
+ '--import', openerHook,
46
+ builtBin,
47
+ 'web',
48
+ '--port', '0',
49
+ ], {
50
+ cwd: root,
51
+ env: {
52
+ ...process.env,
53
+ DEEPSEEK_API_KEY: 'keyless-browser-open-no-call',
54
+ DSH_AGENTS_HOME: join(root, '.agents'),
55
+ DSH_HOME: join(root, '.dsh'),
56
+ DSH_TELEMETRY_DISABLED: '1',
57
+ NODE_NO_WARNINGS: '1',
58
+ SSH_CONNECTION: '',
59
+ SSH_TTY: '',
60
+ },
61
+ input: '',
62
+ timeout: 30_000,
63
+ killSignal: 'SIGKILL',
64
+ reject: false,
65
+ })
66
+ const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1]
67
+ const openLine = result.stdout.split('\n').find(line => line.startsWith('dsh browser-open: '))
68
+ const opening = result.stdout.includes(openingMessage)
69
+ if (readyUrl === undefined || openLine === undefined || !opening) {
70
+ throw new Error(`dsh web browser-open evidence missing\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
71
+ }
72
+ const opened = JSON.parse(openLine.slice('dsh browser-open: '.length)) as BrowserOpenRecord
73
+
74
+ expect({
75
+ exitCode: result.exitCode,
76
+ opening,
77
+ readyUrl: normalizeLocalUrl(readyUrl),
78
+ openedUrl: normalizeLocalUrl(opened.url),
79
+ status: opened.status,
80
+ bootManifest: opened.bootManifest,
81
+ apiKeyPresent: opened.apiKeyPresent,
82
+ dshHomePresent: opened.dshHomePresent,
83
+ stderr: result.stderr,
84
+ }).toMatchInlineSnapshot(`
85
+ {
86
+ "apiKeyPresent": false,
87
+ "bootManifest": true,
88
+ "dshHomePresent": false,
89
+ "exitCode": 0,
90
+ "openedUrl": "http://127.0.0.1:{{port}}/?token={{token}}",
91
+ "opening": true,
92
+ "readyUrl": "http://127.0.0.1:{{port}}/?token={{token}}",
93
+ "status": 200,
94
+ "stderr": "",
95
+ }
96
+ `)
97
+ })
98
+
99
+ it('prints the launcher reason and manual URL after the Web app is ready', async () => {
100
+ const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-failure-snapshot-'))
101
+ tempRoots.push(root)
102
+ const result = await execa(process.execPath, [
103
+ '--import', openerHook,
104
+ builtBin,
105
+ 'web',
106
+ '--port', '0',
107
+ ], {
108
+ cwd: root,
109
+ env: {
110
+ ...process.env,
111
+ BROWSER_OPEN_TEST_FAILURE: 'fixture desktop unavailable',
112
+ DEEPSEEK_API_KEY: 'keyless-browser-open-no-call',
113
+ DSH_AGENTS_HOME: join(root, '.agents'),
114
+ DSH_BROWSER_OPEN_TEST_EXIT_ON_FAILURE: '1',
115
+ DSH_HOME: join(root, '.dsh'),
116
+ DSH_TELEMETRY_DISABLED: '1',
117
+ NODE_NO_WARNINGS: '1',
118
+ SSH_CONNECTION: '',
119
+ SSH_TTY: '',
120
+ },
121
+ input: '',
122
+ timeout: 30_000,
123
+ killSignal: 'SIGKILL',
124
+ reject: false,
125
+ })
126
+ const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1]
127
+ const diagnostic = result.stderr.split(/\r?\n/u)
128
+ .find(line => line.startsWith('web-app: could not open the default browser because '))
129
+
130
+ expect({
131
+ diagnostic,
132
+ exitCode: result.exitCode,
133
+ opened: result.stdout.includes('dsh browser-open: '),
134
+ opening: result.stdout.includes(openingMessage),
135
+ readyUrl: readyUrl === undefined ? undefined : normalizeLocalUrl(readyUrl),
136
+ }).toMatchInlineSnapshot(`
137
+ {
138
+ "diagnostic": "web-app: could not open the default browser because fixture desktop unavailable; use the dsh web URL printed at startup",
139
+ "exitCode": 0,
140
+ "opened": false,
141
+ "opening": true,
142
+ "readyUrl": "http://127.0.0.1:{{port}}/?token={{token}}",
143
+ }
144
+ `)
145
+ })
146
+
147
+ it('prints the host URL without launching a browser in a VS Code Remote SSH session', async () => {
148
+ const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-ssh-snapshot-'))
149
+ tempRoots.push(root)
150
+ const result = await execa(process.execPath, [
151
+ '--import', openerHook,
152
+ builtBin,
153
+ 'web',
154
+ '--port', '0',
155
+ ], {
156
+ cwd: root,
157
+ env: {
158
+ ...process.env,
159
+ DEEPSEEK_API_KEY: 'keyless-browser-open-no-call',
160
+ DSH_AGENTS_HOME: join(root, '.agents'),
161
+ DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1',
162
+ DSH_HOME: join(root, '.dsh'),
163
+ DSH_TELEMETRY_DISABLED: '1',
164
+ NODE_NO_WARNINGS: '1',
165
+ SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22',
166
+ SSH_TTY: '',
167
+ VSCODE_IPC_HOOK_CLI: '/tmp/vscode-ipc',
168
+ },
169
+ input: '',
170
+ timeout: 30_000,
171
+ killSignal: 'SIGKILL',
172
+ reject: false,
173
+ })
174
+ const readyUrl = /dsh web: (http:\/\/[^\s]+)/u.exec(result.stdout)?.[1]
175
+
176
+ expect({
177
+ exitCode: result.exitCode,
178
+ opening: result.stdout.includes(openingMessage),
179
+ readyUrl: readyUrl === undefined ? undefined : normalizeLocalUrl(readyUrl),
180
+ opened: result.stdout.includes('dsh browser-open: '),
181
+ stderr: result.stderr,
182
+ }).toMatchInlineSnapshot(`
183
+ {
184
+ "exitCode": 0,
185
+ "opened": false,
186
+ "opening": false,
187
+ "readyUrl": "http://127.0.0.1:{{port}}/?token={{token}}",
188
+ "stderr": "",
189
+ }
190
+ `)
191
+ })
192
+
193
+ it('rejects a project browser command before starting the Web app', async () => {
194
+ const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-env-snapshot-'))
195
+ tempRoots.push(root)
196
+ writeFileSync(join(root, '.env'), 'BROWSER=./project-browser\n')
197
+ const result = await execa(process.execPath, [
198
+ '--import', openerHook,
199
+ builtBin,
200
+ 'web',
201
+ '--port', '0',
202
+ ], {
203
+ cwd: root,
204
+ env: {
205
+ ...process.env,
206
+ DEEPSEEK_API_KEY: 'keyless-browser-open-no-call',
207
+ DSH_AGENTS_HOME: join(root, '.agents'),
208
+ DSH_HOME: join(root, '.dsh'),
209
+ DSH_TELEMETRY_DISABLED: '1',
210
+ NODE_NO_WARNINGS: '1',
211
+ SSH_CONNECTION: '',
212
+ SSH_TTY: '',
213
+ },
214
+ input: '',
215
+ timeout: 30_000,
216
+ killSignal: 'SIGKILL',
217
+ reject: false,
218
+ })
219
+
220
+ const diagnostic = result.stderr.split(/\r?\n/u)
221
+ .find(line => line.startsWith('Error: dsh: '))
222
+ ?.replace(/^Error: dsh: .*[/\\]\.env/u, 'dsh: {{root}}/.env')
223
+
224
+ expect({
225
+ diagnostic,
226
+ exitCode: result.exitCode,
227
+ opening: result.stdout.includes(openingMessage),
228
+ opened: result.stdout.includes('dsh browser-open: '),
229
+ ready: result.stdout.includes('dsh web: '),
230
+ }).toMatchInlineSnapshot(`
231
+ {
232
+ "diagnostic": "dsh: {{root}}/.env sets "BROWSER", which only the launching environment may set (it decides how this process starts, where its code and instructions load from, or how it reaches the network); export BROWSER instead of putting it in a .env file",
233
+ "exitCode": 1,
234
+ "opened": false,
235
+ "opening": false,
236
+ "ready": false,
237
+ }
238
+ `)
239
+ })
240
+ })
apps/cli/tests/windows-shell.spec.ts ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * The shipped shell composition: the base bundle gates both shell stacks by
3
+ * platform on its own rows (`disabled: !!js process.platform`), so exactly
4
+ * one shell stack mounts per host and no separate platform layer exists —
5
+ * the launcher applies nothing beyond the bundle layers. The spec composes
6
+ * the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the
7
+ * app installation anchor) through the boot's patch algorithm and pins the
8
+ * effective per-platform roster, the preset-level gates that keep tool-bash
9
+ * out of win32 sessions and tool-pwsh out of POSIX sessions, and the
10
+ * cold-start resolution closure for the pwsh rows' bare plugin names.
11
+ */
12
+
13
+ import { afterEach, describe, expect, it } from 'vitest'
14
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+ import yaml from 'js-yaml'
19
+ import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
20
+ import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
21
+ import { SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets'
22
+ import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot'
23
+
24
+ /**
25
+ * The effective disabled state of one row on one platform: a `!!js` expression
26
+ * evaluates with a platform-scoped `process` so both outcomes pin on any host.
27
+ */
28
+ function disabledOn(row: { disabled?: unknown }, platform: 'win32' | 'linux'): boolean {
29
+ const value = row.disabled
30
+ if (value !== null && typeof value === 'object' && '__jsExpr' in value) {
31
+ return Boolean(evaluate({ process: { platform } }, (value as { __jsExpr: string }).__jsExpr))
32
+ }
33
+ return value === true
34
+ }
35
+
36
+ describe('the shipped shell composition (real bundle layers)', () => {
37
+ let home: string
38
+ afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) })
39
+ // The app installation anchor, mirroring profile-boot.ts: the bundle layers
40
+ // resolve from the REAL dsh-base/dsh-web-app packages through it, so this
41
+ // suite composes the shipped patch files, not test fixtures.
42
+ const anchor = fileURLToPath(new URL('../package.json', import.meta.url))
43
+
44
+ it('composes the confined pwsh roster on win32 and the bash roster on POSIX from the same rows', () => {
45
+ home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
46
+ initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
47
+ const profile = loadProfile('dsh', 'web', anchor, home)
48
+ const warnings: string[] = []
49
+ const rows = composeEntries(
50
+ profile.layers.map(layer => layer.patches),
51
+ message => warnings.push(message),
52
+ )
53
+ const byId = new Map(rows.map(row => [row.id, row]))
54
+ // One shared patch set, two rosters: the shell stacks gate themselves.
55
+ for (const id of ['bash-sandbox', 'pwsh-sandbox', 'tool-bash', 'tool-pwsh']) {
56
+ expect(byId.has(id), `row ${id}`).toBe(true)
57
+ }
58
+ expect(disabledOn(byId.get('bash-sandbox')!, 'win32'), 'bash-sandbox on win32').toBe(true)
59
+ expect(disabledOn(byId.get('bash-sandbox')!, 'linux'), 'bash-sandbox on linux').toBe(false)
60
+ expect(disabledOn(byId.get('pwsh-sandbox')!, 'win32'), 'pwsh-sandbox on win32').toBe(false)
61
+ expect(disabledOn(byId.get('pwsh-sandbox')!, 'linux'), 'pwsh-sandbox on linux').toBe(true)
62
+ // Host shell-tool rows are disabled on every platform; sessions mount
63
+ // their own rows instead.
64
+ expect(byId.get('tool-bash')?.disabled).toBe(true)
65
+ expect(byId.get('tool-pwsh')?.disabled).toBe(true)
66
+ // The permission surface never moves: the sandbox/policy rows, the
67
+ // permission switcher, fs-sandbox, and the approval service stay enabled
68
+ // exactly as on POSIX — the confined pwsh executor is what changes.
69
+ for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) {
70
+ expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true)
71
+ }
72
+ // The launcher's cold-start module fallback BFS-links the apps/cli
73
+ // dependency closure into the profile's node_modules, so every bare
74
+ // plugin name in the base patch must resolve from there.
75
+ const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record<string, string> }
76
+ for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) {
77
+ expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined()
78
+ }
79
+ expect(warnings).toEqual([])
80
+ })
81
+
82
+ it('base-only profiles carry both stacks with the same platform gating', () => {
83
+ home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
84
+ initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base'])
85
+ const profile = loadProfile('dsh', 'base-only', anchor, home)
86
+ const warnings: string[] = []
87
+ const rows = composeEntries(
88
+ profile.layers.map(layer => layer.patches),
89
+ message => warnings.push(message),
90
+ )
91
+ const byId = new Map(rows.map(row => [row.id, row]))
92
+ for (const id of ['bash-sandbox', 'tool-bash', 'pwsh-sandbox', 'tool-pwsh']) {
93
+ expect(byId.has(id), `row ${id}`).toBe(true)
94
+ }
95
+ // No web overlay: the tool rows keep their own gating too.
96
+ expect(disabledOn(byId.get('tool-bash')!, 'win32'), 'tool-bash on win32').toBe(true)
97
+ expect(disabledOn(byId.get('tool-bash')!, 'linux'), 'tool-bash on linux').toBe(false)
98
+ expect(disabledOn(byId.get('tool-pwsh')!, 'win32'), 'tool-pwsh on win32').toBe(false)
99
+ expect(disabledOn(byId.get('tool-pwsh')!, 'linux'), 'tool-pwsh on linux').toBe(true)
100
+ expect(warnings).toEqual([])
101
+ })
102
+ })
103
+
104
+ describe('shipped agent presets gate both shell tools by platform', () => {
105
+ const presetRoot = SHIPPED_PRESET_ROOT
106
+
107
+ it.each(['standard', 'ptc', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => {
108
+ const entries: unknown = yaml.load(
109
+ readFileSync(join(presetRoot, preset, 'agent.cordis.yml'), 'utf8'),
110
+ { schema: entryListSchema },
111
+ )
112
+ if (!Array.isArray(entries)) throw new TypeError(`preset ${preset} must parse to an entry array`)
113
+ for (const [id, win32] of [['tool-bash', true], ['tool-pwsh', false]] as const) {
114
+ const row = entries.find((entry): entry is Record<string, unknown> => (
115
+ typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === id
116
+ ))
117
+ if (row === undefined) throw new TypeError(`preset ${preset} must mount ${id}`)
118
+ expect(row.disabled).toMatchObject({ __jsExpr: expect.any(String) as string })
119
+ // A platform-scoped context pins both outcomes on every host.
120
+ const expression = (row.disabled as { __jsExpr: string }).__jsExpr
121
+ expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression)), `${id} on win32`).toBe(win32)
122
+ expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression)), `${id} on linux`).toBe(!win32)
123
+ }
124
+ })
125
+
126
+ it('minimal mounts no shell tool row and gates its persistent shell stack by platform', () => {
127
+ const entries: unknown = yaml.load(
128
+ readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'),
129
+ { schema: entryListSchema },
130
+ )
131
+ if (!Array.isArray(entries)) throw new TypeError('minimal preset must parse to an entry array')
132
+ for (const id of ['tool-bash', 'tool-pwsh']) {
133
+ expect(entries.some(entry => (
134
+ typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === id
135
+ )), `${id} must be absent from minimal`).toBe(false)
136
+ }
137
+ const group = entries.find((entry): entry is Record<string, unknown> => (
138
+ typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === 'persistent-shell'
139
+ ))
140
+ if (group === undefined) throw new TypeError('minimal preset must mount persistent-shell')
141
+ const rows = group.config as unknown[]
142
+ if (!Array.isArray(rows)) throw new TypeError('persistent-shell must carry a row list')
143
+ const byId = new Map(rows
144
+ .filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
145
+ .map(entry => [entry.id, entry]))
146
+ // The bash stack (terminal-bash + persistent-bash) mounts on POSIX only; the
147
+ // pwsh twin (terminal-bash with shellDialect pwsh + persistent-pwsh) mounts on
148
+ // win32 only — exactly one persistent shell per host.
149
+ for (const id of ['terminal-bash', 'persistent-bash']) {
150
+ expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(true)
151
+ expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(false)
152
+ }
153
+ for (const id of ['terminal-pwsh', 'persistent-pwsh']) {
154
+ expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(false)
155
+ expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(true)
156
+ }
157
+ expect(byId.get('terminal-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' })
158
+ })
159
+ })
apps/cli/tsconfig.json ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib/types"
6
+ },
7
+ "include": [
8
+ "src"
9
+ ],
10
+ "references": [
11
+ {
12
+ "path": "../../vendor/cordis"
13
+ },
14
+ {
15
+ "path": "../../vendor/loader"
16
+ },
17
+ {
18
+ "path": "../../vendor/include"
19
+ },
20
+ {
21
+ "path": "../../packages/boot/app-boot"
22
+ },
23
+ {
24
+ "path": "../../packages/boot/cmdline"
25
+ },
26
+ {
27
+ "path": "../../packages/bundle/base"
28
+ },
29
+ {
30
+ "path": "../../packages/bundle/headless"
31
+ },
32
+ {
33
+ "path": "../../packages/bundle/web-app"
34
+ },
35
+ {
36
+ "path": "../../packages/host/webserver"
37
+ },
38
+ {
39
+ "path": "../../packages/host/frontend-static"
40
+ },
41
+ {
42
+ "path": "../../packages/core/session"
43
+ },
44
+ {
45
+ "path": "../../packages/core/system-prompt"
46
+ },
47
+ {
48
+ "path": "../../packages/core/tools"
49
+ },
50
+ {
51
+ "path": "../../packages/util/launch-environment"
52
+ },
53
+ {
54
+ "path": "../../packages/util/home-paths"
55
+ },
56
+ {
57
+ "path": "../../packages/mcp/mcp-client"
58
+ },
59
+ {
60
+ "path": "../../packages/test-support/loader-smoke"
61
+ },
62
+ {
63
+ "path": "../../packages/session-query/session-query-sqlite"
64
+ },
65
+ {
66
+ "path": "../../packages/session-query/session-query"
67
+ },
68
+ {
69
+ "path": "../../packages/shell/shell-env"
70
+ },
71
+ {
72
+ "path": "../../packages/shell/tool-bash"
73
+ }
74
+ ]
75
+ }
apps/cli/tsdown.config.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'tsdown'
2
+
3
+ /**
4
+ * The dsh CLI ships one entry: the `bin` referenced by package.json `bin`.
5
+ * The root tsdown builds only `lib/types/index.js`, so this override points at
6
+ * `lib/types/bin.js` instead; its reachable mode modules bundle with it.
7
+ * Declarations come from `tsc -b` (dts: false), matching every package.
8
+ */
9
+ export default defineConfig({
10
+ entry: ['lib/types/bin.js'],
11
+ outDir: 'lib',
12
+ format: ['esm'],
13
+ platform: 'node',
14
+ target: 'es2024',
15
+ fixedExtension: false,
16
+ dts: false,
17
+ clean: ['lib/*.js'],
18
+ })
apps/desktop-host/package.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@deepseek-ai/dsh-desktop-host",
3
+ "description": "Private Node-mode host process for the Electron desktop application",
4
+ "version": "0.1.6-alpha.1",
5
+ "private": true,
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "lib/index.js",
9
+ "files": [
10
+ "lib/index.js",
11
+ "config/desktop.cordis.patch.yml"
12
+ ],
13
+ "dependencies": {
14
+ "@deepseek-ai/cordis": "workspace:^",
15
+ "@deepseek-ai/cordis-plugin-include": "workspace:^",
16
+ "@deepseek-ai/dsh": "workspace:^",
17
+ "@deepseek-ai/dsh-api-gateway": "workspace:^",
18
+ "@deepseek-ai/dsh-app-boot": "workspace:^",
19
+ "@deepseek-ai/dsh-client-connection": "workspace:^",
20
+ "@deepseek-ai/dsh-client-modules": "workspace:^",
21
+ "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
22
+ "@deepseek-ai/dsh-cmdline": "workspace:^",
23
+ "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
24
+ "@deepseek-ai/dsh-host-webserver": "workspace:^",
25
+ "@deepseek-ai/dsh-launch-environment": "workspace:^",
26
+ "@deepseek-ai/dsh-web-frontend": "workspace:^"
27
+ }
28
+ }
apps/desktop-host/tsconfig.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib/types"
6
+ },
7
+ "include": ["src"],
8
+ "references": [
9
+ { "path": "../../vendor/cordis" },
10
+ { "path": "../../vendor/include" },
11
+ { "path": "../../packages/api/gateway/tsconfig.host.json" },
12
+ { "path": "../../packages/boot/app-boot" },
13
+ { "path": "../../packages/boot/cmdline" },
14
+ { "path": "../../packages/client/connection/tsconfig.host.json" },
15
+ { "path": "../../packages/client/modules" },
16
+ { "path": "../../packages/host/webserver" },
17
+ { "path": "../../packages/util/launch-environment" }
18
+ ]
19
+ }
apps/desktop-host/tsdown.config.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'tsdown'
2
+
3
+ export default defineConfig({
4
+ entry: ['lib/types/index.js'],
5
+ outDir: 'lib',
6
+ format: ['esm'],
7
+ platform: 'node',
8
+ target: 'es2024',
9
+ fixedExtension: false,
10
+ dts: false,
11
+ clean: false,
12
+ })
apps/desktop/README.i18n.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write apps/desktop/README.md
5
+ README.md: c053e4894714d10cba2a9d9c1ebed71b53457c37
6
+ README.zh.md: e27554fe8a91ee8a918380ef5a860b0838351aa5
apps/desktop/README.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DeepSeek Harness Desktop
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ The desktop application is an Electron shell around the dsh Web UI. It opens no listening port: a bundled upstream Node.js child boots the installed dsh project, versioned framed byte pipes carry Fetch requests and streaming responses without an outer Base64 envelope, Node IPC carries lifecycle control, and `dsh-app://` serves the matching client assets.
6
+
7
+ ## Key technical decisions
8
+
9
+ | Decision | Why | Direct consequence |
10
+ |---|---|---|
11
+ | Release identity | The shell API, Web client, backend, and plugin graph are qualified as one combination; independent versions would create untested combinations and ambiguous update availability. | Electron and `@deepseek-ai/dsh` always have the same exact version. A dsh upgrade is a Desktop release, even when the shell code is unchanged. |
12
+ | Runtime | Electron's Node.js carries Electron patches, fuses, ABI, and lifecycle constraints, while system runtimes and package-manager state are uncontrolled. | dsh runs under the bundled upstream Node.js and every package operation uses the bundled pnpm. Electron's Node.js, system Node.js, system pnpm, and user package-manager configuration are outside the execution path. |
13
+ | Package sources | Core installation at startup adds work even when offline. | `extraResources/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
14
+ | Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. |
15
+ | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
16
+ | Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. |
17
+ | Plugin changes | Package installation and Host startup can fail. | Desktop stops the Host and modifies the current profile directly. Failures retain partial changes for explicit repair; there is no automatic profile rollback. |
18
+ | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
19
+
20
+ The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions.
21
+
22
+ ## Installation ownership
23
+
24
+ Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains only installed external plugins at exact versions; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same bundled upstream Node process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
25
+
26
+ The local startup page exposes startup status and available recovery actions; the loaded dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
27
+
28
+ Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, the startup page, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
29
+
30
+ ### Runtime and plugin activation
31
+
32
+ The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
33
+
34
+ 1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links while preserving unrelated files, then starts the actual backend once. Unchanged startups reuse the profile without scanning installed plugin manifests.
35
+ 2. A compatible application upgrade refreshes shared links in the current profile and checks enabled plugins’ peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
36
+ 3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again.
37
+ 4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile.
38
+ 5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error. Unfinished package operations retain a marker so the next launch retries the locked installation and pending builds. Desktop creates no staging directories, activation journals, or rollback copies.
39
+
40
+ The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered only when packaged application resources support profile recovery; development and early initialization failures expose restart alone. The plugin manager remains available through the application menu. Runtime identity is checked before any backend starts; plugin changes have no automatic rollback.
41
+
42
+ Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the available recovery actions and diagnostics; its controls do not require preload.
43
+
44
+ Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Reset preserves the directory and its lock until initialization and Host startup finish. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction.
45
+
46
+ ## Develop
47
+
48
+ `dev:desktop` builds the current Host, client bundles, Web frontend, and Electron shell, projects the built CLI and private Desktop Host packages with their workspace dependencies into a disposable desktop npm project, and launches Electron without downloading the packaged Node.js runtime or resolving dsh from npm:
49
+
50
+ ```sh
51
+ pnpm run dev:desktop
52
+ ```
53
+
54
+ Development Harness state defaults to `apps/desktop/.desktop-build/development/home`, the disposable npm project lives at `apps/desktop/.desktop-build/development/project`, and Electron browser data lives at `apps/desktop/.desktop-build/development/electron-user-data`. Sessions, settings, credentials, package links, and browser data therefore stay out of the user's normal Harness home. An explicit `DSH_HOME` replaces only the development Harness home. Renderer DevTools opens automatically; Main, Renderer, and dsh Host debugging listen on ports 9229, 9222, and 9230. `DSH_DESKTOP_MAIN_INSPECT_PORT`, `DSH_DESKTOP_RENDERER_DEBUG_PORT`, and `DSH_DESKTOP_HOST_INSPECT_PORT` replace those ports, while `DSH_DESKTOP_OPEN_DEVTOOLS=0` keeps the detached Renderer tools closed.
55
+
56
+ After an explicit build, `start:desktop` reconstructs the disposable project and launches the existing artifacts without building again:
57
+
58
+ ```sh
59
+ pnpm run start:desktop
60
+ ```
61
+
62
+ Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
63
+
64
+ ## Package
65
+
66
+ The normal packaging path is one complete command. It performs release preparation before creating the host platform's installers and update metadata. Every target requires a reverse-DNS `DSH_DESKTOP_APP_ID`. macOS targets additionally require the electron-builder certificate qualifier in `DSH_DESKTOP_MACOS_SIGNING_IDENTITY`, its 10-character Apple Team ID in `DSH_DESKTOP_MACOS_TEAM_ID`, and one complete notarytool credential strategy. The App Store Connect API-key strategy uses these variables:
67
+
68
+ ```sh
69
+ export DSH_DESKTOP_APP_ID='<reverse-DNS application ID>'
70
+ export DSH_DESKTOP_MACOS_SIGNING_IDENTITY='<certificate name without the Developer ID Application prefix>'
71
+ export DSH_DESKTOP_MACOS_TEAM_ID='<10-character Apple Team ID>'
72
+ export APPLE_API_KEY='<absolute path to the .p8 file>'
73
+ export APPLE_API_KEY_ID='<App Store Connect API Key ID>'
74
+ export APPLE_API_ISSUER='<App Store Connect issuer UUID>'
75
+ ```
76
+
77
+ `prepare:desktop` is not a prerequisite:
78
+
79
+ ```sh
80
+ pnpm run package:desktop
81
+ ```
82
+
83
+ Release automation uses fixed target commands so runtime preparation, dsh preparation, and electron-builder receive the same platform and architecture:
84
+
85
+ ```sh
86
+ pnpm run package:desktop:mac:arm64
87
+ pnpm run package:desktop:mac:x64
88
+ pnpm run package:desktop:win:x64
89
+ ```
90
+
91
+ The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target.
92
+
93
+ Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets/<target>/`. The Node.js archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state.
94
+
95
+ ### Runtime file selection
96
+
97
+ Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
98
+
99
+ The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under the bundled Node before the Host smoke and final inventory verification.
100
+
101
+ Windows release qualification also runs [native cleanup and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Electron` to the prepared Electron executable and `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies Electron junction cleanup, installer scratch cleanup, and both locked-file replacement modes; it is not part of the unit-test lane.
102
+
103
+ ```powershell
104
+ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
105
+ ```
106
+
107
+ ### Upload updates
108
+
109
+ `DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`, while the production origin remains `https://download.deepseek.com`. Upload additionally requires the selected deployment's COS bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. The target path is `_/harness/desktop/stable/<target>/`, where `target` is `mac-arm64`, `mac-x64`, or `win-x64`.
110
+
111
+ The update destination and upload credentials follow the selected deployment:
112
+
113
+ | Environment | Public origin | COS bucket | COS credentials |
114
+ |---|---|---|---|
115
+ | `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` |
116
+ | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` |
117
+
118
+ Package and upload one target under the same environment. For example, the default test deployment uses:
119
+
120
+ ```sh
121
+ export DOWNLOAD_TEST_ORIGIN='https://desktop-updates.example.com'
122
+ pnpm run package:desktop:mac:arm64
123
+
124
+ export DOWNLOAD_TEST_COS_BUCKET='<test COS bucket>'
125
+ export DOWNLOAD_TEST_COS_SECRET_ID='<test COS SecretId>'
126
+ export DOWNLOAD_TEST_COS_SECRET_KEY='<test COS SecretKey>'
127
+ pnpm run upload:mac:arm64
128
+ ```
129
+
130
+ Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename.
131
+
132
+ The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>`.
133
+
134
+ macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements.
135
+
136
+ Company proxies can accelerate uploads to Apple's notarization service. See the company internal documentation for configuration.
137
+
138
+ ### Unsigned Windows test installer
139
+
140
+ On Windows x64, use the complete unsigned packaging command for local installation testing:
141
+
142
+ ```sh
143
+ pnpm run package:desktop:win:x64:unsigned
144
+ ```
145
+
146
+ The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, including Python and Visual C++ build tools for native modules. Set `PYTHON` to the Python executable when it is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits automatic-update configuration, strips signing credentials, and creates no release completion record. It does not require EV credentials or an update origin. The signed packaging and upload commands retain their release requirements.
147
+
148
+ ### Windows EV signing
149
+
150
+ Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract.
151
+
152
+ NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree.
153
+
154
+ Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target:
155
+
156
+ ```powershell
157
+ $env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer'
158
+ $env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe'
159
+ $env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '<SafeNet private-key container name>'
160
+ $env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '<SafeNet Token Password>'
161
+ pnpm run package:desktop:win:x64
162
+ ```
163
+
164
+ Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
165
+
166
+ The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable.
167
+
168
+ Create a runnable application directory instead of an installer by using the matching `:dir` command, such as:
169
+
170
+ ```sh
171
+ pnpm run package:desktop:dir
172
+ pnpm run package:desktop:mac:arm64:dir
173
+ ```
174
+
175
+ To inspect or troubleshoot the prepared host-target resources without invoking electron-builder, stop the same pipeline after preparation:
176
+
177
+ ```sh
178
+ pnpm run prepare:desktop
179
+ ```
180
+
181
+ This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content.
182
+
183
+ Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares target-specific Node and pnpm executables. `prepare:dsh` installs the production graph once at build time, copies materialized packages into `extraResources/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the copied inventory is checked before signing and again after signing. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
184
+
185
+ An unpacked artifact contains Electron, the materialized dsh production tree, upstream Node.js and pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine.
186
+
187
+ ## Updates
188
+
189
+ A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch displays the local loading page while reconciling the version-bound runtime.
190
+
191
+ Signed packaging emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
192
+
193
+ ## Low-level development overrides
194
+
195
+ An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile.
196
+
197
+ ## Known limitations
198
+
199
+ - The Web "Open In..." action is disabled in Desktop because its host plugin requires HTTP routes; Desktop does not provide a `webServer`.
200
+ - Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment.
201
+ - Desktop plugins with dependency lifecycle scripts are rejected unless their package appears in the desktop project's reviewed `allowBuilds` policy.
202
+ - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, lockfiles, and package-manager state remain separate.
apps/desktop/README.zh.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DeepSeek Harness 桌面端
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 桌面应用是包裹 dsh Web UI 的 Electron 壳。它不打开监听端口:内置的上游 Node.js 子进程启动已安装的 dsh 项目,带版本的分帧字节管道在没有外层 Base64 信封的情况下承载 Fetch 请求与流式响应,Node IPC 承载生命周期控制,`dsh-app://` 则提供与后端版本匹配的客户端资源。
6
+
7
+ ## 关键技术决策
8
+
9
+ | 决策 | 原因 | 直接结果 |
10
+ |---|---|---|
11
+ | 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 |
12
+ | 运行时 | Electron 的 Node.js 带有 Electron 补丁、fuse、ABI 与生命周期约束,而系统运行时和包管理器状态不可控。 | dsh 通过内置的上游 Node.js 运行,所有包操作都使用内置 pnpm。Electron 的 Node.js、系统 Node.js、系统 pnpm 与用户的包管理器配置都不进入执行路径。 |
13
+ | 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `extraResources/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
14
+ | 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 |
15
+ | 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
16
+ | 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 |
17
+ | 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 |
18
+ | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
19
+
20
+ [Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。
21
+
22
+ ## 安装归属
23
+
24
+ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 只包含已安装外部插件的精确版本;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个内置上游 Node 进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
25
+
26
+ 本地启动页提供启动状态和可用恢复操作;加载后的 dsh 渲染进程仅接收桌面协议标记。独立插件窗口接收结构化的列表、安装、删除、更新和更新检查操作;两个渲染进程都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
27
+
28
+ Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。
29
+
30
+ ### 运行时与插件激活
31
+
32
+ 签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
33
+
34
+ 1. 主窗口在 profile 准备或后端启动前显示本地加载页。新 profile 创建清单和共享包链接,保留无关文件,然后启动一次实际后端。未变化的启动复用 profile,不扫描已安装插件的清单。
35
+ 2. 兼容的应用升级在当前 profile 中刷新共享链接,并检查已启用插件的 peer 要求。插件文件、配置、版本和锁文件留在原处;不运行 pnpm。
36
+ 3. 内置 Node 版本、平���或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。
37
+ 4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。
38
+ 5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。未完成的包操作保留标记,使下次启动重试锁定依赖的安装和待执行构建。Desktop 不创建 staging 目录、激活日志或回滚副本。
39
+
40
+ 加载页不依赖 Host。错误页提供重启和重装指导。只有已打包应用的资源支持 profile 恢复时,才提供禁用插件和重置 Desktop;开发模式和早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。每次后端启动前都会检查运行时标识;插件修改不自动回滚。
41
+
42
+ 重置删除 `$DSH_HOME/profiles/desktop` 中除所持事务锁外的所有条目,然后初始化内置 profile。它删除 Desktop 配置和已安装第三方包,不保留备份。共享任务、设置和 Harness-home `.env` 保持不变。壳资源和 preload 失败时使用独立文档显示可用恢复操作和诊断;其控件不依赖 preload。
43
+
44
+ 包事务独占持有 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。重置保留目录及其锁,直到初始化和 Host 启动完成。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。
45
+
46
+ ## 开发
47
+
48
+ `dev:desktop` 会构建当前 Host、客户端 bundle、Web 前端和 Electron 壳,把已构建的 CLI 包、私有 Desktop Host 包及其 workspace 依赖投影为一次性桌面 npm 项目,然后直接启动 Electron;这条路径不下载安装包内的 Node.js,也不从 npm 解析 dsh:
49
+
50
+ ```sh
51
+ pnpm run dev:desktop
52
+ ```
53
+
54
+ 开发 Harness 状态默认写入 `apps/desktop/.desktop-build/development/home`,一次性 npm 项目位于 `apps/desktop/.desktop-build/development/project`,Electron 浏览器数据则位于 `apps/desktop/.desktop-build/development/electron-user-data`。因此,会话、设置、凭据、包链接和浏览器数据都不会进入用户正常使用的 Harness home;显式 `DSH_HOME` 只会替换开发 Harness home。Renderer DevTools 默认自动打开,Main、Renderer 和 dsh Host 调试端口依次为 9229、9222 和 9230。`DSH_DESKTOP_MAIN_INSPECT_PORT`、`DSH_DESKTOP_RENDERER_DEBUG_PORT` 与 `DSH_DESKTOP_HOST_INSPECT_PORT` 可以替换这些端口,`DSH_DESKTOP_OPEN_DEVTOOLS=0` 则保持 Renderer 调试窗口关闭。
55
+
56
+ 显式构建完成后,`start:desktop` 会重新生成一次性项目,并跳过构建直接启动已有产物:
57
+
58
+ ```sh
59
+ pnpm run start:desktop
60
+ ```
61
+
62
+ Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
63
+
64
+ ## 打包
65
+
66
+ 正常打包只需执行一条完整命令。该命令会先准备发布资源,再生成宿主平台的安装包与更新元数据。所有目标都要求通过 `DSH_DESKTOP_APP_ID` 提供反向域名形式的应用 ID。macOS 目标还要求通过 `DSH_DESKTOP_MACOS_SIGNING_IDENTITY` 提供 electron-builder 证书限定名,通过 `DSH_DESKTOP_MACOS_TEAM_ID` 提供对应的 10 字符 Apple Team ID,并提供一套完整的 notarytool 凭据方案。App Store Connect API Key 方式使用以下变量:
67
+
68
+ ```sh
69
+ export DSH_DESKTOP_APP_ID='<reverse-DNS application ID>'
70
+ export DSH_DESKTOP_MACOS_SIGNING_IDENTITY='<certificate name without the Developer ID Application prefix>'
71
+ export DSH_DESKTOP_MACOS_TEAM_ID='<10-character Apple Team ID>'
72
+ export APPLE_API_KEY='<absolute path to the .p8 file>'
73
+ export APPLE_API_KEY_ID='<App Store Connect API Key ID>'
74
+ export APPLE_API_ISSUER='<App Store Connect issuer UUID>'
75
+ ```
76
+
77
+ 无需提前执行 `prepare:desktop`:
78
+
79
+ ```sh
80
+ pnpm run package:desktop
81
+ ```
82
+
83
+ 发布自动化使用固定目标命令,确保运行时准备、dsh 准备与 electron-builder 接收相同的平台和架构:
84
+
85
+ ```sh
86
+ pnpm run package:desktop:mac:arm64
87
+ pnpm run package:desktop:mac:x64
88
+ pnpm run package:desktop:win:x64
89
+ ```
90
+
91
+ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Linux 不是受支持的 Desktop 发布目标。
92
+
93
+ 每个目标都在 `apps/desktop/.desktop-build/targets/<target>/` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。
94
+
95
+ ### 运行时文件筛选
96
+
97
+ 生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。
98
+
99
+ 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用内置 Node 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。
100
+
101
+ Windows 发布验收还需在 Desktop 构建后手动运行[原生清理和替换检查](scripts/smoke-windows.ps1)。将 `$Electron` 设为已准备的 Electron 可执行文件,将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 Electron junction 清理、安装器临时目录清理和两种文件占用替换方式;不属于单元测试通道。
102
+
103
+ ```powershell
104
+ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
105
+ ```
106
+
107
+ ### 上传更新
108
+
109
+ `DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包时写入的更新 URL 与后续 COS 上传目标,可取 `test` 或 `production`;未设置时使用 `test`。测试打包必须通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin,生产 origin 仍为 `https://download.deepseek.com`。上传还必须通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选环境的 COS bucket。目标路径为 `_/harness/desktop/stable/<target>/`,其中 `target` 为 `mac-arm64`、`mac-x64` 或 `win-x64`。
110
+
111
+ 更新目标与上传凭据都与所选环境对应:
112
+
113
+ | 环境 | 公开 origin | COS bucket | COS 凭据 |
114
+ |---|---|---|---|
115
+ | `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` |
116
+ | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` |
117
+
118
+ 同一目标必须在同一环境下完成打包与上传。例如,默认测试环境使用:
119
+
120
+ ```sh
121
+ export DOWNLOAD_TEST_ORIGIN='https://desktop-updates.example.com'
122
+ pnpm run package:desktop:mac:arm64
123
+
124
+ export DOWNLOAD_TEST_COS_BUCKET='<test COS bucket>'
125
+ export DOWNLOAD_TEST_COS_SECRET_ID='<test COS SecretId>'
126
+ export DOWNLOAD_TEST_COS_SECRET_KEY='<test COS SecretKey>'
127
+ pnpm run upload:mac:arm64
128
+ ```
129
+
130
+ 生产发布需在打包前设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证钩子成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。
131
+
132
+ macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。仅生成目录的命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- <path-to-app>` 重复应用检查时,也必须提供两个 macOS 身份变量。
133
+
134
+ macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。
135
+
136
+ 可通过公司代理加速向 Apple 公证服务上传。代理配置参见公司内部文档。
137
+
138
+ ### 未签名 Windows 测试安装包
139
+
140
+ 在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试:
141
+
142
+ ```sh
143
+ pnpm run package:desktop:win:x64:unsigned
144
+ ```
145
+
146
+ 该命令要求设置 `DSH_DESKTOP_APP_ID` 并具备常规构建依赖,包括编译原生模块所需的 Python 和 Visual C++ 构建工具。Python 不在 `PATH` 中时,将 `PYTHON` 设置为其可执行文件路径。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略自动更新配置,清除签名凭据,且不生成发布完成记录。它不需要 EV 凭据或更新源地址。签名打包和上传命令仍遵循正式发布要求。
147
+
148
+ ### Windows EV 签名
149
+
150
+ Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。
151
+
152
+ NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。
153
+
154
+ Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入:
155
+
156
+ ```powershell
157
+ $env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer'
158
+ $env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe'
159
+ $env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '<SafeNet private-key container name>'
160
+ $env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '<SafeNet Token Password>'
161
+ pnpm run package:desktop:win:x64
162
+ ```
163
+
164
+ 打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。
165
+
166
+ PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。
167
+
168
+ 使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如:
169
+
170
+ ```sh
171
+ pnpm run package:desktop:dir
172
+ pnpm run package:desktop:mac:arm64:dir
173
+ ```
174
+
175
+ 需要检查或诊断为宿主目标准备的资源而不调用 electron-builder 时,可以让同一流水线在准备完成后��止:
176
+
177
+ ```sh
178
+ pnpm run prepare:desktop
179
+ ```
180
+
181
+ 这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。
182
+
183
+ 每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Node 与 pnpm 可执行文件。`prepare:dsh` 在构建时安装一次生产依赖图,把物化包复制到 `extraResources/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;复制后的清单在签名前及签名后分别验证。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
184
+
185
+ 未压缩产物包含 Electron、物化后的 dsh 生产依赖树、上游 Node.js 与 pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
186
+
187
+ ## 更新
188
+
189
+ 打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动在显示本地加载页的同时校准版本绑定的运行时。
190
+
191
+ 签名打包为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
192
+
193
+ ## 底层开发覆盖项
194
+
195
+ 未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。
196
+
197
+ ## 已知限制
198
+
199
+ - Desktop 禁用 Web 的「在本地应用中打开…」操作,因为其 Host 插件依赖 HTTP 路由,而 Desktop 不提供 `webServer`。
200
+ - 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。
201
+ - 依赖包含 lifecycle script 的桌面插件,只有其包名进入桌面项目经过评审的 `allowBuilds` 策略后才能安装。
202
+ - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活、锁文件与包管理器状态彼此隔离。
apps/desktop/electron-builder.config.d.mts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Electron-builder fields asserted by the Desktop release tests. */
2
+ export interface DesktopElectronBuilderConfig {
3
+ readonly appId: string
4
+ readonly directories: {
5
+ readonly output: string
6
+ }
7
+ readonly files: readonly [
8
+ string,
9
+ string,
10
+ string,
11
+ string,
12
+ { readonly from: string, readonly to: 'dsh', readonly filter: readonly ['**/*'] },
13
+ { readonly from: string, readonly to: 'dsh/node_modules', readonly filter: readonly ['**/*'] },
14
+ ]
15
+ readonly asarUnpack: readonly string[]
16
+ readonly extraResources: readonly [{ readonly from: string, readonly to: 'runtime' }]
17
+ readonly mac: {
18
+ readonly identity: string | undefined
19
+ readonly forceCodeSigning: boolean
20
+ readonly notarize: boolean
21
+ readonly signIgnore: readonly string[]
22
+ }
23
+ readonly dmg: {
24
+ readonly sign: boolean
25
+ readonly writeUpdateInfo: boolean
26
+ }
27
+ readonly nsis: {
28
+ readonly include: string
29
+ }
30
+ readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise<void> | undefined
31
+ readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null
32
+ }
33
+
34
+ /**
35
+ * Create electron-builder configuration from one release environment.
36
+ * @param env - Packaging environment.
37
+ * @param hostPlatform - Build-host platform used when no explicit target is present.
38
+ * @param hostArch - Build-host architecture used when no explicit target is present.
39
+ * @returns electron-builder configuration.
40
+ */
41
+ export function createElectronBuilderConfig(
42
+ env?: NodeJS.ProcessEnv,
43
+ hostPlatform?: NodeJS.Platform,
44
+ hostArch?: string,
45
+ ): DesktopElectronBuilderConfig
46
+
47
+ declare const electronBuilderConfig: DesktopElectronBuilderConfig
48
+
49
+ export default electronBuilderConfig
apps/desktop/electron-builder.config.mjs ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import {
4
+ resolveDesktopAppId,
5
+ resolveMacOSNotarizationEnvironment,
6
+ resolveMacOSSigningEnvironment,
7
+ } from './scripts/desktop-release-environment.mjs'
8
+ import { notarizeMacOSDiskImageArtifact } from './scripts/notarize-macos-disk-images.mjs'
9
+ import { verifyMacOSSignatureAfterSign } from './scripts/verify-macos-signature.mjs'
10
+ import {
11
+ createWindowsTokenSigner,
12
+ installWindowsNsisBootstrapSigner,
13
+ } from './scripts/windows-sign.mjs'
14
+ import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs'
15
+ import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './scripts/desktop-build-paths.mjs'
16
+
17
+ /**
18
+ * Create electron-builder configuration from one release environment.
19
+ * @param {NodeJS.ProcessEnv} env - Packaging environment.
20
+ * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no explicit target is present.
21
+ * @param {string} hostArch - Build-host architecture used when no explicit target is present.
22
+ * @returns {object} electron-builder configuration.
23
+ */
24
+ export function createElectronBuilderConfig(
25
+ env = process.env,
26
+ hostPlatform = process.platform,
27
+ hostArch = process.arch,
28
+ ) {
29
+ const appId = resolveDesktopAppId(env)
30
+ const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM
31
+ const resolvedPlatform = targetPlatform ?? hostPlatform
32
+ const resolvedArch = env.DSH_DESKTOP_TARGET_ARCH ?? hostArch
33
+ if (env.DSH_DESKTOP_UNSIGNED !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_UNSIGNED)) {
34
+ throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1')
35
+ }
36
+ const unsigned = env.DSH_DESKTOP_UNSIGNED === '1'
37
+ if (unsigned && resolvedPlatform !== 'win32') throw new Error('desktop package: unsigned builds require Windows')
38
+ const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin')
39
+ const packagesWindows = targetPlatform === 'win32'
40
+ const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined
41
+ if (packagesMacOS) resolveMacOSNotarizationEnvironment(env)
42
+ const windowsSigner = packagesWindows && !unsigned
43
+ ? createWindowsTokenSigner({
44
+ certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE,
45
+ signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL,
46
+ tokenPin: env.DSH_DESKTOP_WINDOWS_TOKEN_PIN,
47
+ keyContainer: env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER,
48
+ })
49
+ : undefined
50
+ if (windowsSigner !== undefined) {
51
+ installWindowsNsisBootstrapSigner({ sign: windowsSigner })
52
+ }
53
+ const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
54
+ const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
55
+ return {
56
+ appId,
57
+ productName: 'DeepSeek Harness',
58
+ artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
59
+ directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts },
60
+ asar: true,
61
+ files: [
62
+ 'lib/*.js',
63
+ 'lib/*.cjs',
64
+ 'renderer/**/*',
65
+ 'package.json',
66
+ { from: buildPaths.dsh, to: 'dsh', filter: ['**/*'] },
67
+ // electron-builder excludes a source directory's root node_modules.
68
+ { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] },
69
+ ],
70
+ asarUnpack: [
71
+ '**/*.{node,dylib,dll,so,exe}',
72
+ '**/*.so.*',
73
+ '**/spawn-helper',
74
+ '**/@vscode/ripgrep/bin/rg',
75
+ ],
76
+ extraResources: [
77
+ { from: buildPaths.runtime, to: 'runtime' },
78
+ ],
79
+ mac: {
80
+ category: 'public.app-category.developer-tools',
81
+ identity: macOSSigning?.signingIdentity,
82
+ forceCodeSigning: true,
83
+ hardenedRuntime: true,
84
+ // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
85
+ signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '\\.pak$'],
86
+ notarize: true,
87
+ target: ['dmg', 'zip'],
88
+ },
89
+ dmg: {
90
+ sign: true,
91
+ writeUpdateInfo: false,
92
+ },
93
+ afterSign: async context => {
94
+ if (context.electronPlatformName !== 'darwin') return
95
+ verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env))
96
+ },
97
+ artifactBuildCompleted: artifact => {
98
+ if (!artifact.file.endsWith('.dmg')) return
99
+ return notarizeMacOSDiskImageArtifact(
100
+ artifact,
101
+ env,
102
+ macOSSigning ?? resolveMacOSSigningEnvironment(env),
103
+ )
104
+ },
105
+ win: {
106
+ forceCodeSigning: !unsigned,
107
+ signtoolOptions: {
108
+ sign: windowsSigner,
109
+ signingHashAlgorithms: ['sha256'],
110
+ },
111
+ target: ['nsis'],
112
+ },
113
+ linux: {
114
+ category: 'Development',
115
+ target: ['AppImage'],
116
+ },
117
+ nsis: {
118
+ include: fileURLToPath(new URL('./scripts/installer.nsh', import.meta.url)),
119
+ oneClick: false,
120
+ allowToChangeInstallationDirectory: true,
121
+ differentialPackage: true,
122
+ },
123
+ publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl }],
124
+ }
125
+ }
126
+
127
+ export default createElectronBuilderConfig()
apps/desktop/package.json ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@deepseek-ai/dsh-desktop",
3
+ "description": "Electron desktop shell for a bundled dsh runtime and external plugins",
4
+ "version": "0.1.6-alpha.1",
5
+ "private": true,
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "lib/main.js",
9
+ "scripts": {
10
+ "build": "tsc -b && tsdown",
11
+ "dev": "tsx scripts/dev.ts",
12
+ "start": "tsx scripts/dev.ts --skip-build",
13
+ "prepare:runtime": "tsx scripts/prepare-runtime.ts",
14
+ "prepare:packages": "tsx scripts/prepare-package-set.ts",
15
+ "prepare:dsh": "tsx scripts/prepare-dsh.ts",
16
+ "prepare:package": "tsx scripts/package-target.ts --prepare-only",
17
+ "verify:mac-signature": "node scripts/verify-macos-signature.mjs",
18
+ "package": "tsx scripts/package-target.ts",
19
+ "package:dir": "tsx scripts/package-target.ts --dir",
20
+ "package:mac:arm64": "tsx scripts/package-target.ts mac-arm64",
21
+ "package:mac:arm64:dir": "tsx scripts/package-target.ts mac-arm64 --dir",
22
+ "package:mac:x64": "tsx scripts/package-target.ts mac-x64",
23
+ "package:mac:x64:dir": "tsx scripts/package-target.ts mac-x64 --dir",
24
+ "package:win:x64": "tsx scripts/package-target.ts win-x64",
25
+ "package:win:x64:unsigned": "tsx scripts/package-target.ts win-x64 --unsigned",
26
+ "package:win:x64:dir": "tsx scripts/package-target.ts win-x64 --dir",
27
+ "upload:mac:arm64": "tsx scripts/upload-target.ts mac-arm64",
28
+ "upload:mac:x64": "tsx scripts/upload-target.ts mac-x64",
29
+ "upload:win:x64": "tsx scripts/upload-target.ts win-x64"
30
+ },
31
+ "dependencies": {
32
+ "electron-updater": "^6.8.9",
33
+ "semver": "^7.8.5"
34
+ },
35
+ "devDependencies": {
36
+ "@aws-sdk/client-s3": "3.1067.0",
37
+ "@deepseek-ai/dsh-home-paths": "workspace:^",
38
+ "@electron/notarize": "2.5.0",
39
+ "@types/js-yaml": "^4.0.9",
40
+ "@types/node": "^22.20.0",
41
+ "@types/semver": "^7.8.0",
42
+ "app-builder-lib": "26.15.3",
43
+ "electron": "^44.0.0",
44
+ "electron-builder": "^26.15.3",
45
+ "extract-zip": "^2.0.1",
46
+ "js-yaml": "^4.2.0",
47
+ "pnpm": "11.7.0",
48
+ "tar": "^7.5.0",
49
+ "typescript": "^6.0.3"
50
+ }
51
+ }
apps/desktop/tsconfig.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib/types"
6
+ },
7
+ "include": ["src"],
8
+ "references": [
9
+ { "path": "../../packages/util/home-paths" }
10
+ ]
11
+ }
apps/desktop/tsdown.config.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'tsdown'
2
+
3
+ export default defineConfig([
4
+ {
5
+ entry: ['lib/types/main.js'],
6
+ outDir: 'lib',
7
+ format: ['esm'],
8
+ platform: 'node',
9
+ target: 'es2024',
10
+ fixedExtension: false,
11
+ dts: false,
12
+ clean: false,
13
+ deps: { neverBundle: ['electron'] },
14
+ },
15
+ ...(['preload', 'preload-app'] as const).map(name => ({
16
+ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM.
17
+ entry: { [name]: `lib/types/${name}.js` },
18
+ outDir: 'lib',
19
+ format: ['cjs'] as const,
20
+ platform: 'node' as const,
21
+ target: 'es2024',
22
+ fixedExtension: false,
23
+ dts: false,
24
+ clean: false,
25
+ deps: { neverBundle: ['electron'] },
26
+ })),
27
+ ])
apps/web/.npmignore ADDED
@@ -0,0 +1 @@
 
 
1
+ *.map
apps/web/index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="manifest" href="/manifest.webmanifest" />
7
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
8
+ <title>DSH Local Build</title>
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ <script type="module" src="/src/main.ts"></script>
13
+ </body>
14
+ </html>
apps/web/package.json ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@deepseek-ai/dsh-web-frontend",
3
+ "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
4
+ "version": "0.1.6-alpha.1",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "apps/web"
12
+ },
13
+ "type": "module",
14
+ "exports": {
15
+ "./dist/*": "./dist/*",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "!dist/**/*.map",
21
+ "!dist/preview.html",
22
+ "!dist/preview"
23
+ ],
24
+ "scripts": {
25
+ "build": "vite build",
26
+ "dev": "vite",
27
+ "watch": "vite build --watch --no-emptyOutDir",
28
+ "build:preview": "pnpm --filter @deepseek-ai/dsh-experimental-webworker-runtime exec tsdown && pnpm --filter @deepseek-ai/dsh-experimental-webworker-packer exec tsdown && vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz",
29
+ "serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1"
30
+ },
31
+ "license": "MIT",
32
+ "devDependencies": {
33
+ "@deepseek-ai/cordis-plugin-group": "workspace:^",
34
+ "@deepseek-ai/dsh-client-modules": "workspace:^",
35
+ "@deepseek-ai/dsh-client-store": "workspace:^",
36
+ "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
37
+ "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
38
+ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
39
+ "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
40
+ "@deepseek-ai/dsh-client-web": "workspace:^",
41
+ "@deepseek-ai/dsh-cmdline": "workspace:^",
42
+ "@deepseek-ai/dsh-pwsh-local": "workspace:^",
43
+ "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^",
44
+ "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
45
+ "@deepseek-ai/dsh-remote-mock": "workspace:^",
46
+ "@types/node": "^22.0.0",
47
+ "@types/react": "~18.3.1",
48
+ "@types/react-dom": "~18.3.0",
49
+ "@types/ws": "8.18.1",
50
+ "@vitejs/plugin-react": "^4.0.0",
51
+ "http-server": "^14.1.1",
52
+ "fflate": "^0.8.2",
53
+ "playwright": "^1.49.0",
54
+ "react": "^18.2.0",
55
+ "react-dom": "^18.2.0",
56
+ "typescript": "^6.0.3",
57
+ "vite": "^6.0.0",
58
+ "vitest": "^4.1.8",
59
+ "ws": "8.21.0",
60
+ "@deepseek-ai/dsh-launch-environment": "workspace:^",
61
+ "@deepseek-ai/dsh-subprocess-local": "workspace:^",
62
+ "@deepseek-ai/dsh-experimental-auto-review": "workspace:^"
63
+ }
64
+ }
apps/web/product-isolation.ts ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Vite adapters for recording product chunk, asset, CSS, and worker inputs. */
2
+
3
+ import type { Plugin } from 'vite'
4
+ import { WebProductBundleIsolation } from '../../scripts/web-product-bundle-isolation.ts'
5
+
6
+ const DEPENDENCY_ANALYSIS_PLUGIN = 'dsh-browser-dependency-analysis'
7
+
8
+ /**
9
+ * Identify a dependency-disclosure walk that deliberately leaves third-party imports external.
10
+ * @returns Analysis intent that refuses builds which can write output.
11
+ */
12
+ export function browserDependencyAnalysis(): Plugin {
13
+ return {
14
+ name: DEPENDENCY_ANALYSIS_PLUGIN,
15
+ apply: 'build',
16
+ configResolved(config) { requireNonWritingAnalysis(config.build.write) },
17
+ }
18
+ }
19
+
20
+ function requireNonWritingAnalysis(write: boolean): void {
21
+ if (write !== false) throw new Error('browser dependency analysis requires build.write: false')
22
+ }
23
+
24
+ /**
25
+ * Reject experimental inputs reachable from the built default Web page.
26
+ * @param repository - repository root supplying package ownership.
27
+ * @param webRoot - Vite root containing index.html and public assets.
28
+ * @returns Build plugins that preserve Vite's output and fail before publication on a violation.
29
+ */
30
+ export function productWebBundleIsolation(repository: string, webRoot: string): Plugin[] {
31
+ const inputs = new WebProductBundleIsolation(repository, webRoot)
32
+ let dependencyAnalysis = false
33
+ return [{
34
+ name: 'dsh-product-web-chunk-inputs',
35
+ apply: 'build',
36
+ generateBundle: {
37
+ order: 'pre',
38
+ handler(_options, bundle) {
39
+ if (!dependencyAnalysis) inputs.captureChunks(bundle)
40
+ },
41
+ },
42
+ }, {
43
+ name: 'dsh-product-web-bundle-isolation',
44
+ apply: 'build',
45
+ enforce: 'post',
46
+ config(config) {
47
+ const renderBuiltUrl = config.experimental?.renderBuiltUrl
48
+ const workerPlugins = config.worker?.plugins
49
+ return {
50
+ experimental: {
51
+ renderBuiltUrl(filename, context) {
52
+ inputs.assetReference(filename, context.hostId, context.type)
53
+ return renderBuiltUrl?.(filename, context)
54
+ },
55
+ },
56
+ worker: {
57
+ plugins: () => [...workerPlugins?.() ?? [], {
58
+ name: 'dsh-worker-build-inputs',
59
+ generateBundle: {
60
+ order: 'post',
61
+ handler(_options, bundle) { inputs.workerBundle(bundle, this.getWatchFiles()) },
62
+ },
63
+ }],
64
+ },
65
+ }
66
+ },
67
+ configResolved(config) {
68
+ dependencyAnalysis = config.plugins.some(plugin => plugin.name === DEPENDENCY_ANALYSIS_PLUGIN)
69
+ if (dependencyAnalysis) {
70
+ requireNonWritingAnalysis(config.build.write)
71
+ return
72
+ }
73
+ const cssPlugins = config.plugins.filter(plugin => plugin.name === 'vite:css')
74
+ const css = cssPlugins[0]
75
+ if (cssPlugins.length !== 1 || css?.transform === undefined) {
76
+ throw new Error('Web product isolation: Vite CSS input instrumentation is unavailable')
77
+ }
78
+ const transform = typeof css.transform === 'function' ? css.transform : css.transform.handler
79
+ css.transform = {
80
+ ...typeof css.transform === 'function' ? {} : css.transform,
81
+ handler(code, id, options) {
82
+ inputs.cssTransform(id)
83
+ const context = new Proxy(this, {
84
+ get(target, property) {
85
+ if (property === 'addWatchFile') return (file: string): void => {
86
+ inputs.cssDependency(id, file)
87
+ target.addWatchFile(file)
88
+ }
89
+ const value: unknown = Reflect.get(target, property, target)
90
+ return typeof value === 'function' ? value.bind(target) : value
91
+ },
92
+ })
93
+ return transform.call(context, code, id, options)
94
+ },
95
+ }
96
+ },
97
+ buildStart() { inputs.reset() },
98
+ generateBundle: {
99
+ order: 'post',
100
+ handler(_options, bundle) {
101
+ if (!dependencyAnalysis) inputs.verify(bundle, id => this.getModuleInfo(id))
102
+ },
103
+ },
104
+ }]
105
+ }
apps/web/tsconfig.json ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "outDir": "lib/types",
6
+ "jsx": "react-jsx",
7
+ "lib": [
8
+ "ES2024",
9
+ "DOM",
10
+ "DOM.Iterable"
11
+ ],
12
+ "types": [
13
+ "node"
14
+ ]
15
+ },
16
+ "include": [
17
+ "src",
18
+ "tests"
19
+ ],
20
+ // The web e2e lane (scaffold + replay specs) boots the host spine and reads
21
+ // its Context merges — host-plane programs, checked in tsconfig.host.json;
22
+ // this client-registered project must not also hold them (one program
23
+ // cannot see both sides of the cordis Context merges).
24
+ "exclude": [
25
+ "tests/default-product-isolation.e2e.ts",
26
+ "tests/diff-context.e2e.ts",
27
+ "tests/scaffold.ts",
28
+ "tests/auto-review-fixture.ts",
29
+ "tests/scaffold-generation.spec.ts",
30
+ "tests/scaffold-hermetic.e2e.ts",
31
+ "tests/startup-rpc-budget.e2e.ts",
32
+ "tests/minimal-preset.snapshot.ts",
33
+ "tests/message-feedback-protocol.snapshot.ts",
34
+ "tests/preset-migration.snapshot.ts",
35
+ "tests/live-interactions.e2e.ts",
36
+ "tests/question-composer.e2e.ts",
37
+ "tests/approval-composer.e2e.ts",
38
+ "tests/ptc-escalation.e2e.ts",
39
+ "tests/plan-control-row.e2e.ts",
40
+ "tests/plan-review.e2e.ts",
41
+ "tests/steering.e2e.ts",
42
+ "tests/navigation-panes.e2e.ts",
43
+ "tests/chat-scroll-fixture.ts",
44
+ "tests/trajectory-virtualization.e2e.ts",
45
+ "tests/lifecycle-chrome.e2e.ts",
46
+ "tests/details-session-lifecycle.e2e.ts",
47
+ "tests/document-preview.e2e.ts",
48
+ "tests/plugin-config.e2e.ts",
49
+ "tests/settings-chrome.e2e.ts",
50
+ "tests/models-settings.e2e.ts",
51
+ "tests/deepseek-messages-settings.e2e.ts",
52
+ "tests/deepseek-messages-chat.e2e.ts",
53
+ "tests/models-settings-recovery.e2e.ts",
54
+ "tests/default-model.e2e.ts",
55
+ "tests/github-ready-review.e2e.ts",
56
+ "tests/streaming-fence-highlight.e2e.ts",
57
+ "tests/declared-reasoning.e2e.ts",
58
+ "tests/onboarding-deepseek-config.e2e.ts",
59
+ "tests/onboarding-usable-provider.e2e.ts",
60
+ "tests/remote-welcome.e2e.ts",
61
+ "tests/workspace-new-session-folding.e2e.ts",
62
+ "tests/workspace-management.e2e.ts",
63
+ "tests/workspace-recency.e2e.ts",
64
+ "tests/session-unarchive.e2e.ts",
65
+ "tests/replay-round-trip.e2e.ts",
66
+ "tests/hmr-live.e2e.ts",
67
+ "tests/preview-boot.e2e.ts",
68
+ "tests/seeded-history.e2e.ts",
69
+ "tests/stats-paged-history.e2e.ts",
70
+ "tests/sidebar-scrollbar.e2e.ts",
71
+ "tests/rail-search-expand.e2e.ts",
72
+ "tests/conversation-column-overflow.e2e.ts",
73
+ "tests/ptc-round.e2e.ts",
74
+ "tests/present.e2e.ts",
75
+ "tests/present-svg.e2e.ts",
76
+ "tests/composer-draft-scroll.e2e.ts",
77
+ "tests/composer-placeholder.e2e.ts",
78
+ "tests/cordis-tool-round.e2e.ts",
79
+ "tests/web-search-round.e2e.ts",
80
+ "tests/file-upload-round.e2e.ts",
81
+ "tests/message-actions.e2e.ts",
82
+ "tests/open-in-app-ssh.e2e.ts",
83
+ "tests/message-feedback.e2e.ts",
84
+ "tests/markdown-images.e2e.ts",
85
+ "tests/reference-composer.e2e.ts",
86
+ "tests/markdown-wide-table.e2e.ts",
87
+ "tests/math-rendering.e2e.ts",
88
+ "tests/markdown-cjk-strong.e2e.ts",
89
+ "tests/markdown-inline-code-links.e2e.ts",
90
+ "tests/clickable-links-gallery.e2e.ts",
91
+ "tests/queue-actions.e2e.ts",
92
+ "tests/queue-image.e2e.ts",
93
+ "tests/skill-invocation-policy.e2e.ts",
94
+ "tests/skill-user-invoke.e2e.ts",
95
+ "tests/permission-policy-context.e2e.ts",
96
+ "tests/access-confirmation.e2e.ts",
97
+ "tests/agent-preset-selection.e2e.ts",
98
+ "tests/agent-preset-authoring.e2e.ts",
99
+ "tests/shipped-composition.e2e.ts",
100
+ "tests/schedule-after.e2e.ts",
101
+ "tests/feedback-command.e2e.ts",
102
+ "tests/feedback-release.e2e.ts",
103
+ "tests/agent-team-panel.e2e.ts",
104
+ "tests/sidebar-right.e2e.ts",
105
+ "tests/sidebar-terminal.e2e.ts",
106
+ "tests/startup-auto-selection.e2e.ts",
107
+ "tests/produced-files.e2e.ts",
108
+ "tests/produced-file-mentions.e2e.ts",
109
+ "tests/goal-bar.e2e.ts",
110
+ "tests/goal-command-presentation.e2e.ts",
111
+ "tests/subagent-conversation.e2e.ts",
112
+ "tests/subagent-interrupt.e2e.ts",
113
+ "tests/subagent-interrupt-ui.e2e.ts",
114
+ "tests/sidebar-subagent-activity.e2e.ts",
115
+ "tests/background-job-list.e2e.ts",
116
+ "tests/auto-review-denial.e2e.ts",
117
+ "tests/bash-abort-row.e2e.ts",
118
+ "tests/skill-tool-row.e2e.ts",
119
+ "tests/turn-tail-actions.e2e.ts",
120
+ "tests/goal-multi-turn-actions.e2e.ts",
121
+ "tests/chat-scroll-fixture.ts",
122
+ "tests/chat-scroll-contract.e2e.ts",
123
+ "tests/chat-long-interactions.e2e.ts",
124
+ "tests/chat-continuous-conversation.e2e.ts",
125
+ "tests/composer-tab-geometry.e2e.ts",
126
+ "tests/complex-history.perf.ts",
127
+ "tests/pwsh-terminal.e2e.ts",
128
+ "tests/workflow-run.e2e.ts"
129
+ ],
130
+ "references": [
131
+ { "path": "../../packages/experimental/auto-review" },
132
+ {
133
+ "path": "../../packages/client/store"
134
+ },
135
+ {
136
+ "path": "../../packages/client/web"
137
+ },
138
+ {
139
+ "path": "../../packages/client/modules"
140
+ },
141
+ {
142
+ "path": "../../packages/test-support/client-runtime"
143
+ },
144
+ {
145
+ "path": "../../packages/test-support/remote-mock"
146
+ }
147
+ ]
148
+ }
apps/web/vite.config.ts ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { defineConfig } from 'vite'
5
+ import type { Plugin } from 'vite'
6
+ import react from '@vitejs/plugin-react'
7
+ import { clientBuildEnvironmentDefines } from '../../scripts/client-build-environment.ts'
8
+ import { productWebBundleIsolation } from './product-isolation.ts'
9
+
10
+ const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
11
+ const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
12
+ + 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
13
+ + 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.'
14
+ const DEFAULT_CLIENT_TITLE = 'DSH Local Build'
15
+
16
+ /** Escape build-time text before placing it in the HTML title element. */
17
+ function escapeHtmlText(value: string): string {
18
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
19
+ }
20
+
21
+ /** Project the public build title into the initial HTML document. */
22
+ function clientDocumentTitle(): Plugin {
23
+ const title = escapeHtmlText(process.env.DSH_CLIENT_TITLE ?? DEFAULT_CLIENT_TITLE)
24
+ return {
25
+ name: 'dsh-client-document-title',
26
+ transformIndexHtml(html) {
27
+ return html.replace('<title>DSH Local Build</title>', `<title>${title}</title>`)
28
+ },
29
+ }
30
+ }
31
+
32
+ /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
33
+ function rejectStandaloneServe(): Plugin {
34
+ return {
35
+ name: 'dsh-reject-standalone-web-serve',
36
+ config(_config, env) {
37
+ if (env.command === 'serve') throw new Error(STANDALONE_ERROR)
38
+ },
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Emit preview.html beside index.html: the built index page with one module
44
+ * script — the worker bootstrap entry — spliced ahead of its entry tag. Both
45
+ * pages share every chunk; the extra tag is the only difference, so the
46
+ * static worker deployment ships the served page verbatim plus its
47
+ * bootstrap.
48
+ */
49
+ function emitPreviewPage(): Plugin {
50
+ let bootstrapFile: string | undefined
51
+ let write = true
52
+ let written = false
53
+ let outputDirectory = ''
54
+ return {
55
+ name: 'dsh-emit-preview-page',
56
+ configResolved(config) {
57
+ write = config.build.write
58
+ outputDirectory = resolve(config.root, config.build.outDir)
59
+ },
60
+ buildStart() {
61
+ bootstrapFile = undefined
62
+ written = false
63
+ },
64
+ generateBundle(_options, bundle) {
65
+ if (!write) return
66
+ for (const item of Object.values(bundle)) {
67
+ if (item.type === 'chunk' && item.isEntry && item.name === 'bootstrap') bootstrapFile = item.fileName
68
+ }
69
+ if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle')
70
+ },
71
+ writeBundle() { written = true },
72
+ async closeBundle() {
73
+ if (!write || !written || bootstrapFile === undefined) return
74
+ const page = await readFile(resolve(outputDirectory, 'index.html'), 'utf8')
75
+ const anchor = page.indexOf('<script type="module"')
76
+ if (anchor === -1) throw new Error('vite: built index.html lost its module entry tag')
77
+ const tag = `<script type="module" crossorigin src="./${bootstrapFile}"></script>`
78
+ await writeFile(resolve(outputDirectory, 'preview.html'), `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`)
79
+ },
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Vendor-chunk membership, by exact npm package name — the heavy render
85
+ * families (math, highlight, markdown) that change only on dependency bumps.
86
+ * Only packages workspace code imports DIRECTLY need listing: their private
87
+ * transitive dependencies (oniguruma machinery, character tables, …) are
88
+ * imported solely by these and rollup's chunk coloring pulls them into
89
+ * vendor automatically. A dependency shared with index-side code falls back
90
+ * to index — a few kB of dilution, never a correctness problem. Anything not
91
+ * listed (react family, the vendored cordis workspace, tiny helpers like
92
+ * anser/clsx, all workspace code) stays in the default `index` chunk, so
93
+ * editing shell code re-hashes only index and returning clients keep the
94
+ * cached vendor chunk.
95
+ *
96
+ * Every member must be React-free. A package that
97
+ * imports react/jsx-runtime must never be listed — rollup folds a module
98
+ * shared between the entry and a manual chunk into the manual chunk, so one
99
+ * react-importing member would drag the single shared react copy into
100
+ * vendor. The React side of markdown/math rendering is workspace code and
101
+ * rides index.
102
+ */
103
+ const VENDOR_PACKAGES: ReadonlySet<string> = new Set([
104
+ // math
105
+ 'katex',
106
+ // syntax highlight (@shikijs/langs is handled separately below —
107
+ // lazy grammars must not land here)
108
+ 'shiki',
109
+ // markdown parse pipeline (micromark/mdast; the incremental React renderer
110
+ // over it is workspace code)
111
+ 'mdast-util-from-markdown',
112
+ 'mdast-util-gfm',
113
+ 'mdast-util-math',
114
+ 'micromark-core-commonmark',
115
+ 'micromark-extension-gfm',
116
+ 'micromark-extension-math',
117
+ 'micromark-factory-space',
118
+ 'micromark-util-character',
119
+ 'micromark-util-classify-character',
120
+ 'micromark-util-sanitize-uri',
121
+ 'micromark-util-symbol',
122
+ 'micromark-util-types',
123
+ ])
124
+
125
+ /**
126
+ * Boot grammars statically imported by ui-primitives' highlight.ts
127
+ * (`@shikijs/langs/typescript` → `dist/typescript.mjs`, etc.). They live in
128
+ * the same package as the lazy read-card grammars, but unlike those they are
129
+ * part of the initial load and belong in the vendor chunk; the lazy ones must
130
+ * stay unassigned so each keeps its own on-demand chunk.
131
+ */
132
+ const BOOT_GRAMMAR_FILES: readonly string[] = [
133
+ 'dist/typescript.mjs',
134
+ 'dist/shellscript.mjs',
135
+ 'dist/json.mjs',
136
+ ]
137
+
138
+ /** Font asset extensions routed to assets/fonts/ (KaTeX's woff2/woff/ttf faces). */
139
+ const FONT_EXTENSIONS: readonly string[] = ['.woff2', '.woff', '.ttf']
140
+
141
+ /**
142
+ * npm package name of a resolved module id: the segment after the last
143
+ * `node_modules/`. pnpm nests the real package under an inner node_modules.
144
+ */
145
+ function npmPackageOf(id: string): string | undefined {
146
+ const parts = id.split('/node_modules/')
147
+ if (parts.length === 1) return undefined
148
+ const [first, second] = parts[parts.length - 1].split('/')
149
+ if (first.startsWith('.')) return undefined // .pnpm store segment, not a package
150
+ if (first.startsWith('@')) return second === undefined ? undefined : `${first}/${second}`
151
+ return first
152
+ }
153
+
154
+ export default defineConfig({
155
+ // Relative asset URLs: preview.html mounts the same output under any base
156
+ // directory, and the served index resolves identically from the site root.
157
+ base: './',
158
+ plugins: [
159
+ rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(),
160
+ productWebBundleIsolation(src('../..'), src('.')),
161
+ ],
162
+ build: {
163
+ // The worker bootstrap holds its page at top-level await; Vite's default
164
+ // `modules` target (es2020-era) rejects that syntax.
165
+ target: 'es2022',
166
+ sourcemap: true,
167
+ rollupOptions: {
168
+ input: {
169
+ index: src('./index.html'),
170
+ // Standalone entry, not an index.html script tag: Vite folds every
171
+ // module tag of one page into a single synthetic entry, and only a
172
+ // separate input keeps the shared page chunks bootstrap-free.
173
+ bootstrap: src('./src/preview.ts'),
174
+ },
175
+ output: {
176
+ // The worker-preview surface groups under dist/preview/ (the page
177
+ // itself stays at dist/preview.html), so the published payload can
178
+ // exclude it as one directory.
179
+ entryFileNames(chunk): string {
180
+ return chunk.name === 'bootstrap' ? 'preview/[name]-[hash].js' : 'assets/[name]-[hash].js'
181
+ },
182
+ // Output layout: the two main chunks stay at assets/ root; lazy
183
+ // @shikijs/langs grammar chunks group under assets/langs/; fonts
184
+ // (all KaTeX faces referenced by vendor.css) group under
185
+ // assets/fonts/. Sourcemaps need no arrangement: rollup writes each
186
+ // .map next to its js and references it by bare relative filename.
187
+ chunkFileNames(chunk): string {
188
+ // Grammar chunks are recognized by their member modules, not the
189
+ // facade: shared embedded-grammar chunks (e.g. html+javascript,
190
+ // split out because php/ruby/mdx embed them) have no facade at all.
191
+ // index and vendor are excluded by name — vendor legitimately
192
+ // carries the three boot grammars.
193
+ if (chunk.name === 'index' || chunk.name === 'vendor') return 'assets/[name]-[hash].js'
194
+ const isLangChunk = chunk.moduleIds.some(id => id.includes('/node_modules/@shikijs/langs/'))
195
+ return isLangChunk ? 'assets/langs/[name]-[hash].js' : 'assets/[name]-[hash].js'
196
+ },
197
+ assetFileNames(asset): string {
198
+ const fileName = asset.names[0] ?? ''
199
+ const isFont = FONT_EXTENSIONS.some(ext => fileName.endsWith(ext))
200
+ return isFont ? 'assets/fonts/[name]-[hash][extname]' : 'assets/[name]-[hash][extname]'
201
+ },
202
+ manualChunks(id: string): string | undefined {
203
+ const pkg = npmPackageOf(id)
204
+ if (pkg === undefined) return undefined // workspace + vendored cordis: index
205
+ if (pkg === '@shikijs/langs') {
206
+ return BOOT_GRAMMAR_FILES.some(file => id.endsWith(`/${file}`)) ? 'vendor' : undefined
207
+ }
208
+ return VENDOR_PACKAGES.has(pkg) ? 'vendor' : undefined
209
+ },
210
+ },
211
+ },
212
+ },
213
+ worker: {
214
+ // The preview worker rides dist/preview/ with the rest of that surface.
215
+ rollupOptions: { output: { entryFileNames: 'preview/[name]-[hash].js' } },
216
+ },
217
+ resolve: {
218
+ // One instance per shared npm identity: a bare specifier otherwise resolves
219
+ // from the importer's directory, so a diverging range ships a second React
220
+ // and splits hook and element identity. Entries are package ids — they cover
221
+ // react/jsx-runtime and react-dom/client — and resolve from this package's
222
+ // node_modules, so react must stay a devDependency here and any watcher must
223
+ // run vite from this directory (scripts/dev-web.ts). Workspace packages need
224
+ // no entry: pnpm links each of them to a single directory.
225
+ dedupe: ['react', 'react-dom'],
226
+ // Workspace packages are consumed as built lib products: each resolves
227
+ // through its own package.json exports from the importer's directory, and
228
+ // CSS still rides Vite's pipeline because the client build preset emits it
229
+ // beside the bundle. Plugin packages never enter this graph; they arrive as
230
+ // runtime bundles through the client module system. The remaining alias
231
+ // browserizes the vendored Cordis Loader's only Node import.
232
+ alias: [
233
+ { find: /^node:module$/, replacement: src('./src/node-module-stub.ts') },
234
+ ],
235
+ },
236
+ define: {
237
+ ...clientBuildEnvironmentDefines(process.env),
238
+ // vendored loader internal.ts: fromInternal() probes the Node major —
239
+ // "0.0.0" takes neither branch, returning undefined (exactly the empty
240
+ // internal slot the shell boot fills with the client module loader).
241
+ 'process.versions.node': '"0.0.0"',
242
+ 'process.execArgv': '[]',
243
+ // vendored loader index.ts: envData falls to its default branch.
244
+ 'process.env.CORDIS_SHARED': 'undefined',
245
+ },
246
+ })
benchmarks/AGENTS.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md — Performance Benchmarks
2
+
3
+ This tree owns required, repository-level performance gates whose measured user path crosses package ownership. Package-local diagnostics remain beside their owners and use the `.perf.ts` suffix instead of joining `test:bench`.
4
+
5
+ - Organize benchmarks by measured user path, one directory per path. Do not mirror the package tree.
6
+ - Host cases use `*.bench.ts`; Client-face cases use `*.bench.client.ts`. Worker, fixture, and support modules do not carry a benchmark suffix.
7
+ - The private `@deepseek-ai/dsh-benchmarks` workspace owns benchmark-only dependencies. `test:bench` builds workspace libraries and `benchmarks/.dsh-build/` workers before Vitest orchestration. Timed CPU work runs in those workers under plain Node, without a TypeScript loader; runtime package imports must resolve to built `lib/` entries.
8
+ - Browser workflow cases drive built Client bundles through the shared shipped-composition Web scaffold. Report its source-resolved test Host separately from published-Host evidence; two animation frames prove a rendering opportunity, not hardware presentation. Use fresh browsers and private scaffold worlds per sample.
9
+ - Synthesize fixed inputs from reviewed constants. Never use recorded Sessions, user material, ambient repositories, or network services.
10
+ - Run process-level wall-clock and retained-memory samples in fresh children with private `mkdtemp` roots. Pure synchronous folds create a fresh object graph per sample and must not mutate process-global state. Bound every child, await exit, and remove owned roots after failure as well as success.
11
+ - Record reference-machine expectations separately from the shared CI time scale and variance headroom. Do not apply the time scale to memory or dimensionless ratios.
12
+ - Report enough raw and aggregate measurements to explain each verdict, including whether a budget uses a median, minimum, absolute value, or ratio. Enforce reviewed source constants; environment variables must not override performance budgets.
13
+ - Keep scenario-specific support beside its benchmark. Move a helper into `benchmarks/support/` only after at least two benchmark directories require the same behavior.
14
+ - Exercise production entry points. Do not copy product algorithms, add production exports solely for measurement, or turn benchmark completion into duplicate semantic assertions.
15
+ - A compiled worker may bundle a private integration adapter when no public Node export exposes the measured user path. Keep package imports external so product services resolve through their built package exports.
16
+ - Record the workload, timing boundary, memory endpoint, calibration reference, alternatives, and known exclusions in the owning Agent Note.
benchmarks/package.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@deepseek-ai/dsh-benchmarks",
3
+ "version": "0.1.3-alpha.1",
4
+ "license": "MIT",
5
+ "private": true,
6
+ "type": "module",
7
+ "devDependencies": {
8
+ "playwright": "^1.49.0",
9
+ "@xterm/headless": "^6.0.0",
10
+ "@deepseek-ai/dsh-terminal": "workspace:^",
11
+ "@deepseek-ai/dsh-subprocess": "workspace:^",
12
+ "@deepseek-ai/dsh-llm-replay": "workspace:^",
13
+ "@deepseek-ai/cordis": "workspace:^",
14
+ "@deepseek-ai/dsh-agent": "workspace:^",
15
+ "@deepseek-ai/dsh-agent-loop": "workspace:^",
16
+ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
17
+ "@deepseek-ai/dsh-agent-presets": "workspace:^",
18
+ "@deepseek-ai/dsh-api-session-controller": "workspace:^",
19
+ "@deepseek-ai/dsh-client-store": "workspace:^",
20
+ "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
21
+ "@deepseek-ai/dsh-deque": "workspace:^",
22
+ "@deepseek-ai/dsh-lazy-require": "workspace:^",
23
+ "@deepseek-ai/dsh-llm": "workspace:^",
24
+ "@deepseek-ai/dsh-sdk-client": "workspace:^",
25
+ "@deepseek-ai/dsh-session": "workspace:^",
26
+ "@deepseek-ai/dsh-session-persistence": "workspace:^",
27
+ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
28
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
29
+ "@deepseek-ai/dsh-session-query": "workspace:^",
30
+ "@deepseek-ai/dsh-session-stats": "workspace:^",
31
+ "@deepseek-ai/dsh-session-title": "workspace:^",
32
+ "@deepseek-ai/dsh-session-turn-outline": "workspace:^",
33
+ "@deepseek-ai/dsh-subagent": "workspace:^",
34
+ "@deepseek-ai/dsh-tools": "workspace:^",
35
+ "@deepseek-ai/dsh-token-meter": "workspace:^",
36
+ "@deepseek-ai/dsh-typert-protocol": "workspace:^"
37
+ },
38
+ "peerDependencies": {
39
+ "@deepseek-ai/cordis": "workspace:^"
40
+ }
41
+ }
benchmarks/tsdown.config.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'tsdown'
2
+
3
+ const shared = {
4
+ format: 'esm' as const,
5
+ platform: 'node' as const,
6
+ target: 'es2024',
7
+ fixedExtension: false,
8
+ dts: false,
9
+ deps: {
10
+ neverBundle: [/^@deepseek-ai\//],
11
+ onlyBundle: false as const,
12
+ },
13
+ }
14
+
15
+ /** Compile measured benchmark workers while keeping workspace packages on their built `lib` entries. */
16
+ export default defineConfig([
17
+ {
18
+ ...shared,
19
+ entry: { 'terminal-io.worker': 'terminal-io/terminal-io.worker.ts' },
20
+ outDir: '.dsh-build/terminal-io',
21
+ clean: true,
22
+ tsconfig: 'tsconfig.host.json',
23
+ },
24
+ {
25
+ ...shared,
26
+ entry: { 'reconnect.worker': 'active-stream-reconnect/reconnect.worker.client.ts' },
27
+ outDir: '.dsh-build/active-stream-reconnect',
28
+ clean: true,
29
+ tsconfig: 'tsconfig.client.json',
30
+ },
31
+ {
32
+ ...shared,
33
+ entry: {
34
+ 'agent-continuation.worker': 'agent-continuation/agent-continuation.worker.ts',
35
+ 'child-catalog.worker': 'agent-continuation/child-catalog.worker.ts',
36
+ 'profile-continuation.worker': 'agent-continuation/profile-continuation.worker.ts',
37
+ 'profile-adapter': 'agent-continuation/profile-adapter.ts',
38
+ },
39
+ outDir: '.dsh-build/agent-continuation',
40
+ clean: true,
41
+ tsconfig: 'tsconfig.host.json',
42
+ },
43
+ {
44
+ ...shared,
45
+ entry: { 'session-open.worker': 'session-open/session-open.worker.ts' },
46
+ outDir: '.dsh-build/session-open',
47
+ clean: true,
48
+ tsconfig: 'tsconfig.host.json',
49
+ },
50
+ {
51
+ ...shared,
52
+ entry: {
53
+ 'conversation-fold.worker': 'conversation-fold/conversation-fold.worker.client.ts',
54
+ },
55
+ outDir: '.dsh-build/conversation-fold',
56
+ clean: true,
57
+ tsconfig: 'tsconfig.client.json',
58
+ },
59
+ ])
docs/AGENTS.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md — The documentation standard
2
+
3
+ This file defines document structure, Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc](../.agents/skills/dsh-doc/SKILL.md) for placement and validation, and [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage and editorial judgment; the [doc-tiers Agent Note](../.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale.
4
+
5
+ ## Document structure
6
+
7
+ These rules apply to human-facing documentation; [Agent Notes](../.agents/notes/README.md) remain outside their scope. A [postmortem](postmortem/README.md) is an incident-scoped reference; chronology records evidence, not a teaching sequence. A document's subject and tree position fix its scope: describe its own subject at appropriate detail and direct children only by purpose, responsibility, and high-level behavior; link to the owning descendant for lower-level detail. Document type does not widen that scope. A reference may be exhaustive only about its own subject. Testing mechanisms, fixtures, and harnesses belong at the lowest owning level; higher documents link there.
8
+
9
+ Classify every in-scope document as a tutorial or reference. Tutorials follow an ordered path to an outcome and introduce only what each step needs. References define a lookup scope and current behavior without a teaching sequence. Separate substantial tutorial and reference content; label a section when either part is small.
10
+
11
+ Before writing a tutorial, privately classify the reader's starting knowledge and each concept as beginner, intermediate, or advanced. Establish prerequisites before dependent concepts, increase difficulty gradually, and move unnecessary advanced material to a later tutorial or reference.
12
+
13
+ Author in this order: locate the document in the tree; set its permitted detail; choose tutorial or reference; for a tutorial, order concepts by prerequisite and difficulty; relocate descendant-owned detail; replace lower-level explanations with links to their owners.
14
+
15
+ ## The tier taxonomy: one home per fact
16
+
17
+ Each fact has one home: the tier whose job it is; elsewhere, link there.
18
+
19
+ | Tier | Job | Does NOT belong there |
20
+ |---|---|---|
21
+ | Root `AGENTS.md` | Standing orders: rules an agent needs in context in every session, one to three lines each, linking its home | Stories, worked examples, situational procedures, anything restated from a linked home |
22
+ | Subtree `AGENTS.md` (`packages/`, `docs/`, `.agents/notes/`) | Orders specific to that subtree | Repo-wide rules the root file already carries |
23
+ | [architecture.md](architecture.md) | Ordered map: composition, core packages, loop, seams, extension points; read before changing `packages/` | Type definitions (→ subsystems), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations |
24
+ | [subsystems/](subsystems/README.md) | One reference page per subsystem: type definitions, semantics, and the generated Cordis API | Behavior narration (→ architecture.md) |
25
+ | [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and required verification; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority |
26
+ | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — |
27
+ | [Persistence history](persistence-changes/README.md) and [format references](persistence-changes/historical-formats/README.md) | Type-change acknowledgements, release comparisons, and complete historical format schemas | Behavior-only changes; current runtime contracts |
28
+ | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the Agent Note each guide links) |
29
+ | [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history |
30
+ | Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns |
31
+ | [development.md](development.md) | Contributor setup, daily workflow, and a summary of CI; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ Agent Notes), check-by-check lists that drift from `package.json` scripts |
32
+ | Generated reference: the per-page `cordis-surface` regions in [subsystems/](subsystems/README.md), the [Cordis core API + inherited tier](cordis-api/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive English sources regenerated from source and freshness-gated; reviewed Chinese counterparts follow the [pairing workflow](i18n/README.md#scope-and-exclusions) | Hand edits to generated English sources or regions; Chinese counterparts update through pairing only |
33
+ | Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) |
34
+
35
+ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type definitions → subsystems; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link.
36
+
37
+ ## Writing rules
38
+
39
+ - **Document current state.** Keep history in commits, PRs, Agent Notes, postmortems, or scoped persistence records. Other prose names live mechanisms, not changes or stack positions. General Session-format prose links [version/status authority](session-format-status.md); retain numbers for version-specific contracts, examples, or evidence.
40
+ - **Every non-trivial change includes at least one Agent Note in the same PR.** Update the owning note or add one; only mechanical/local edits are exempt ([scope](../.agents/notes/README.md#when-to-write-one)).
41
+ - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit.
42
+ - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).
43
+ - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)).
44
+ - **Pairs update together**: [Terminology-guided](i18n/terminology.md), single-pass active-agent work repositions first-use annotations, preserves untouched prose, and re-records; `dsh-translate-docs` remains user-invoked ([contract](i18n/README.md)).
45
+ - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, failure, timing, ownership, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details.
46
+ - Write directly: name actors and facts ([decision](../.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). Reserve `seam` for the defined capability. Name the exact check, type, API, operation, or behavior instead of metaphorical "gate", "vocabulary", or "surface".
47
+
48
+ ## Wordcount Budgets
49
+
50
+ [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) sets standing-doc ceilings; `pnpm run verify-doc-budgets` rejects excess or missing files.
51
+
52
+ When the gate goes red:
53
+
54
+ 1. **Relocate** content that belongs in another tier; leave a one-line link if needed.
55
+ 2. **Condense** content that belongs here but can be shorter.
56
+ 3. **Raise** the ceiling only when the words need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug.
57
+
58
+ Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the document still has room. Targets: root `AGENTS.md` ≤ 1,950; `architecture.md` ≤ 2,400; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 750 and this file ≤ 1,320; `packages/README.md` ≤ 994; plus `cordis-primer.md` 600, `defensive-patterns.md` 550, `testing.md` 1,300, `examples/AGENTS.md` 310. Review governs unbudgeted tiers.
59
+
60
+ ## The slop checklist
61
+
62
+ Hunt these in any doc; [dsh-doc](../.agents/skills/dsh-doc/SKILL.md) runs this list as an audit:
63
+
64
+ - Duplicated rules: search a distinctive phrase; keep one home and link the rest.
65
+ - History outside its permitted tier: state current facts and link the historical owner.
66
+ - Implementation-status annotations in prose or diagrams ("implemented!", "future: …"). Status rots; the repo layout and package manifests carry it.
67
+ - Hand-restated catalogs, JSDoc, or inventories of tests, packages, and status when source or a generator is authoritative.
68
+ - Reasoning transcripts: step-by-step implementation narration, proof of obvious branches, test walkthroughs, or rejected local alternatives. Keep the resulting contract or durable rationale; delete the path used to derive it.
69
+ - Rationale repeated beside sibling methods instead of once at the owning capability or helper.
70
+ - Paragraph walls: one paragraph carrying several rules and parenthetical asides. Split it or demote the detail to its home.
71
+ - Emphasis inflation: bold, CAPS, or "critically" everywhere means nothing stands out. Reserve emphasis for the clause that changes behavior.
72
+ - Spec-speak in `implemented/` Agent Notes: "should", migration plans, acceptance checklists. An implemented Agent Note describes what is, per the [implemented-note instructions](../.agents/notes/implemented/AGENTS.md).
73
+
74
+ ## Repository references
75
+
76
+ Use relative Markdown links for current files and tags or PR numbers for historical references. `verify-md-links` checks local targets. [Reference validation](../scripts/verify-repository-references.ts) rejects actual commit identifiers and disallowed organization URLs in maintained files.
docs/agent-lifecycle.i18n.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write docs/agent-lifecycle.md
5
+ agent-lifecycle.md: 6ffe3c2b47e766ac985b3192a1c08787821fc46e
6
+ agent-lifecycle.zh.md: c4b2fa70dbdb0487c3871b6e65227eac8b062e24