Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl
#	examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl
#	examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl
#	examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl
#	packages/acp/acp/README.i18n.yaml
#	packages/acp/acp/src/index.ts
#	packages/client/connection/src/client/index.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/queue-store.spec.ts
#	packages/client/ui-conversation/src/client/input/contract.ts
#	packages/client/ui-goal/README.i18n.yaml
#	packages/compact/compact-basic/src/index.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/tmux-context/tests/tmux-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent-loop/tests/agent.spec.ts
#	packages/core/agent-loop/tests/loop.spec.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/session/README.i18n.yaml
#	packages/core/session/README.md
#	packages/core/session/README.zh.md
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/goal/tool-goal/README.i18n.yaml
#	packages/goal/tool-goal/README.md
#	packages/goal/tool-goal/README.zh.md
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/events.schema.ts
#	packages/host/apiproxy/src/api/events.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/tests/api-proxy-commands.spec.ts
#	packages/host/apiproxy/tests/api-proxy-projections.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/session-persistence/session-checkpoint-policy/src/index.ts
#	packages/session-persistence/session-persistence/tests/coordinator-contract.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/subagent/subagent-inprocess/README.i18n.yaml
#	packages/subagent/subagent-inprocess/README.md
#	packages/subagent/subagent-inprocess/README.zh.md
#	packages/subagent/subagent-inprocess/src/index.ts
#	packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
#	packages/support/acp-snapshot/src/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
_Kerman
2026-08-03 10:56:09 +08:00
605 changed files with 25740 additions and 2265 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 774bb08d2e923e15db23b3980eff7759b45fc146
README.zh.md: de096659679df3d73f9aa76c9563b31c8ecb3cb6
README.md: 76d9ed65398322cb9244a31661ee59b60c23f793
README.zh.md: 16a7a4ec52b830e45c32a61a103d87be5941ab3b

View File

@@ -11,7 +11,7 @@ The TUI surface:
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session;
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below);
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. The shipped tree's Cordis HMR keeps `config.yaml` live; an explicit `--config` tree replaces that overlay, and a tree without HMR reads it at startup only.
- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context.
- registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible.
@@ -21,10 +21,22 @@ The TUI surface:
`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`.
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both tell the coding agent its resolved model and session working directory, treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. Web additionally names the DeepSeek Harness Web GUI as the interaction surface, this checkout as its own source location, and the process's canonical local URL and mode in both the prompt and managed `$DSH_WEB_URL`/`$DSH_WEB_MODE`; references such as “this page” therefore identify the GUI without claiming access to implicit DOM, route, or screenshot state. In production mode the host reads rebuilt frontend dist and client bundles on the next request, so refreshing the existing URL updates that GUI without replacing its process. `dsh web --dev` mounts the client-plugin HMR receiver, but no-refresh updates additionally require `pnpm run dev:web` in the same checkout to watch and rebuild plugin bundles; shell and ordinary package changes still require a rebuild and page refresh. Bare `apps/web` Vite serving fails before listening because it cannot inject `window.__DSH_BOOT__`. The index service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one.
All three surfaces consume `$DSH_HOME/config.yaml`; the TUI and Web apply valid edits live, while one-shot headless runs read it at startup. The shipped trees include an empty `repository-plugins` row, so a standalone user can add prepared GitHub Plugins without an SDK project or install command:
```yaml
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
config:
repositories:
- 'github:PolyArch/humanize#<commit>'
```
The repository must contain a prepared `.dsh-plugin` package; the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration) documents authoring, nested Plugin paths, the immutable cache, trust boundary, and failure semantics. A failed live edit keeps the last good tree and emits Cordis's `hmr/config-update-failed` event.
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).

View File

@@ -11,7 +11,7 @@ TUI 界面:
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话;
-**调用目录** 视为 workspace会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文);
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`已交付配置树中的 Cordis HMR 会持续应用 `config.yaml` 的变更;显式 `--config` 配置树会替代该个人覆盖,未包含 HMR 的配置树只在启动时读取该文件。
-`DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有overlay 不会写入会话事件或模型上下文。
- 注册裸 `/compact`agent 空闲时即使未达到自动压力也会摘要有效的较早历史该命令拒绝参数并只在独立替换标记对持久化后报告成功。压缩compaction期间提交的提示词保留其队列身份并在该检查点之后启动注入的上下文仍保持可见。
@@ -21,10 +21,22 @@ TUI 界面:
`dsh --dump-config``dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include``applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr与 Loader 启动时的警告一致。由启动器持有的启动上下文值会话身份、CLI 标志补丁是每次调用的事实位于配置树之外不会出现。dump 标志会拒绝仅用于启动的标志(`-p``--resume``--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`
Web 和无头界面启动 `base.cordis.yml``web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
Web 和无头界面启动 `base.cordis.yml``web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都会告知编码 agent 所用模型和会话工作目录,将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。Web 还会明确说明交互界面是 DeepSeek Harness Web GUI、当前 checkout 是自身源码位置,并在提示词及受管的 `$DSH_WEB_URL`/`$DSH_WEB_MODE` 中提供该进程的规范本地 URL 和模式;因此,「这个页面」等表述会指向该 GUI但 agent 不会声称可以访问未显式提供的 DOM、路由或截图状态。在生产模式下宿主会在下次请求时读取重新构建的前端 dist 和客户端 bundle因此刷新现有 URL 即可更新该 GUI无须替换其进程。`dsh web --dev` 会挂载客户端插件的 HMR热模块替换接收端但要实现无刷新更新还需在同一 checkout 中运行 `pnpm run dev:web`,以监视并重新构建插件 bundleshell 和普通包package的更改仍需重新构建并刷新页面。直接使用裸 `apps/web` Vite 服务会在开始监听前失败,因为它无法注入 `window.__DSH_BOOT__`。索引服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。
三个界面都会使用 `$DSH_HOME/config.yaml`TUI 和 Web 实时应用有效编辑,而一次性无头运行只在启动时读取。已交付的配置树包含一个空的 `repository-plugins` 配置项,因此独立用户无需 SDK 项目或安装命令,只需配置即可添加已准备的 GitHub 插件:
```yaml
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
config:
repositories:
- 'github:PolyArch/humanize#<commit>'
```
仓库必须包含已准备的 `.dsh-plugin` 包;[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)说明创作方式、嵌套插件路径、不可变缓存、信任边界和失败语义。实时编辑失败时,最后一个可用树保持运行,并发出 Cordis 的 HMR热模块替换事件 `hmr/config-update-failed`
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL``OPENAI_API_KEY` / `OPENAI_BASE_URL``ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。

View File

@@ -12,6 +12,8 @@ flowchart LR
cfg --> plugin_tui_timer
plugin_tui_hmr["hmr<br/>@cordisjs/plugin-hmr"]
cfg --> plugin_tui_hmr
plugin_tui_repository_plugins["repository-plugins<br/>@deepseek-ai/dsh-repository-plugin"]
cfg --> plugin_tui_repository_plugins
plugin_tui_llm["llm<br/>@deepseek-ai/dsh-llm"]
cfg --> plugin_tui_llm
plugin_tui_session["session<br/>@deepseek-ai/dsh-session"]
@@ -92,10 +94,16 @@ flowchart LR
cfg --> plugin_tui_subagent_spawn
plugin_tui_subagent_fork["subagent-fork<br/>@deepseek-ai/dsh-subagent-fork"]
cfg --> plugin_tui_subagent_fork
plugin_tui_tool_subagent_control["tool-subagent-control<br/>@deepseek-ai/dsh-tool-subagent-control"]
cfg --> plugin_tui_tool_subagent_control
plugin_tui_tool_subagent_list_agents["tool-subagent-list-agents<br/>@deepseek-ai/dsh-tool-subagent-control/list-agents"]
cfg --> plugin_tui_tool_subagent_list_agents
plugin_tui_tool_subagent["tool-subagent<br/>@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_tui_tool_subagent
plugin_tui_tool_subagent_fork["tool-subagent-fork<br/>@deepseek-ai/dsh-tool-subagent"]
cfg --> plugin_tui_tool_subagent_fork
plugin_tui_tool_subagent_report["tool-subagent-report<br/>@deepseek-ai/dsh-tool-subagent-report"]
cfg --> plugin_tui_tool_subagent_report
plugin_tui_workflow_workerthread["workflow-workerthread<br/>@deepseek-ai/dsh-workflow-workerthread"]
cfg --> plugin_tui_workflow_workerthread
plugin_tui_tool_workflow["tool-workflow<br/>@deepseek-ai/dsh-tool-workflow"]
@@ -116,8 +124,6 @@ flowchart LR
cfg --> plugin_tui_tool_goal
plugin_tui_tool_ralph["tool-ralph<br/>@deepseek-ai/dsh-tool-ralph"]
cfg --> plugin_tui_tool_ralph
plugin_tui_tool_session_query["tool-session-query<br/>@deepseek-ai/dsh-tool-session-query"]
cfg --> plugin_tui_tool_session_query
plugin_tui_tool_str_replace_editor["tool-str-replace-editor<br/>@deepseek-ai/dsh-tool-str-replace-editor"]
cfg --> plugin_tui_tool_str_replace_editor
plugin_tui_repeat_tool_guard["repeat-tool-guard<br/>@deepseek-ai/dsh-repeat-tool-guard"]
@@ -144,6 +150,7 @@ flowchart LR
| --- | --- |
| `timer` | `@cordisjs/plugin-timer` |
| `hmr` | `@cordisjs/plugin-hmr` |
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
| `llm` | `@deepseek-ai/dsh-llm` |
| `session` | `@deepseek-ai/dsh-session` |
| `session-title` | `@deepseek-ai/dsh-session-title` |
@@ -184,8 +191,11 @@ flowchart LR
| `subagent` | `@deepseek-ai/dsh-subagent` |
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` |
| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` |
| `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` |
| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
| `tool-subagent-report` | `@deepseek-ai/dsh-tool-subagent-report` |
| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` |
| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` |
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
@@ -196,7 +206,6 @@ flowchart LR
| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
| `tool-goal` | `@deepseek-ai/dsh-tool-goal` |
| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` |
| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` |
| `tool-str-replace-editor` | `@deepseek-ai/dsh-tool-str-replace-editor` |
| `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` |
| `web` | `@deepseek-ai/dsh-web` |

View File

@@ -22,6 +22,13 @@
config:
root: ['.']
# `$DSH_HOME/config.yaml` replaces this row's config to select exact GitHub
# repository Plugin generations. The app registers the DSH-owned runtime even
# when the list is empty so a later personal-config edit can load
# transactionally; one-shot headless runs consume the startup value only.
- id: repository-plugins
name: '@deepseek-ai/dsh-repository-plugin'
- id: llm
name: '@deepseek-ai/dsh-llm'
@@ -250,17 +257,31 @@
config:
providerName: fork
# Continuable background children are selected per delegation tool. The
# separately loaded follow-up tool registers the one global `send_message`.
- id: tool-subagent-control
name: '@deepseek-ai/dsh-tool-subagent-control'
- id: tool-subagent-list-agents
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent
backgroundMode: continuable
- id: tool-subagent-fork
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
backgroundMode: continuable
# Optional direct-child return channel; absent from roots and one-shot agents.
- id: tool-subagent-report
name: '@deepseek-ai/dsh-tool-subagent-report'
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
@@ -309,12 +330,6 @@
subagentProvider: spawn
maxRounds: 64
- id: tool-session-query
name: '@deepseek-ai/dsh-tool-session-query'
config:
maxSearchResults: 100
searchTimeoutMs: 30000
- id: tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
@@ -332,7 +347,9 @@
# chat, at each search; its Messages endpoint is separate from the
# chat-completions endpoint, so it takes its own base-URL override. Fetch stays
# disabled and no fetch provider is mounted: that provider defers SSRF
# protection and the model would choose the request target.
# protection and the model would choose the request target. Search is a full
# auxiliary model request with server-side retrieval, so this shipped DeepSeek
# route gets 60s while the provider-neutral tool default remains 30s.
- id: web
name: '@deepseek-ai/dsh-web'
config:
@@ -348,6 +365,7 @@
name: '@deepseek-ai/dsh-tool-web'
config:
fetch: false
searchTimeoutMs: 60000
# ── rows every surface mounts, whose values each overlay states ──────────────

View File

@@ -25,6 +25,12 @@
- id: plan-mode
disabled: true
- id: tool-subagent-control
disabled: true
- id: tool-subagent-list-agents
disabled: true
- id: tool-subagent
disabled: true
@@ -45,9 +51,6 @@
- id: tool-ralph
disabled: true
- id: tool-session-query
disabled: true
- id: tool-str-replace-editor
disabled: true

View File

@@ -10,6 +10,11 @@
# ── surface-specific values the base deliberately omits ─────────────────────
- id: system-prompt
config:
persona: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested.
- id: hmr
disabled: true

View File

@@ -77,6 +77,7 @@
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-repository-plugin": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
@@ -116,10 +117,11 @@
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",

View File

@@ -1,6 +1,6 @@
/**
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* for the Web/headless surface.
* (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly).
* Everything here is what must exist before the Loader runs: the patch
* composition over the shipped base and surface overlay (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud activation audit after the tree
@@ -16,7 +16,13 @@ import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { boot, installFailLoud, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
import {
boot,
installFailLoud,
loadOverlayPatches,
loadPersonalPatches,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -140,8 +146,10 @@ export interface AppCLIEntryOptions {
* `$DSH_HOME/config.yaml` overlay is applied instead.
*/
extraOverlayPath?: string
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
/** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */
dev: boolean
/** Whether `$DSH_HOME/config.yaml` remains live after the initial boot. */
watchPersonalConfig: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
/**
@@ -155,6 +163,8 @@ export interface AppCLIEntryOptions {
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
trustedHosts?: string[]
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
prepare?: (ctx: Context) => Promise<void> | void
}
/**
@@ -180,8 +190,8 @@ export class AppCLIEntry {
constructor(private readonly options: AppCLIEntryOptions) {}
/**
* Run the boot chain: patch composition → Loader include boot (dev row
* before await) → fail-loud triple.
* Run the boot chain: patch composition → Loader installation → surface
* preparation → config-tree boot (dev row before await) → fail-loud triple.
* @returns the settled root context and the listening port.
*/
async run(): Promise<{ ctx: Context; port: number }> {
@@ -233,11 +243,12 @@ export class AppCLIEntry {
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
this.patches = [...overrides.entries()].map(([id, bag]) => {
const generated = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
})
this.patches = generated
// Telemetry opt-out: a row can only be turned off at the patch layer
// (config cannot disable an entry), and the switch must hold BEFORE the
@@ -246,22 +257,36 @@ export class AppCLIEntry {
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
}
/** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */
/** Shared Loader boot; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */
private async bootTree(): Promise<void> {
// One include of the shared base with every overlay as a sibling patch
// list: patches never cross an include boundary, so nesting them would
// silently stop reaching base rows. The surface overlay applies first, then
// this entry's profile-json and CLI-flag patches, which therefore win.
const patches = [
const compose = (overlay: PatchOptions[]): PatchOptions[] => [
...loadOverlayPatches('dsh', this.options.overlayPath),
...this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
...overlay,
...this.patches,
]
// An explicit --config overlay REPLACES the personal overlay, so there is
// then no personal layer to keep live — the watcher is personal-only.
const watchPersonal = this.options.watchPersonalConfig && this.options.extraOverlayPath === undefined
const patches = compose(
this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
)
this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => {
await this.options.prepare?.(ctx)
// Config-only HMR for the personal overlay: module reload stays off for
// this surface (web.cordis.yml disables the shared `hmr` row until its
// reload lifecycle is tested), so this row watches no module roots.
if (watchPersonal) await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
})
if (watchPersonal) {
await watchPersonalPatches(this.ctx, { binName: 'dsh', compose })
}
}
/** Install the diagnostic for plugin rejections that happen after settled boot. */

View File

@@ -285,7 +285,7 @@ Examples:
.option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'developer mode: hot-reload the browser client')
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')

View File

@@ -78,6 +78,7 @@ export async function runHeadless(task: string): Promise<void> {
configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)),
overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
dev: false,
watchPersonalConfig: false,
port: 0,
})
const { ctx, port } = await entry.run()

View File

@@ -28,8 +28,10 @@ import {
loadOverlayPatches,
loadPersonalPatches,
resolveConfigPath,
watchPersonalPatches,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { SessionId } from '@deepseek-ai/dsh-session'
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
@@ -201,15 +203,16 @@ export async function runTui(
// presence is checked against the tree actually booting, so a
// --config-replace tree is judged on its own rows, not the shipped base's.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
const patches = [
const composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [
...replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...resolvedConfig === undefined
? loadPersonalPatches(NAME) ?? []
? personalPatches
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
],
...telemetryPatch === undefined ? [] : [telemetryPatch],
]
const patches = composePatches(loadPersonalPatches(NAME) ?? [])
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
const ctx = await boot(
NAME,
@@ -246,6 +249,14 @@ export async function runTui(
}
},
)
// The shipped tree includes HMR and keeps personal config live. An explicit
// --config tree replaces the personal overlay (so there is nothing to keep
// live), and a --config-replace or HMR-less tree remains a valid composition
// that still receives the startup overlay but deliberately has no hidden
// watcher.
if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) {
await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches })
}
app.current = ctx
addHarnessSourceSection(ctx, SOURCE_ROOT)
if (showFirstRunWelcome) {

View File

@@ -7,23 +7,87 @@
*/
import { fileURLToPath } from 'node:url'
import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type { Context } from 'cordis'
import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tool-bash'
import { AppCLIEntry } from './app-cli-entry.ts'
// The shared core every `dsh` surface mounts, plus this surface's overlay over it.
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url))
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
const DSH_WEB_URL = 'DSH_WEB_URL' as const
const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
type WebMode = 'production' | 'development'
// Display-only mirror of the webserver schema's loopback host: the address the
// local URL always prints. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
const updateContract = mode === 'development'
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
+ 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
+ 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
: 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
+ 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
+ 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
+ 'The browser provides no implicit DOM, route, or screenshot context. '
+ updateContract
+ 'Starting another server does not update this GUI. '
+ 'The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. '
+ 'Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.'
}
/** Resolve the canonical loopback URL from the active Web server. */
function localWebUrl(ctx: Context): string {
const port = ctx.get('httpServer')?.port
if (port === undefined) throw new Error('dsh web: httpServer service missing while resolving Web runtime')
return `http://${LOOPBACK_HOST}:${String(port)}`
}
/**
* Register the launcher-owned prompt and shell runtime context before the
* shared config tree mounts. The earlier injections install the prompt
* sections and managed Bash contributor when their owning services activate;
* dynamic values read the bound server only when consumed.
* @param ctx - Web root context with Loader installed but no config tree mounted.
* @param sourceRoot - absolute checkout root resolved from the launcher module.
* @param mode - whether this process mounted the client-plugin HMR receiver.
*/
export function prepareWebRuntimeContext(ctx: Context, sourceRoot: string, mode: WebMode): void {
ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, sourceRoot)
promptCtx.systemPrompt.section({
name: 'app:web-surface',
order: -98,
text: () => webSurfacePrompt(localWebUrl(promptCtx), mode),
})
})
ctx.inject(['bashEnv'], (runtimeCtx) => {
runtimeCtx.bashEnv.register({
name: 'web-runtime',
variables: {
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
},
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: mode }),
})
})
}
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
* through only when the flag was given; absent, the shipped Web overlay value stands.
* @param host - the bind host, or `undefined` to keep the config default.
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
* @param dev - mount the client HMR receiver; `pnpm run dev:web` separately rebuilds watched plugin bundles.
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
* @param config - an overlay of loader patches applied over the shipped web
@@ -38,17 +102,21 @@ export async function runWeb(
trustedHosts: string[] | undefined,
config?: string,
): Promise<void> {
const mode: WebMode = dev ? 'development' : 'production'
const entry = new AppCLIEntry({
configPath: BASE_CONFIG,
overlayPath: WEB_OVERLAY,
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
dev,
prepare: (ctx) => { prepareWebRuntimeContext(ctx, SOURCE_ROOT, mode) },
watchPersonalConfig: true,
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },
...trustedHosts !== undefined && { trustedHosts },
})
const { ctx, port: boundPort } = await entry.run()
const resolvedLocalWebUrl = localWebUrl(ctx)
let exiting = false
const shutdown = (code: number): void => {
@@ -65,6 +133,5 @@ export async function runWeb(
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.
const lanCandidate = entry.lanAddresses[0]
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
console.log(`dsh web: ${resolvedLocalWebUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
}

View File

@@ -94,6 +94,13 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(stdout).toContain('model: deepseek-v4-pro')
expect(stdout).toContain('cwd: !!js process.cwd()')
expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'")
expect(stdout).toContain([
'- id: tool-web',
" name: '@deepseek-ai/dsh-tool-web'",
' config:',
' fetch: false',
' searchTimeoutMs: 60000',
].join('\n'))
// Provenance comment separators name each section's source file.
expect(stdout).toContain('# == base.cordis.yml')
expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml')

View File

@@ -33,13 +33,10 @@ const EXPECTED_TUI_TOOLS = [
'edit',
'exit_plan_mode',
'get_goal',
'list_agents',
'ralph',
'read',
'session_event_read',
'session_event_search',
'session_event_trace',
'session_search',
'session_trace',
'send_message',
'skill',
'str_replace_editor',
'subagent',

View File

@@ -1,4 +1,5 @@
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
import { createHash } from 'node:crypto'
import { realpathSync } from 'node:fs'
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -6,6 +7,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
import { PREPARED_ENTRY_FILENAME, prepareDshPlugin } from '@deepseek-ai/dsh-repository-plugin'
import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
@@ -66,6 +68,38 @@ function seedWorkspace(
}
}
/**
* Run the real `prepareDshPlugin` over an equivalent one-skill `.dsh-plugin`
* package and return the generated wrapper text, so the smoke's cache-seeded
* wrapper can never drift from the generator's template.
*/
async function generatePreparedWrapper(pluginName: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'dsh-smoke-wrapper-'))
try {
const plugin = join(root, '.dsh-plugin')
await mkdir(join(root, 'skills', 'config-only-repository'), { recursive: true })
await writeFile(join(root, 'skills', 'config-only-repository', 'SKILL.md'), [
'---',
'name: config-only-repository',
'description: Generator input; the seeded cache copy owns the visible text.',
'---',
'',
'Repository instructions.',
'',
].join('\n'))
await mkdir(plugin, { recursive: true })
await writeFile(join(plugin, 'package.json'), `${JSON.stringify({
name: pluginName,
version: '0.0.0',
dsh: { skills: ['../skills'] },
}, undefined, 2)}\n`)
await prepareDshPlugin(plugin)
return await readFile(join(plugin, PREPARED_ENTRY_FILENAME), 'utf8')
} finally {
await rm(root, { recursive: true, force: true })
}
}
/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */
async function seedResumeSession(cwd: string): Promise<void> {
const sessionCwd = realpathSync.native(cwd)
@@ -640,6 +674,54 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, PTY_SMOKE_TEST_TIMEOUT_MS)
it('loads a cached repository Plugin from personal config alone', async () => {
const source = 'github:fixture/repository#fixed-ref'
const specifier = `${source}&path:/.dsh-plugin`
const key = createHash('sha256').update(specifier).digest('hex')
const packageRoot = `cache/repository-plugins/${key}/node_modules/repository`
// Produced by the real generator (prepareDshPlugin over an equivalent
// .dsh-plugin package) rather than hand-written, so a wrapper-template
// change cannot leave this smoke exercising a stale shape. The cache
// LAYOUT below (sha256 key, marker, node_modules/repository) remains a
// deliberate external pin of the durable on-disk format.
const wrapper = await generatePreparedWrapper('config-only-fixture')
const output = await smoke({
label: 'dsh personal repository Plugin',
tempDirPrefix: 'dsh-personal-repository-plugin-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({
personal: {
'config.yaml': [
'- id: repository-plugins',
" name: '@deepseek-ai/dsh-repository-plugin'",
' config:',
' repositories:',
` - '${source}'`,
'',
].join('\n'),
[`cache/repository-plugins/${key}/.repository-cache.json`]: `${JSON.stringify({ specifier })}\n`,
[`${packageRoot}/dsh-plugin.mjs`]: wrapper,
[`${packageRoot}/dsh-plugin-assets/skills/0/config-only-repository/SKILL.md`]: [
'---',
'name: config-only-repository',
'description: CONFIG_ONLY_REPOSITORY_SKILL',
'---',
'',
'Repository instructions.',
'',
].join('\n'),
},
}),
actions: [
{ waitFor: 'main-session-', send: '/skill:config-only' },
{ waitFor: 'CONFIG_ONLY_REPOSITORY_SKILL', send: '\x03/exit\r' },
],
})
expect(output).toContain('CONFIG_ONLY_REPOSITORY_SKILL')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
const output = await smoke({
label: 'dsh invalid personal config',

View File

@@ -0,0 +1,32 @@
import { sep } from 'node:path'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { HARNESS_SOURCE_SECTION } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { prepareWebRuntimeContext } from '../src/web.ts'
describe('prepareWebRuntimeContext', () => {
it('installs both sections before a later systemPrompt consumer activates', async () => {
const ctx = new Context()
const sourceRoot = `${sep}opt${sep}harness-src`
let observedSections: { name: string; text: string }[] | undefined
try {
prepareWebRuntimeContext(ctx, sourceRoot, 'production')
ctx.provide('httpServer', { port: 3080 } as Context['httpServer'])
const consumer = ctx.inject(['systemPrompt'], async (promptCtx) => {
const assembly = await promptCtx.systemPrompt.assemble()
observedSections = assembly.sections
})
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
await consumer
expect(observedSections?.map(section => section.name)).toContain(HARNESS_SOURCE_SECTION)
expect(observedSections?.find(section => section.name === 'app:web-surface')?.text)
.toContain('http://127.0.0.1:3080')
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -23,6 +23,9 @@
{
"path": "../../packages/ui/app-boot"
},
{
"path": "../../packages/bash/tool-bash"
},
{
"path": "../../packages/ui/tui"
},