Merge branch 'master' into worktree/docs-homepage-copy

This commit is contained in:
Yichen Jiang
2026-08-10 13:23:32 +08:00
committed by GitHub
568 changed files with 26358 additions and 1432 deletions

View File

@@ -298,6 +298,9 @@
"process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml": "sha256:89b6dc255cb0dd9d97ae6f34f37d185412d4260d7bf2f87d6b25e340f01dc26e",
"process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md": "sha256:1742e09435ade4a09349c8843eb381e870a2be2d51b74449f08260422c8096b3",
"process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md": "sha256:220bd53a88617b09ee8970627c46540fa8951724b102acfe296a7ed7d0f7b5fe",
"process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5",
"process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936",
"process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04",

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 .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
2026-07-27-wine-windows-gates-experiment.md: c2b5419c106e232520b9ad21339b0758da1b6694
2026-07-27-wine-windows-gates-experiment.zh.md: 7ad19e49a43c07369eba6511fb7a46f5b3291df8
2026-07-27-wine-windows-gates-experiment.md: ce2ad1ad4506f59083de5731677d052161849e3d
2026-07-27-wine-windows-gates-experiment.zh.md: a0a5e8e7cce078adf2c961fe4d8cc50edc28355c

View File

@@ -1,6 +1,7 @@
# Agent Note: Wine-run Windows blocking gates on Linux runners
Status: implemented
Archived: 2026-08-08
English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md)
@@ -8,7 +9,7 @@ English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md)
The pull-request Windows lane exists to prove the two blocking win32 surfaces — the workspace build and the production site — and it ran on hosted `windows-2025`, the slowest job in the required matrix: 79 minutes against 1.52.5 for the Linux jobs, so the Windows VM's boot, setup, and filesystem costs dominated every pull request's critical path.
The experiment tested whether a plain Linux runner can produce an equivalent win32 signal for the blocking surfaces at Linux wall clock, so no Windows VM sits on the pull-request path at all.
The question the experiment answered: can a plain Linux runner produce an equivalent win32 signal for the blocking surfaces at Linux wall clock, so no Windows VM sits on the pull-request path at all?
## Decision

View File

@@ -1,6 +1,7 @@
# Agent Note: 在 Linux runner 上用 Wine 运行 Windows 阻断门禁
Status: implemented
Archived: 2026-08-08
[English](2026-07-27-wine-windows-gates-experiment.md) | 中文
@@ -8,13 +9,13 @@ Status: implemented
Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 workspace 构建与生产站点。该通道此前运行在托管 `windows-2025` 上,是必需矩阵中最慢的作业:耗时 79 分钟,而 Linux 作业耗时 1.52.5 分钟,因此 Windows VM 的启动、准备与文件系统开销主导了每个 pull request 的关键路径。
实验测试了一台普通 Linux runner 能否以 Linux 作业的墙钟时间为这些阻断表面产出等效的 win32 信号,让 pull request 路径上完全没有 Windows VM
实验回答的问题是:一台普通 Linux runner 能否以 Linux 作业的墙钟时间为这些阻断表面产出等效的 win32 信号,让 pull request 路径上完全没有 Windows VM
## 决策
[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b``tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。
依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64使 Windows 平台包物化进同一个 store通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:一个独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达本通道采纳了该原型的校验和固定同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装约在此仍由 Linux 侧验证)。
依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64使 Windows 平台包物化进同一个 store通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:一个独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达本通道采纳了该原型的校验和固定同时明确放弃其「Windows pnpm 安装依赖树」的目标(安装约在此仍由 Linux 侧验证)。
该通道靠四个杠杆把墙钟时间保持在与 Linux CI 作业相当的水平master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键、Wine 供给apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。
@@ -32,7 +33,7 @@ Pull request 的 Windows 通道旨在验证两个阻断性 win32 表面,即 wo
**在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可投入使用。
**在 Wine 下由 Windows pnpm 执行安装。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix用 Linux 预取填充 store再由 Windows Node 运行 `pnpm install --offline`,让安装约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。
**在 Wine 下由 Windows pnpm 执行安装。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix用 Linux 预取填充 store再由 Windows Node 运行 `pnpm install --offline`,让安装约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道牺牲这份保真度hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。
**Linux 上的文件系统语义通道casefold ext4、文件名 lint。** 以近零成本捕获最高频的 Windows 故障类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
2026-08-03-per-session-agent-presets.md: 6f1643c25008c3363cb10adb7fbff7afeea31cbe
2026-08-03-per-session-agent-presets.zh.md: 7afe9ade5c98fadb96384a7e0acd47531c370e0c

View File

@@ -0,0 +1,80 @@
# Agent Note: A session's agent is composed from a preset cordis.yml
Status: implemented
English | [中文](2026-08-03-per-session-agent-presets.zh.md)
## Problem
One `dsh` process serves many sessions, but the composition that decides what an agent *is* — its tools, persona, prompt sections, delegation backends — is fixed for the whole process by the `cordis.yml` the launcher booted. A deployment that wants a benchmark-minimal agent beside a full coding agent has to run two processes, and the shipped workaround (`apps/cli/config/minimal.cordis.yml`, a `--config` overlay that disables tool rows) changes every session at once.
The obvious reading of "let a session pick its composition" is that the loader needs a new tier. It does not. [`dsh-tools`](../../../../packages/core/tools/README.md) and [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md) already file registrations into the calling context's scope layer, and [the agent is a registration scope](2026-07-08-agent-scope-contexts.md). What was missing is a way to point a whole `cordis.yml` at one agent's scope.
## Decision
A **preset** is a directory holding one `agent.cordis.yml`. The agent factory's `setup(agentCtx)` mounts it as a Cordis `include` subtree plugged into that agent's scope context. Entry contexts chain to the context a subtree was plugged into, so every registration inside the preset lands in that agent's layer and unwinds with the agent. No registry gains a tier, and no session already running is touched.
Composition splits into two planes, decided by what must be shared rather than by what feels agent-related:
| Plane | Instances | Contents |
|---|---|---|
| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), and the web host |
| Agent | one per session | What a single agent contributes to those registries: tool plugins, persona and prompt sections, compaction policy |
Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane.
The presets the deployment ships are the directories under `apps/cli/config/agent-presets/`; the roster is that listing, not a list restated here.
Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance.
Which preset an unnamed session gets is a user setting (`agent-presets.default`) layered over the composition's own `default`, which becomes the `base`. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a `cordis.yml` they may not own.
## Consequences
**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session header enforces from the other side — the header records the id a session actually runs, so a resume rebuilds that composition rather than today's default, and the gateway rejects an attempt to adopt a live session under a different one. A snapshot would make the two disagree at exactly the moment the setting changes.
**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it.
**A preset can only name a group because the app registers one.** Sharing a realm across rows is a `cordis:group` row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve `@cordisjs/plugin-group` by name: Node's upward `node_modules` walk never reaches the harness from there. `boot()` therefore registers `cordis:group` beside `cordis:include` as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the `isolate` vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers.
**A preset may not publish into the root service realm.** Such a service is process-global rather than per-session, so the second session mounting the same preset collides with the first — and the collision surfaces as an unhandled rejection that `setup` never observes, leaving a half-composed agent that looks healthy. The mount rejects it instead, and the package invariant re-checks on every service notification because a row publishing from a timer or an asynchronous continuation would escape a one-shot audit.
**Failure rolls the agent back.** `setup` runs before publication, so a rejected mount fails `ctx.agents.create()` and leaves nothing behind. This is why `setup` is the one supported call site.
**A test that the preset file is never rewritten has to be able to fail.** The first version asserted the file was unchanged after an ordinary mount, and could not have caught anything: the Loader only reaches its write path when it decides the config changed, and nothing in that composition ever self-disposed. The regression plants a row that disposes itself — the shape a real preset hits every time an agent is torn down — and keeps the composition in a temp root rather than under `fixtures/`, because without the override the Loader rewrites the file it read: a committed fixture would be damaged by the very run that proves the bug, and every run after it would compare against the damaged file and pass.
**Fiber membership is object identity, not `uid`.** A `uid` is a per-registry counter, so fibers in two different roots collide on it; comparing by `uid` made one runtime's subtree answer for a service published in another. `ctx.plugin()` returns a thenable `Object.create(fiber)` wrapper that is never identical to the fiber in a parent chain, so the subtree captures its own fiber during construction.
**A preset file is an input, never a persistence target.** `EntryTree.write()` persists a tree whenever the Loader decides the config changed, and a plugin self-disposing is enough — tearing an agent down disposes its whole subtree. Inherited, that rewrites the composition it read, in practice truncating a shipped preset to `[]` the first time a session ends. The subtree overrides `write()` to do nothing.
**A plugin that looks itself up in the global registry breaks inside a preset.** `ctx.tools.register()` files into the CALLING context's scope, so a plugin mounted in a preset registers for one agent and an unscoped `ctx.tools.get(name)` correctly finds nothing. `dsh-tool-skill` did exactly that and threw on every preset mount; it now compares against the definition it registered. Any plugin meant to be preset-mountable must hold its own registration rather than re-read it by name.
**An entry-local `isolate` realm is invisible to the agent's own scope, not only to the host.** Only rows inside that group resolve the service. That is what makes a preset's `skills` registry belong to one agent rather than being shared — and it means a consumer left outside its provider's group silently resolves the host registry and contributes nothing.
**Switching is allowed only while a session is blank.** Once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so `agentPreset.select` answers `agent-preset-locked`. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the `AgentHandle` it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails.
**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought.
**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and its backends are host-plane; the preset contributes the delegation TOOLS, which resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones.
**A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off.
**A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it.
**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default.
**A durable header field is not durable until every backend writes it.** `agentPreset` landed on `SessionHeader` with the right rationale and neither persistence backend carried it: the JSONL header line, the SQLite `sessions` row, and the derived query index each map the header column by column, so a resumed session came back with no preset and the surfaces that name it fell silent. `summarizeCold` had the same shape — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it.
**The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright.
**A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `dsh-host-apiproxy` discards the `AgentHandle` it creates, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one.
- Remaining TODO: idle agent eviction — dispose after the session is persisted and re-mount on resume. It belongs to the host that owns the handle, not to this seam.
## Alternatives considered
**Add a preset tier to the scoped registries.** `ScopedLayers.merge()` combines the global layer with exactly one exact-scope layer. A middle tier would let many sessions share one mounted composition, but it changes `dsh-scope` and every scope-aware registry to save a cost measured in milliseconds, and it gives a preset's registrations a lifetime no agent owns.
**Make the agent's scope key the preset.** Sessions on one preset would share a layer for free, but per-agent registrations — `installAgentLlmTarget`, per-agent tool restrictions — would then collide across sessions.
**Run each preset as a child process.** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) already proves a full child harness works, and isolation would be absolute. It also means proxying streaming, approvals, and projections per session, which is a transport project rather than a composition one.

View File

@@ -0,0 +1,81 @@
# Agent Note会话的 agent 由一份 preset cordis.yml 组装而成
Status: implemented
[English](2026-08-03-per-session-agent-presets.md) | 中文
## 问题
一个 `dsh` 进程服务多个会话,但决定 agent智能体究竟是什么的那套组装——它的工具、人设、提示词段落、委派后端——由启动器所引导的 `cordis.yml` 一次性固定给整个进程。若某个部署希望一个 benchmark 精简 agent 与一个完整编码 agent 并存,就必须跑两个进程;而现有的变通方案(`apps/cli/config/minimal.cordis.yml`,一个用来禁用工具行的 `--config` 覆盖层)会一次性改变所有会话。
对"让会话自选组装"最直觉的理解,是 loader 需要新增一层。其实不需要。[`dsh-tools`](../../../../packages/core/tools/README.md) 与 [`dsh-system-prompt`](../../../../packages/core/system-prompt/README.md) 本就按调用方上下文的 scope 分层归档注册,而且 [agent 本身就是一个注册 scope](2026-07-08-agent-scope-contexts.md)。此前缺的只是一种把整份 `cordis.yml` 指向某一个 agent scope 的办法。
## 决策
**preset** 是一个目录,其中放置一份 `agent.cordis.yml`。agent 工厂的 `setup(agentCtx)` 把它作为 Cordis `include` 子树,挂载到该 agent 的 scope 上下文之下。entry 上下文沿原型链连到子树被挂载时所在的上下文,因此 preset 内部的每一次注册都落进该 agent 的分层,并随 agent 一起卸载。没有任何注册表新增分层,也没有任何已在运行的会话被触及。
组装划分为两个平面,依据是什么必须共享,而不是什么感觉上与 agent 有关:
| 平面 | 实例数 | 内容 |
|---|---|---|
| 宿主 | 一份 | 注册表本身(`tools``systemPrompt``agents``agent-loop``sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测),以及 web 宿主 |
| agent | 每会话一份 | 单个 agent 对这些注册表的贡献:工具插件、人设与提示词段落、压缩策略 |
模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。
部署交付哪些 preset取决于 `apps/cli/config/agent-presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。
挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB因此隔离比任何共享方案都更划算而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。
未指名 preset 的会话拿到哪一个,是一项用户设置(`agent-presets.default`),叠在组装自身的 `default` 之上——后者成为 `base`。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 `cordis.yml` 就能调整的东西。
## 后果
**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session header 从另一侧执行的同一条——header 记录会话实际运行的 id因此恢复重建的是那份组装而不是当下的默认值网关也会拒绝把一个活着的会话收编到另一个 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。
**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。
**preset 能写出 group是因为 app 注册了它。** 跨行共享 realm 就是一个 `cordis:group` 行,而住在本工作区之外的 preset——也就是 Harness home 下由人或 agent 创作的那些,正是这套设计的目的——无法按名字解析 `@cordisjs/plugin-group`Node 向上查找 `node_modules` 的路径从那里永远走不到 harness。因此 `boot()``cordis:group``cordis:include` 并排注册为 loader builtin两者都经由环境模块管线加载而不依赖被包含树自身的说明符解析。没有它上文那套 `isolate` 词汇就只能一行一行地表达,提供方也永远无法与它的消费方归入同一组。
**preset 不得把服务发布进根 realm。** 这类服务是进程级全局而非按会话的,因此第二个挂载同一 preset 的会话会与第一个相撞——而这次相撞表现为 `setup` 永远观察不到的未处理 rejection留下一个看起来健康、实则组装到一半的 agent。挂载改为直接拒绝它本包的运行时不变量还会在每次服务通知时复查因为从定时器或异步续体中发布的行会绕过一次性审计。
**失败会让 agent 回滚。** `setup` 在发布之前运行,因此挂载被拒绝会让 `ctx.agents.create()` 失败且不留残留。这正是 `setup` 是唯一受支持调用点的原因。
**「preset 文件从不被回写」这条断言,必须先有失败的可能。** 最初那版在一次普通挂载之后断言文件未变其实什么也抓不到Loader 只在认定 config 变了时才会走到写路径,而那份组装里没有任何一行会自行销毁。回归用例改为植入一个自行销毁的行——真实 preset 在每次 agent 被拆除时都会命中的形状——并把组装放在临时根目录而不是 `fixtures/`没有那个覆写Loader 会回写它读入的文件,于是提交进仓库的 fixture 会被**恰恰是证明该缺陷的那次运行**改坏,之后每一次运行都拿改坏后的文件作比较从而通过。
**fiber 归属判定用对象同一性,而非 `uid`。** `uid` 是按 registry 计数的序号,因此两个不同根下的 fiber 会在它上面撞号;按 `uid` 比较曾导致一个运行时的子树为另一个运行时中发布的服务背锅。`ctx.plugin()` 返回的是 thenable 的 `Object.create(fiber)` 包装对象,与父链中出现的 fiber 永远不同一,因此子树在构造时捕获自己的 fiber。
**preset 文件是输入,绝不是持久化目标。** 只要 loader 认为配置变了,`EntryTree.write()` 就会回写整棵树,而一个插件自我 dispose 就足以触发——销毁 agent 会 dispose 它的整棵子树。若继承该行为,它会重写自己读入的那份组装,实际后果是第一次会话结束时把随附 preset 截断成 `[]`。子树因此把 `write()` 覆盖为空操作。
**按自身名字回查全局注册表的插件,在 preset 里必然失效。** `ctx.tools.register()` 归档进**调用方**上下文的 scope因此挂在 preset 里的插件只为一个 agent 注册,而不带 scope 的 `ctx.tools.get(name)` 理所当然查不到。`dsh-tool-skill` 正是这样写的,于是每次 preset 挂载都抛错;现在它与自己注册的那个定义比对。任何希望可被 preset 挂载的插件,都必须持有自己的注册对象,而不是按名字重新读取。
**entry 本地 `isolate` realm 不仅对宿主不可见,对 agent 自身的 scope 同样不可见。** 只有该组内部的行能解析到该服务。这正是让 preset 的 `skills` 注册表归属单个 agent 而非共享的原因——同时也意味着:被留在提供方组之外的消费方会静默解析到宿主注册表,然后什么都不贡献。
**只有空白会话才允许切换。** 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call因此 `agentPreset.select` 返回 `agent-preset-locked`。空白期的切换保留 agent 与 session只替换子树——因为宿主丢弃了它创建的 `AgentHandle`,也没有 delete RPC而保留它们本身就是更好的结果会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset并在新组装装载失败时恢复原来的那一份。
**创作 preset 是一次 RPC而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list``select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。
**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren``followup`因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次第二个会话本来也会相撞。注册表与后端属于宿主平面preset 贡献的是委派**工具**,它们解析宿主注册表。`workflows` 保持 entry-local因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。
**真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。
**preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下Node 向上查找 `node_modules` 永远够不到已安装的 harness因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。
**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset而非部署当前的默认值。
**持久化的头部字段,在每个后端都写入之前都算不上持久。** `agentPreset` 带着正确的理由落在了 `SessionHeader`而两个持久化后端都没有携带它JSONL 头部行、SQLite `sessions` 行、以及派生的查询索引各自逐列映射头部,于是被恢复的会话回来时没有 preset所有据以命名它的表层随之失声。`summarizeCold` 是同一个形状——它手工拼装冷列表行,而没有复用共享的投影。声明为持久的字段,需要一个跨越真实存储的测试,而不只是声明它的那个类型。
**这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态因为一旦跑过一个轮次preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。
**preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。**`--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB挂载耗时分别约 38 ms 与 135 ms进程里第一个 agent 另需约 7 MB那是 Node 首次 import 模块的一次性成本此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收50 个 `standard` 占住 57.8 MB释放后全部归还。所以对象图并不泄漏缺的是生命周期。`dsh-host-apiproxy` 创建后直接丢弃 `AgentHandle``archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB而在此之前约 0.2 MB。注意剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。
- 遗留 TODOidle agent 驱逐——会话持久化后 dispose恢复时重新挂载。它属于持有 handle 的那个宿主,不属于本 seam。
## 考虑过的替代方案
**在 scope 注册表中新增 preset 分层。** `ScopedLayers.merge()` 把全局层与恰好一个精确 scope 层合并。新增中间层可以让多个会话共用一份已挂载的组装,但它要改动 `dsh-scope` 及每个 scope 感知的注册表,换来的只是毫秒级的开销节省,而且会让 preset 的注册获得一个没有任何 agent 拥有的生命周期。
**把 agent 的 scope 键设为 preset。** 同一 preset 上的会话就能免费共享一层,但按 agent 的注册——`installAgentLlmTarget`、按 agent 的工具限制——会跨会话相撞。
**把每个 preset 作为子进程运行。** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) 已经证明完整的子 harness 可行,隔离性也会是绝对的。但这同时意味着要按会话代理流式输出、审批与投影,那是一个传输层项目,而非组装问题。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md
2026-08-08-per-preset-standing-mounts.md: 834d645f5f293a2e137b8faf662e301f1e8bb971
2026-08-08-per-preset-standing-mounts.zh.md: 45ce0f4e7dec28e5bf807898dc9cdbf32b8e4eb5

View File

@@ -0,0 +1,32 @@
# Agent Note: Per-preset standing mounts over a scope parent chain
Status: implemented
English | [中文](2026-08-08-per-preset-standing-mounts.zh.md)
## Problem
Per-session preset mounts made the model-facing registry surface per-agent while three independent host readers still assumed it was static: cold `session.history` found no presenters (every card silently degraded to the generic renderer — indistinguishable from "tool has no presenter"), the projections block dropped preset-registered keys (clients treat an omitted key as capability absence and CLEAR the row), and the TypeRT gateway resolved `goals` on the host root (`service-unavailable`). Patching each reader individually traded one silent degradation for another: resuming to reach presenters flipped the projections fold from detached to live and wiped the token counts instead.
## Decision
A preset is one composition per PROCESS, not one per session. The roster mounts it once under a synthetic standing scope; each agent joins by binding its scope key to the mount's (`bindScopeParent(agentKey, standingKey)`). Two `dsh-scope` mechanisms carry everything: registration views walk the parent chain (`agent → preset → global`, nearest shadowing farthest), and scoped dispatch admits listeners tagged with an ancestor of the carrier key — upward only, so a sibling preset's listeners stay deaf.
## Consequences
Standing mounts fix the class, not the instances: the registrations a reader needs exist for the process lifetime, keyed by preset id, no agent required. What made it cheap
- The stateful preset plugins (`plan-mode`, `token-meter`, `compact-basic`, `tasks-local`) already key state by `Session`/`Agent` — they predate presets. Sharing one instance is a return to their design, not a rewrite.
- Preset ymls are unchanged: one mount per preset = one Entry per preset, whose entry-local realms (`isolate: <name>: true`) keep two presets' same-named services apart exactly as they kept two sessions' apart.
- A shared realm label was NOT an option: `provide()` throws on a second registration under the same realm symbol, so labels pool the REALM, never the instance — a per-session world sharing a label crashes the second mount.
## Load-bearing details
- **Standing mounts hang off the service's untraced `selfCtx`.** A method invoked through the traceable proxy sees `this.ctx` rebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their own `inject` declares (`cannot get property "tools" without inject` while the entry's store holds it). The `tasks-local` selfCtx precedent, now with a second consumer.
- **A settled mount serves until its composition file's stamp changes.** The composition a running session joined must survive its file changing or disappearing; each generation records the file's stamp (mtime + size) and a session that finds it stale starts the next generation, so file edits — the only composition editor once authoring became copy-only — reach later sessions without any authoring call dropping the pointer. Joined sessions keep their generation, and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations.
- **`peek()` stays chain-blind.** Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a global-surface name for everything nested inside it).
- **Re-linking runs only through the `ScopeParentBinding` the mount's one bind returned** — the roster holds it privately, so the blank-session recompose path is the sole re-link and no other caller can move a composed agent; it stays valid only while nothing produced under the old parent is retained, which the holder must uphold because the relation cannot see session logs.
## Alternatives considered
Resume-on-read (wipes detached projections), a host-plane presenter table plus a block completeness flag (fixes two readers, leaves the class), per-session template mounts (duplicates every instance to serve pure functions). Kept for the record: the gateway-facing `goals` domain stays host-plane regardless — a Remote method whose receiver comes from a generated descriptor resolves on the host, which is the `bash-env` host-plane criterion read from the consuming side.

View File

@@ -0,0 +1,32 @@
# Agent Note: Per-preset standing mounts over a scope parent chain
Status: implemented
[English](2026-08-08-per-preset-standing-mounts.md) | 中文
## Problem
按会话挂载 preset 让面向模型的注册面变成按 agent 的,而三个独立的宿主读取方仍然假设它是静态的:冷读 `session.history` 找不到 presenter每张卡都静默退化成通用渲染器——与「工具本无 presenter」无法区分、投影块丢掉 preset 注册的键(客户端把缺失键当作能力不存在并**清掉**该行、TypeRT 网关在宿主根上解析 `goals``service-unavailable`)。逐个读取方打补丁只是拿一种静默降级换另一种:为拿到 presenter 而 resume会把投影折叠从 detached 翻到 livetoken 计数随之被抹掉。
## Decision
一个 preset 是**每进程**一份组装而不是每会话一份。roster 在一个合成常驻 scope 下挂载它一次;每个 agent 通过把自己的 scope key 绑定到挂载的 key`bindScopeParent(agentKey, standingKey)`)加入。两条 `dsh-scope` 机制承载了一切:注册视图沿父链解析(`agent → preset → global`,近者遮蔽远者),带作用域的分发对标签为载体键祖先的监听器放行——只向上,兄弟 preset 的监听器保持失聪。
## Consequences
常驻挂载修的是这一类问题而非其中的个例:读取方需要的注册在进程生命周期内始终存在,按 preset id 索引,不需要任何 agent。让它便宜的原因
- 有状态的 preset 插件(`plan-mode``token-meter``compact-basic``tasks-local`)本就按 `Session`/`Agent` 分键存状态——它们早于 preset 存在。共享一份实例是回归其设计,不是改写。
- preset 的 yml 不变:每 preset 挂一次 = 每 preset 一个 Entry其 entry 本地 realm`isolate: <name>: true`)让两个 preset 的同名服务互不相干,正如它从前隔开两个会话。
- 共享 realm label **不是**选项:`provide()` 对同一 realm 符号下的第二次注册直接抛错label 池化的是 REALM 而非实例——按会话挂载的世界里共享 label 会让第二次挂载崩溃。
## Load-bearing details
- **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。
- **挂载一旦成功即持续供职,直到组装文件的 stamp 变化。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stampmtime + 大小),发现过期的会话开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。
- **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。
- **重新认父只能经由挂载首绑返回的 `ScopeParentBinding`**——roster 私藏该句柄,空白会话 recompose 因此是唯一的重链路径,其他调用方无法挪动已组合的 agent其合法性仍以旧父之下产出一概不被保留为前提由持有方保证因为该关系看不见会话日志。
## Alternatives considered
冷读时 resume抹掉 detached 投影)、宿主面 presenter 表加投影块完整性标志(修两个读取方、留下这一类)、每会话模板挂载(为了服务纯函数而复制每一份实例)。留档:面向网关的 `goals` 域无论如何留在宿主平面——Remote 方法的接收者来自生成的 descriptor、在宿主上解析这正是 `bash-env` 宿主平面判据从消费侧读出的样子。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md
2026-08-09-layered-skill-registry.md: 3f092cfb4b722e3dd51fa4dc46c620259eaffa39
2026-08-09-layered-skill-registry.zh.md: 38b17329c8d46ee9bbd0863f3fae7cf6be39aa75

View File

@@ -0,0 +1,39 @@
# Agent Note: The skill registry is host-held and layered per scope
Status: implemented
English | [中文](2026-08-09-layered-skill-registry.zh.md)
## Problem
The agent-preset stack moved the whole skill capability — registry, local provider, and the `skill` tool — into each preset's `isolate` realm, because "which skills an agent has" is an agent-plane choice. That framing conflated two different questions: which skills a *deployment* supplies, and whether an *agent* consumes them. A repository plugin's prepared wrapper declares `inject: ['skills']` and mounts its skill root as a host-plane provider; with no host registry composed in the web and headless profiles, that wrapper waited forever and the repository-plugin e2e hung, which was bypassed at the time by dropping the fixture's skill root. A per-preset realm registry also made the gateway's skill listing depend on a live agent — a cold session's `/` popup had no registry to read at all.
The tools registry never had this problem: it is one host singleton layered per scope over `dsh-scope`, so deployment-level tools (MCP servers, plugin entries) register globally while a preset's rows register into that preset's layer.
## Decision
`SkillService` adopts the same shape. It holds `ScopedLayers<SkillLayer>`; `registerProvider()` and `register()` file into the layer of the calling context's scope, so host rows and repository plugins land in the global layer while a preset's `skill-local` — mounted by the standing composition, whose context carries the preset's scope key — lands in that preset's layer. Provider names are unique per layer rather than process-wide, which is what lets every preset mount its own `local` provider.
Reads take the viewing scope through `SkillViewOptions` (the calling agent, which is its own scope key). The registry merges the global layer with the scope's chain: **the nearest layer wins a duplicate name outright, and rank decides duplicates only within one layer** — the tools registry's shadowing rule. Rank-pooling across layers was considered and rejected: ranks were designed to order sources that know about each other, and under a global pool a later-installed repository plugin could silently displace a preset's own same-named skill by registration-order tiebreak, changing a preset's behavior remotely. Nearest-wins keeps a composition's behavior decided by its author.
Discovery caches are keyed by the resolved scope chain plus one revision counter, so a blank-session recompose — which re-parents the agent's scope key without touching the registry — is visible to the next read.
The composition moves with it: the web-app bundle re-enables the base `skill` registry row (only `skill-local` and `tool-skill` stay preset-owned), and preset compositions drop their `isolate: skills` realm for bare rows over the host registry. The gateway's skills domain reads the host registry in the presenter scope — the live agent, else the recorded preset's standing key — so a cold session lists the catalog its composition actually serves instead of failing; the `serviceFor` branch stays for compositions that still realm-mount their own registry.
## Consequences
**A deployment-level skill reaches every preset-composed session that mounts `tool-skill`.** The repository-plugin e2e's skill root and assertions are restored; the shipped-Web e2e proves the badge row (the same host-registration shape) merges into a standard-preset agent's catalog while the host view stays global-only.
**Layer visibility and consumption stay separate choices.** A core-web agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`.
**Provider options are still the borrowed caller object.** `SkillViewOptions` extends `SkillLookupOptions`; the registry consumes `scope` and providers read only their own contract from the same readonly object, preserving the existing borrow-identity guarantee.
**The TUI profile is unaffected.** With every row at host, there is exactly one (global) layer and the merged view equals the old single-registry view, ranks and all.
**Shadowing across layers is silent.** Within a layer the loser is logged as before; a nearer layer replacing a farther name follows the tools registry's convention and logs nothing. The registry still exposes no API to inspect shadowed definitions.
## Alternatives considered
**Rank pool across all visible layers.** Faithful to the single-registry precedence, but cross-layer ties break on registration order (boot-time providers always beat standing mounts), and a preset's own skill could be displaced by a deployment change it never sees. Rejected for composition stability; see Decision.
**Keep per-preset realm registries and deliver repository skills as directories a preset's provider scans.** Leaves the wrapper's `inject: ['skills']` contract broken (or forks the wrapper per profile), duplicates discovery configuration into every preset, and still gives cold sessions nothing to read. Rejected.

View File

@@ -0,0 +1,39 @@
# Agent Noteskill 注册表由宿主持有并按 scope 分层
Status: implemented
[English](2026-08-09-layered-skill-registry.md) | 中文
## 问题
agent-preset stack 曾把整个 skill 能力——注册表、本地提供方和 `skill` 工具——搬进每个 preset 的 `isolate` realm理由是"agent 拥有哪些 skill"属于 agent 平面的选择。这一框架混淆了两个不同的问题:*部署*供给哪些 skill与*agent*是否消费它们。repository 插件的 prepared wrapper 声明 `inject: ['skills']` 并把它的 skill 根目录挂载为宿主平面的提供方web 与 headless profile 不再组合宿主注册表后,该 wrapper 永远等待repository-plugin e2e 因而挂死,当时通过删掉 fixture 的 skill 根目录绕过。按 preset 的 realm 注册表还让网关的 skill 列表依赖存活 agent——冷会话的 `/` 弹窗根本没有注册表可读。
工具注册表从未有过这个问题:它是一个宿主单例,基于 `dsh-scope` 按 scope 分层因此部署级工具MCP 服务器、插件 entry注册进全局层preset 的行注册进该 preset 的层。
## 决定
`SkillService` 采用同一形态。它持有 `ScopedLayers<SkillLayer>``registerProvider()``register()` 落入调用方上下文 scope 对应的层——宿主行与 repository 插件落入全局层preset 的 `skill-local`(由常驻组合挂载,其上下文携带该 preset 的 scope key落入该 preset 的层。提供方名称在每层内唯一而非进程级唯一,这正是让每个 preset 都能挂载自己的 `local` 提供方的前提。
读取通过 `SkillViewOptions` 携带观察 scope调用中的 agentagent 本身就是自己的 scope key。注册表将全局层与该 scope 的链合并:**最近层直接赢得重名rank 只在单层内裁决重名**——即工具注册表的遮蔽规则。曾考虑跨层 rank 合池并予以否决rank 的设计前提是各来源彼此知情;在全局池下,后安装的 repository 插件可能凭注册顺序平手规则静默顶掉 preset 自带的同名 skill远程改变 preset 的行为。最近层优先让组合的行为由其作者决定。
发现缓存以解析后的 scope 链加一个修订计数为键,因此空会话重组——只重设 agent scope key 的父级、不触碰注册表——对下一次读取立即可见。
组合随之调整web-app bundle 重新启用 base 的 `skill` 注册表行(只有 `skill-local``tool-skill` 仍归 presetpreset 组合拆掉 `isolate: skills` realm改为直接落在宿主注册表上的平铺行。网关的 skills 域以 presenter scope 读取宿主注册表——存活 agent否则记录在案的 preset 的 standing key——冷会话由此列出其组合真正供给的目录而不再报错`serviceFor` 分支保留,兼容仍以 realm 自挂注册表的组合。
## 影响
**部署级 skill 会到达每个挂载 `tool-skill` 的 preset 会话。**repository-plugin e2e 的 skill 根目录与断言已恢复shipped-Web e2e 证明 badge 行(同一种宿主注册形态)汇入 standard preset agent 的目录,而宿主视图保持仅全局。
**层可见性与消费仍是两个独立选择。**core-web agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。
**提供方选项仍是借用的调用方对象。**`SkillViewOptions` 扩展 `SkillLookupOptions`;注册表消费 `scope`,提供方只从同一个只读对象中读取自己的契约,保持既有的借用恒等保证。
**TUI profile 不受影响。**所有行都在宿主时只有一个全局合并视图等于旧的单注册表视图rank 行为不变。
**跨层遮蔽是静默的。**层内败者照旧记录日志;较近层顶替较远层的名称沿用工具注册表的惯例,不记录。注册表仍不提供检查被遮蔽定义的 API。
## 曾考虑的替代方案
**跨全部可见层的 rank 合池。**忠实于单注册表的优先级但跨层平手按注册顺序裁决启动期提供方永远赢过常驻挂载preset 自带 skill 可能被它看不见的部署变更顶掉。因组合稳定性否决;见"决定"。
**保留按 preset 的 realm 注册表,把 repository skill 作为目录交给 preset 的提供方扫描。**wrapper 的 `inject: ['skills']` 契约仍然破损(或者按 profile 分叉 wrapper发现配置在每个 preset 里重复,冷会话依旧无处可读。否决。

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 .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md
2026-08-03-cli-signal-shutdown-escalation.md: c8aac6e2be927bd1f4a445c00e0aaa870b10a465
2026-08-03-cli-signal-shutdown-escalation.zh.md: 14e2149eb5153b3743e188c6b233d000b36d60db
2026-08-03-cli-signal-shutdown-escalation.md: 55917400fac2728d13dc2cdd799a7e234b6ed661
2026-08-03-cli-signal-shutdown-escalation.zh.md: c7897a8d77e8c2ebad43cec4e12170b04c837350

View File

@@ -20,11 +20,13 @@ The fix has two ownership layers. The OTel backend adds `shutdownTimeoutMillis`
Web and headless share `createProcessShutdown`, one process-level controller around root disposal:
- Normal shutdown calls coalesce onto one disposal and retain the first requested exit code; they never escalate one another.
- Normal shutdown calls coalesce onto one disposal and retain the first requested exit code; they never escalate one another. Successful disposal records that code through `process.exitCode` and lets Node drain its remaining handles naturally. Disposal failure still forces process exit because the launcher cannot assume the failed tree reached quiescence.
- The first signal starts the same graceful disposal and a referenced five-second exit backstop. Disposal success or failure exits once; neither can cancel the process exit.
- A signal received while shutdown is pending forces immediate exit with that signal path's code. This includes the first `Ctrl+C` after headless normal completion has already entered disposal, and a second signal after a signal initiated the drain.
- The five-second bound is a process-safety invariant, not a deployment tunable. It is long enough for the telemetry deployment's ordinary drain ceiling while still bounding any wedged disposer at the launcher boundary.
Normal completion deliberately avoids `process.exit()`: an immediately forced exit after an Undici request can hit Node's [Windows libuv async-handle assertion](https://github.com/nodejs/node/issues/56645) before the completed request's native handle cleanup drains. A signal can still force exit after normal disposal has completed if another handle keeps the process alive.
Headless preserves exit 0 for a completed turn, exit 1 for another turn-end reason or API business error, 130 for SIGINT, and 143 for SIGTERM. Web preserves its existing SIGTERM exit 0 and SIGINT exit 130 behavior.
This supersedes the [telemetry deployment Note's](../feature/2026-07-31-web-telemetry-default-mount.md) assumption that SDK exporter/processor timeouts bound complete provider shutdown, and its earlier decision to defer a process-level backstop. The backend owns its export loss/latency policy and closes the known SDK `forceFlush()` gap; the launcher owns the outer guarantee that no plugin can trap the process indefinitely.
@@ -37,15 +39,17 @@ This supersedes the [telemetry deployment Note's](../feature/2026-07-31-web-tele
**Add only the five-second timeout.** Rejected because a user pressing `Ctrl+C` again is asking to stop waiting now. Swallowing that intent for the rest of the grace period recreates the reported behavior at a shorter duration.
**Always call `process.exit()` after successful disposal.** Rejected because root disposal proves the application tree is quiescent, not that Node and its native dependencies have finished retiring every asynchronous handle. Setting `process.exitCode` preserves the requested status while letting the runtime finish that work.
## Consequences
A healthy exit still disposes the complete Cordis tree. The known telemetry wait releases after at most three seconds; any other wedged exit lasts at most five seconds without further input, and a repeated signal ends it immediately. Forced or deadline-bounded exit can interrupt telemetry export or remaining cleanup, which is intentional only after the graceful contract has failed or the user has explicitly escalated.
A healthy normal exit still disposes the complete Cordis tree and then waits for Node's event loop to drain. The known telemetry wait releases after at most three seconds; any other wedged exit lasts at most five seconds without further input, and a signal ends a lingering normal completion or pending shutdown immediately. Forced or deadline-bounded exit can interrupt telemetry export or remaining cleanup, which is intentional only after the graceful contract has failed or the user has explicitly escalated.
The controller is launcher infrastructure rather than a Cordis plugin: it makes no claim that disposal completed, and it does not weaken the lifecycle rule that ordinary disposers must reach quiescence.
## Testing
`apps/cli/tests/process-shutdown.spec.ts` pins resolved and rejected disposal, the five-second backstop, normal-call coalescing, a signal interrupting normal disposal, and second-signal escalation.
`apps/cli/tests/process-shutdown.spec.ts` pins natural completion after resolved disposal, forced exit after rejected disposal, the five-second backstop, normal-call coalescing, signal-owned disposal, a signal interrupting normal disposal or post-disposal handle draining, and second-signal escalation.
`apps/cli/tests/headless-shutdown.e2e.ts` boots the real shipped Web/headless Loader tree in a PTY with a test-only plugin whose disposer announces entry and never settles. The test sends SIGINT after the observation URL, waits for proof that disposal started, sends SIGINT again, and requires exit 130. The source/artifact launch resolver keeps the same regression on both execution planes. This PTY case covers the user-visible process state; no model-output snapshot changes.

View File

@@ -20,11 +20,13 @@ Status: implemented
Web 与 headless 共用 `createProcessShutdown`,它是围绕根级 dispose 建立的进程级控制器:
- 多次正常关闭调用会汇合到同一次 dispose并保留首次请求的退出码这些调用不会相互触发强制退出。
- 多次正常关闭调用会汇合到同一次 dispose并保留首次请求的退出码这些调用不会相互触发强制退出。dispose 成功后,控制器通过 `process.exitCode` 记录该退出码,让 Node 自然排空剩余句柄dispose 失败时仍强制退出,因为启动器不能假定失败的插件树已经完全停稳。
- 第一个信号会启动同一次优雅 dispose并设置一个带引用的 5 秒退出兜底。dispose 无论成功或失败都会触发且仅触发一次退出;任何一种结果都无法取消进程退出。
- 关闭待结算期间收到信号时,会立即按该信号路径的退出码强制退出。这既包括 headless 正常完成已经进入 dispose 后收到的第一次 `Ctrl+C`,也包括由信号启动排空后收到的第二个信号。
- 5 秒上限是进程安全不变式,而不是部署调节项。它足以覆盖遥测部署的常规排空时限,同时仍在启动器边界为任何卡死的 disposer 设置等待上限。
正常完成会刻意避免调用 `process.exit()`Undici 请求刚完成后立即强制退出,可能会在原生句柄清理尚未排空时触发 Node 的 [Windows libuv 异步句柄断言](https://github.com/nodejs/node/issues/56645)。如果正常 dispose 已经完成,但仍有其他句柄让进程保持存活,信号依然可以强制退出。
headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API 业务错误仍以 1 退出,对 SIGINT 以 130 退出,对 SIGTERM 以 143 退出。Web 保留现有行为SIGTERM 以 0 退出SIGINT 以 130 退出。
这项决策取代了[遥测部署 Agent Note](../feature/2026-07-31-web-telemetry-default-mount.md) 中 SDK 导出器/处理器超时能够限制提供方完整关闭流程的假设,也取代了其中暂缓进程级退出兜底的决定。后端负责导出数据丢失与延迟策略,并封住已知的 SDK `forceFlush()` 缺口;启动器负责最外层保证,确保任何插件都无法无限期困住进程。
@@ -37,15 +39,17 @@ headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API
**只增加 5 秒超时。** 不予采纳:用户再次按下 `Ctrl+C`,就是要求立即停止等待。若在剩余宽限期内继续吞掉这一意图,只是缩短了报告中故障的持续时间,并未解决问题。
**dispose 成功后仍总是调用 `process.exit()`。** 不予采纳:根级 dispose 只能证明应用插件树已经完全停稳,不能证明 Node 及其原生依赖已经回收所有异步句柄。设置 `process.exitCode` 既保留请求的状态码,也允许运行时完成这部分工作。
## 后果
健康的退出流程仍会对整棵 Cordis 插件树执行 dispose。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒,再次收到信号立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭约定已经失败,或用户明确要求强制退出时,才会有意接受这一结果。
健康的正常退出流程仍会对整棵 Cordis 插件树执行 dispose,随后等待 Node 事件循环自然排空。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒收到信号时,仍在排空句柄的正常完成流程或待结算的关闭流程都会立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭约定已经失败,或用户明确要求强制退出时,才会有意接受这一结果。
该控制器属于启动器基础设施,而不是 Cordis 插件:它不会声称 dispose 已经完成,也不会削弱普通 disposer 必须达到完全停稳状态的生命周期规则。
## 测试
`apps/cli/tests/process-shutdown.spec.ts` 固定了 dispose 成功与失败、5 秒退出兜底、正常调用汇合、信号中断正常 dispose,以及第二次信号强制退出的行为。
`apps/cli/tests/process-shutdown.spec.ts` 固定了 dispose 成功后的自然完成、dispose 失败后的强制退出、5 秒退出兜底、正常调用汇合、信号拥有的 dispose、信号中断正常 dispose 或 dispose 后句柄排空,以及第二次信号强制退出的行为。
`apps/cli/tests/headless-shutdown.e2e.ts` 在 PTY 中启动真实交付的 Web/headless Loader 插件树,并挂载一个仅用于测试的插件;该插件的 disposer 会声明已经进入清理流程,但永不结算。测试在观察地址出现后发送 SIGINT等待 dispose 已启动的证据,再次发送 SIGINT并要求进程以 130 退出。源码/产物启动解析器使两个执行平面都覆盖同一项回归。该 PTY 用例覆盖用户可见的进程状态;模型输出快照没有变化。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md
2026-08-09-broken-preset-roster-rows.md: fef6a183b10f98b8ae9d2b42701380c69bc83462
2026-08-09-broken-preset-roster-rows.zh.md: 196bcf4ef16325a1d7692d2ea13d9fa683d500f4

View File

@@ -0,0 +1,33 @@
# Agent Note: Broken presets are roster rows, not gaps
Status: implemented
English | [中文](2026-08-09-broken-preset-roster-rows.zh.md)
## Problem
With files as the only composition editor, hand-edit damage had two failure shapes and both were silent until the worst moment. A preset whose `agent.cordis.yml` no longer parsed listed as a perfectly ordinary row — selectable, copyable, settable as the default — and failed only when the next session tried to mount it; set as default, every new session failed to start. A directory whose composition file was deleted outright vanished from the roster while still occupying its id on disk: `copy` refused the name with "delete the existing preset first" and `remove` answered "not found" — two contradictory errors with no way out short of hand-deleting the directory.
## Decision
Discovery owns health, and a damaged directory is a **roster row carrying a `broken` reason**, never a gap. `scanRoot` treats every directory whose name is a usable preset id as a preset slot: composition missing → broken ("still occupies the id; delete it or restore the file"), composition unreadable/unparsable/not-a-list-of-named-rows → broken with the parser's first line. The shape check parses with the loader's own `entryListSchema` (the `!!js` dialect), so health can never call broken what the loader would accept; directories whose names fail `PRESET_ID` are skipped outright, because no copy could ever collide with them. `broken` rides `AgentPreset`, the `agentPreset.list` wire entry, and the UI row. Mounting paths (`mount`/`recompose`/`standingKeyFor`) refuse a broken preset up front via `resolveMountable` with the discovery-reported reason; `resolve` still answers (delete/read/report need the row), and `copy`'s roster check now sees ghosts, which turns the "already exists" refusal actionable — the broken card to delete is on the same page.
Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure.
## Consequences
- The ghost dead end is gone end to end: the directory lists broken, its delete clears it, and the freed id is immediately claimable (covered by unit, component, and e2e tests).
- A default that later breaks still fails the session start loudly — the pickers hide broken rows, but nothing rewrites a stored default; `resolveMountable`'s early refusal is the same message every unloadable shape gets, instead of loader-dependent errors.
- Health runs on every `list()`: one read+parse per preset per roster read, accepted for the same reason unmemoized discovery was — rosters are small and freshness is the contract.
- Copying broken is refused in the UI only (disabled with reason); the host keeps `copy` shape-agnostic. A broken source yields an equally broken, equally visible copy — no capability is gained, and the host-side refusal would have needed its own error vocabulary for no journey that survives the disabled button.
## Load-bearing details
- **`PRESET_ID` moved to `types.ts`** so discovery and authoring share one containment vocabulary; authoring re-exports it unchanged.
- **The reason is one line.** js-yaml appends a multi-line code-frame snippet; the roster card is not a terminal, so `compositionProblem` keeps the first line.
- **Two mount.spec races were left untouched deliberately**: `ensureStanding` is still reachable with a preset resolved just before deletion (the private-path tests), and its stamp/unstampable semantics are unchanged — the health check happens before, in the public route.
- **Creator-mode guidance rides the same PR**: the `cordis` preset's persona now forbids editing the shipped install (corrupting `cordis` would disable the mode itself) and points authoring at `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`; its skill teaches `preset.yml` metadata, the copy-first workflow, the one-escalation sandbox reality (the preset root lies outside the session workspace), and honest verification (the agent cannot start sessions; the settings page's red marking is the user's check). Verified live: asked to edit the shipped `cordis` composition directly, the composed agent refuses citing both rules and offers the copy path; asked for a real preset, it lands it under `$DSH_HOME`, batches writes into one escalation, self-checks with the loader dialect, and hands verification to the user.
## Alternatives considered
Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment.

View File

@@ -0,0 +1,33 @@
# Agent Note损坏的 preset 是名单行,不是空缺
Status: implemented
[English](2026-08-09-broken-preset-roster-rows.md) | 中文
## 问题
文件成为唯一的组装编辑器之后,手动编辑造成的损坏有两种形态,且都要拖到最糟的时刻才暴露。`agent.cordis.yml` 解析不了的 preset 在名单上是一张完全正常的行——可选择、可复制、可设为默认——直到下一个会话尝试挂载才失败;一旦被设为默认,所有新会话都无法启动。组装文件被整个删掉的目录则从名单上消失,却仍在磁盘上占着它的 id`copy` 以「先删除既有 preset」拒绝这个名字`remove` 却回答「找不到」——两条互相矛盾的错误,除了手动删目录别无出路。
## 决定
发现过程负责健康,受损目录是**携带 `broken` 原因的名单行**,绝不是空缺。`scanRoot` 把名字是可用 preset id 的每个目录都当作一个 preset 槽位:组装缺失 → broken「仍占着该 id删除目录或恢复文件」组装不可读/解析失败/不是具名行列表 → broken 并携带解析器的首行。形状检查用加载器自己的 `entryListSchema`(含 `!!js` 的方言)解析,因此健康检查绝不会把加载器接受的组装叫作损坏;名字不符合 `PRESET_ID` 的目录直接跳过,因为复制永远不可能与之相撞。`broken` 依次落在 `AgentPreset``agentPreset.list` 的线上条目和 UI 行上。挂载路径(`mount`/`recompose`/`standingKeyFor`)经 `resolveMountable` 用发现时记下的原因在前置拒绝;`resolve` 照样应答(删除/读取/上报都需要这一行),而 `copy` 的名单检查现在看得见幽灵,让「已存在」的拒绝变得可操作——要删的损坏卡片就在同一页上。
界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给),而两个选择器(通用设置行、新会话 chip`presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装端出无法组装的选项只会推迟失败。
## 后果
- 幽灵死路端到端消除:目录以损坏行列出,删除即清掉,释放的 id 立刻可用(单测、组件测试与 e2e 各自覆盖)。
- 事后才损坏的默认值仍会在会话启动处大声失败——选择器隐藏损坏行,但没有任何东西改写已存的默认;`resolveMountable` 的前置拒绝让每种不可加载形态得到同一条消息,而不是依赖加载器内部的报错。
- 健康检查随每次 `list()` 运行:每次读名单对每个 preset 一次读取加解析,接受的理由与不做缓存的发现相同——名单很小,新鲜是契约。
- 复制损坏 preset 只在 UI 层拒绝(按钮禁用并给出原因);宿主的 `copy` 保持形状无关。损坏来源产出同样损坏、同样可见的副本——没有能力增益,而宿主侧拒绝需要为一条被禁用按钮挡住的路径专门发明错误词汇。
## 关键细节
- **`PRESET_ID` 移到 `types.ts`**让发现与创作共享同一份包含边界词汇authoring 原样转发导出。
- **原因只留一行。** js-yaml 会附上多行代码框摘录;名单卡片不是终端,`compositionProblem` 只保留首行。
- **mount.spec 的两个竞态用例特意不动**`ensureStanding` 仍可能拿到删除前一刻解析出的 preset私有路径测试其 stamp/unstampable 语义不变——健康检查发生在此之前的公开路径上。
- **创造模式的引导随同一 PR 落地**`cordis` preset 的 persona 现在禁止编辑随附安装(损坏 `cordis` 会禁用这一模式本身),并把创作指向 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`;其技能新教了 `preset.yml` 元信息、先复制再改的流程、一次升级的沙箱现实preset 根目录在会话工作区之外与诚实的验证方式agent 无法自己启动会话;设置页的红色标记是用户的检查项)。已实测:被要求直接改随附 `cordis` 组装时,组装出的 agent 援引两条规则拒绝并给出复制路径;被要求真正创建 preset 时,它落在 `$DSH_HOME` 下、把写入合并为一次升级、用加载器方言自查、并把验证交还用户。
## 曾考虑的替代方案
隐藏损坏 preset 但在复制时用更好的报错拒绝该 id幽灵仍然无法从任何界面清除。深度校验读名单时解析每一行的模块挂载已经拥有这一失败并带回滚每次读名单逐行 import 既不便宜也不更可操作。阻止 `settings` 写入指向损坏默认值settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。

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 .agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.md
2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6
2026-08-01-pwsh-tool-and-executor.zh.md: c59ba8e3e6c68d48d310861325c74dc6cec8e5c3
2026-08-01-pwsh-tool-and-executor.md: 855d8e5db8a78e7e798061724d89fde31b28e975
2026-08-01-pwsh-tool-and-executor.zh.md: dcf5c53cd6681be07adafef92d556b400c116736

View File

@@ -6,30 +6,30 @@ English | [中文](2026-08-01-pwsh-tool-and-executor.zh.md)
## Problem
The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool itself is also far larger than a Windows-first profile needs: background tasks, sandbox escalation, and the persistent-PTY twin are all bash-shaped surface that a minimal `pwsh` tool should not carry.
The harness spoke one shell dialect on every platform: `bash`. Windows hosts could run it only through WSL or Git-Bash shims, and the shipped `dsh-bash-local` executor is POSIX-only (`bash` hardcoded, process-group semantics POSIX). The Windows roadmap — defaulting hosts to `pwsh`, later pwsh TUI/GUI rendering — had no execution foundation: there was no PowerShell implementation of the bash executor seam and no model-facing tool that taught the PowerShell dialect. The bash tool is also larger than a Windows-first profile strictly needs — the persistent-PTY twin in particular is bash-shaped surface the `pwsh` tool still does not carry. The original minimal profile also left out background tasks and sandbox escalation: background arrived with the [parity decision](2026-08-02-pwsh-tool-bash-parity.md), and the sandbox surface (denial rendering plus `sandbox_permissions` escalation) arrived with the [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) — the minimal tool was sized for the danger-full-access Windows posture, and that premise ended when the sandbox PR re-enabled confinement and approval on Windows.
## Decision
Two new packages under `packages/bash/`:
- **`@deepseek-ai/dsh-pwsh-local`** — a local implementation of the `ctx.bash` executor seam over `ctx.subprocess`, mirroring `dsh-bash-local` call-for-call: `resolve()` defaults and caps from config, `run()` fuses the config-clamped timeout with the caller's signal through one deadline, `start()` returns a consuming background handle whose processes belong to the subprocess service. The command string rides as ONE argv element to `pwsh -NoLogo -NoProfile -NonInteractive -Command`, so PowerShell parses it and no shell-quoting layer exists. Executable resolution (`resolvePwshPath`) is a pure function of `(configured, env, platform)`: explicit config first, then Windows probes PowerShell 7's install, PATH entries (quotes stripped), and Windows PowerShell 5.1, else a bare `pwsh` via PATH.
- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call minus the sandbox surface: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, and the bash marker/truncation rendering story (a clean exit produces no marker). The parity decision supersedes this note's minimal-profile tool description.
- **`@deepseek-ai/dsh-tool-pwsh`** — the model-facing tool over `ctx.bash`, PowerShell-dialect by contract, mirroring `dsh-tool-bash` call-for-call: foreground and `run_in_background` execution through the generic task runtime, managed `DSH_*` environment through the shared [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) registry, the bash marker/truncation rendering story (a clean exit produces no marker), and — since the Windows ACL sandbox decision — the sandbox denial rendering and `sandbox_permissions` escalation surface, plus the Windows-specific ConstrainedLanguage and named-pipe contracts in the tool description. The parity decision supersedes this note's minimal-profile tool description.
Windows vitest coverage is deliberately NOT part of this change: the repo's Windows CI lane owns build/static gates, and unit coverage runs on Linux, where both packages' suites run against a real `pwsh` (preinstalled on the GitHub-hosted runners) or self-skip when absent. The vitest `windowsUnsupportedPackages` exclusion narrows from `packages/bash/*` to the bash-requiring packages so the pwsh suites can also run natively on Windows dev machines.
The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [a proposal](../../proposed/feature/2026-08-01-windows-pwsh-default.md).
The roadmap beyond this decision — defaulting Windows hosts to `pwsh` (bash off), and pwsh TUI/GUI rendering — is recorded separately as [the Windows pwsh default decision](2026-08-01-windows-pwsh-default.md).
## Alternatives considered
**Extend `dsh-bash-local` with a pwsh mode.** Rejected: the executor's identity is the shell it spawns; a second dialect inside one package doubles its config surface (`shell` switches) and its test matrix, and the two dialects' quirks (signal facts on Windows, quoting domains) belong to their own packages' documentation.
**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the bash tool's background/sandbox surface is bash-shaped; a `pwsh` mode would either hide it (conditional schema churn) or inherit it (surface the minimal profile explicitly rejects). The minimal twin keeps the model contract honest.
**Extend `dsh-tool-bash` with a dialect parameter.** Rejected: the model-visible contract is the dialect itself (paths, variables, exit facts differ), so a dialect parameter would either churn the schema conditionally or force one tool to teach two dialects; the separate twin keeps the model contract honest — and carries the shared surfaces (background, sandbox, rendering) by mirroring rather than by sharing an implementation.
**Wire the pwsh tool into the shipped CLI compositions now.** Rejected: mounting `tool-pwsh` + `pwsh-local` in `base.cordis.yml` would change the shipped roster before the Windows-default decision lands; this change ships the capability and its wiring points (`apps/cli` dependencies, tsconfig projects) without switching any default.
## Consequences
- The bash executor seam gains a second, Windows-native implementation with an identical request/spec contract, so model-facing consumers beyond `tool-pwsh` (hooks bridges, in-process plugins) can run PowerShell without dialect shims.
- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground and background work (minus sandbox), with prompt guidance that states the marker contract precisely.
- `tool-pwsh` is the model-visible Windows-first shell tool: behaviorally interchangeable with the bash tool for foreground, background, and sandboxed work — including the same-turn `sandbox_permissions` escalation through `ctx.approval` — with prompt guidance that states the marker contract, the sandbox denial/escalation vocabulary, and the ConstrainedLanguage and named-pipe boundaries precisely.
- Windows semantics differ where the platform differs: forced termination reports exit 1 with no signal (so `signal`/`killed` status facts are POSIX-only), and PowerShell writes CRLF, which tests normalize.
- The CLI gains two workspace dependencies and two tsconfig projects without mounting either plugin — the composition decision stays with the Windows-default proposal.

View File

@@ -6,30 +6,30 @@ Status: implemented
## 问题
harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX硬编码 `bash`,进程组语义是 POSIX 的。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生是 bash 形状表面,最小化的 `pwsh` 工具不该背负
harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX硬编码 `bash`,进程组语义是 POSIX 的。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具大于 Windows 优先画像的严格所需——尤其持久 PTY 孪生是 `pwsh` 工具至今仍不背负的 bash 形状表面。最初的最小画像也没有后台任务与沙箱升级:后台随 [parity 决策](2026-08-02-pwsh-tool-bash-parity.md) 到来,沙箱面(拒绝渲染加 `sandbox_permissions` 升级)随 [Windows ACL sandbox 决策](2026-08-08-windows-acl-restricted-token-sandbox.md) 到来——最小工具当初按 danger-full-access 的 Windows 姿态裁剪,这一前提在 sandbox PRPull Request于 Windows 上重新启用隔离与审批时终结
## 决策
`packages/bash/` 下新增两个包:
- **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local``resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`
- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,约定是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、减去 sandbox 面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染故事(干净退出不产生 marker。parity 决策取代了本 note 的最小画像工具描述。
- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,约定是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境bash 的 marker/截断渲染故事(干净退出不产生 marker,以及——自 Windows ACL sandbox 决策以来——沙箱拒绝渲染与 `sandbox_permissions` 升级面,外加工具描述中的 Windows 专属 ConstrainedLanguage 与 named-pipe 约定。parity 决策取代了本 note 的最小画像工具描述。
Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行GitHub 托管 runner 预装或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。
本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。
本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash与 pwsh TUI/GUI 渲染——已落地为 [Windows 默认 pwsh 决策](2026-08-01-windows-pwsh-default.md)。
## 备选方案
**给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell在一个包内塞第二种方言会翻倍配置面`shell` 开关与测试矩阵且两种方言的怪癖Windows 上的信号实情、引号域)应各自归入自己包的文档。
**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动,要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型约定保持诚实
**给 `dsh-tool-bash` 增加方言参数。** 否决:模型可见约定本身就是方言(路径、变量、退出事实都不同),因此方言参数要么让 schema 按条件翻动,要么逼一个工具教两种方言;独立的孪生让模型约定保持诚实——并以镜像而非共享实现的方式携带共享表面(后台、沙箱、渲染)
**现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。
## 后果
- bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范约定一致,因此 `tool-pwsh` 之外的面向模型消费方hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。
- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台后台工作(减 sandbox上与 bash 工具行为可互换,提示词指导精确陈述 marker 约定
- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台后台与沙箱化工作上与 bash 工具行为可互换——包括经 `ctx.approval` 的同轮次 `sandbox_permissions` 升级——提示词指导精确陈述 marker 约定、沙箱拒绝/升级词汇,以及 ConstrainedLanguage 与 named-pipe 边界
- Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIXPowerShell 输出 CRLF测试做归一化。
- CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md
2026-08-01-windows-pwsh-default.md: f0da86e52bcdd53a10b60164d7cc12261cfc5c49
2026-08-01-windows-pwsh-default.zh.md: 41a6429eab8f86a8960ac4aa372aeacfda4661c4

View File

@@ -0,0 +1,44 @@
# Agent Note: Windows defaults to pwsh
Status: implemented
English | [中文](2026-08-01-windows-pwsh-default.zh.md)
## Problem
The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior (hardcoded `bash -c` argv, process-group semantics); the model-facing bash tool teaches the bash dialect. The Windows-native foundation shipped in the [pwsh executor and tool decision](2026-08-01-pwsh-tool-and-executor.md) — a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but shipped compositions still mounted the bash stack on Windows, so a Windows host without a shim could not run the shipped shell.
## Decision
Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged.
- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` service is disabled — nothing in the Windows roster asks for approval, so the model is never told approval exists or that asks are auto-rejected. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce.
- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud.
- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style.
The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior.
## Alternatives considered
**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config.
**Ship the platform layer from `apps/cli` code instead of a bundle data file.** Rejected: the patch belongs next to the rows it replaces, in the bundle that owns them, so the shipped roster stays visible as composition data and dumps carry its provenance; the launcher contributes only the win32 gate.
**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor.
**Keep fs path-rule confinement on Windows (`sandbox-policy` + `fs-sandbox` without OS runners).** Rejected: the shell is the model's primary tool and unconfined on Windows, so fs-only path rules are trivially bypassable and would overstate the boundary; the honest posture is full degradation to danger-full-access.
**Ship a `DSH_WINDOWS_SHELL` environment escape hatch.** Rejected: decisive behavior changes belong in composition config, which already overrides the platform layer row by id; a second override channel would split the single source of truth for roster decisions.
## Consequences
- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled).
- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass.
- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows.
- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-local`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel.
## Verification
- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure with the platform injected, and composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm to assert the win32 danger-full-access roster and the base-only-profile warning; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows patch file shape (disables, inserts, and the absent approval service).
- Keyless: a win32 `dsh --profile <name> --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged.
- The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes).

View File

@@ -0,0 +1,44 @@
# Agent Note: Windows 默认改用 pwsh
Status: implemented
[English](2026-08-01-windows-pwsh-default.md) | 中文
## 问题
harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片WSL 或 Git-Bash或退回到仅 POSIX 的 `dsh-bash-local` 行为(硬编码 `bash -c` argv、进程组语义面向模型的 bash 工具教的是 bash 方言。Windows 原生基础已随 [pwsh 执行器与工具决策](2026-08-01-pwsh-tool-and-executor.md) 交付——`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但交付组合在 Windows 上仍然挂载 bash 栈,没有垫片的 Windows 主机跑不了交付的 shell。
## 决策
启动交付 profile`dsh web``dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈POSIX 主机不变。
- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runnerlandlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox``sandbox-policy``fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access`permission`/`ui-permission` 离开清单dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 服务也被禁用——Windows 清单里没有任何动作需要审批,模型也不会被告知"审批存在"或"请求会被自动拒绝"。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。
- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts``win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间覆盖所有组合路径启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。
- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli``dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh``dsh-base` 还声明 `dsh-fs-local`按仓库惯例base bundle 把每个行插件都列为依赖。
pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。
## 备选方案
**在 `dsh-bash-local` 内部让 Windows 默认 pwsh一个执行器方言开关** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell而按平台门控的组合是部署选择不是执行器配置。
**从 `apps/cli` 代码而非 bundle 数据文件交付平台层。** 否决patch 应放在它替换的行旁边、属于拥有这些行的 bundle让交付清单作为组合数据保持可见、转储带有出处启动器只贡献 win32 门控。
**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。
**在 Windows 上保留 fs 路径规则限制(无 OS runner 的 `sandbox-policy` + `fs-sandbox`)。** 否决shell 是模型的主工具且在 Windows 上不限权,仅限 fs 的路径规则一行命令即可绕过,会夸大边界;诚实的姿态是完全退化到 danger-full-access。
**交付 `DSH_WINDOWS_SHELL` 环境变量逃生门。** 否决:决定性的行为变更应集中在组合配置中,而组合配置已能按行 id 覆盖平台层;第二条覆盖通道会分裂清单决策的单一事实来源。
## 后果
- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。
- Windows 上没有任何沙箱fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。
- POSIX 主机不变平台层永不生效bash 栈仍是通用 `cordis.patch.yml` 的行。
- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud——组合配置是唯一的覆盖通道。
## 验证
- 单元:`apps/cli/tests/windows-shell.spec.ts` 以平台注入固定 win32 默认、自定义 profile 跳过与缺文件失败,并通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app断言 win32 danger-full-access 清单与 base-only profile 警告;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows patch 文件形状(禁用、插入与缺席的 approval 服务)。
- Keylesswin32 上的 `dsh --profile <name> --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行POSIX 转储CI Linux不变。
- 真实组合冒烟在 win32 上启动 web profilepwsh 栈挂载成功(即本笔记描述的确切清单)。

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 .agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md
2026-08-02-pwsh-tool-bash-parity.md: d61dd6f21223121973520014c44e2e5846e7387a
2026-08-02-pwsh-tool-bash-parity.zh.md: 120f66b1d9c713d5e3b71575186fc2194b0718a5
2026-08-02-pwsh-tool-bash-parity.md: 79a09cb4c9698660faff18cf9ae016bec0bce227
2026-08-02-pwsh-tool-bash-parity.zh.md: 3e01fd4447fbf047b7cbb3949e708094a6c06c09

View File

@@ -10,13 +10,13 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi
## Decision
`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, minus the sandbox surface, and its model-visible text describes exactly that behavior:
`dsh-tool-pwsh` now mirrors `dsh-tool-bash` call-for-call, and its model-visible text describes exactly that behavior:
- **Rendering adopts the bash story verbatim**: stdout, a marked `[stderr]` section, truncation notices with spill paths, `(no output)` for an empty body, and exit markers only for non-zero exits — a clean exit produces no marker. The description and the `tool:pwsh` prompt section state this precisely ("Non-zero exits are reported as `[exit code: N]` markers"), deliberately not copying the bash prompt's "every result" phrasing, which its own renderer contradicts.
- **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it.
- **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls; shared environment ownership therefore sits outside either model-facing shell tool.
- **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker.
- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision.
- **Out of scope, unchanged**: persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). Sandbox escalation shipped later with the [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) — the pwsh tool now carries the sandbox denial rendering and the same-turn `sandbox_permissions` escalation surface, plus the Windows ConstrainedLanguage contract in its description. The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision.
## Alternatives considered
@@ -28,7 +28,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi
## Consequences
- The bash and pwsh tools are now behaviorally interchangeable for foreground and background shell work (minus sandbox), and the pwsh prompt/description sentences are each backed by the renderer.
- The bash and pwsh tools are now behaviorally interchangeable for foreground, background, and sandboxed shell work (the sandbox surface arrived with the Windows ACL sandbox decision), and the pwsh prompt/description sentences are each backed by the renderer — the reviewer's grep-against-code check passes.
- Parity ran BOTH ways once: the pwsh tool's structured foreground abort (`HarnessError('tool call aborted', TOOL_ABORTED)` with name `AbortError`) was backported to the bash tool, replacing its uncoded `Error('command aborted')` — a model-visible/logged change pinned by exact-shape tests on both sides and by the cancel-tool-calls fixture.
- `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do).
- Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before.

View File

@@ -10,13 +10,13 @@ Status: implemented
## 决策
`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`减去 sandbox 面,其模型可见文本精确描述这一行为:
`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,其模型可见文本精确描述这一行为:
- **渲染完全采用 bash 故事**stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。
- **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。
- **`DSH_*` 环境共享而非复制**`BashEnvRegistry``dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env``ctx.bashEnv` + 内置事实 + session-persistence contributor两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁;因此,共享环境的所有权不属于任何一个面向模型的 shell 工具。
- **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。
- **范围外,不变**sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell后端仅限 Linux/macOSConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。
- **范围外,不变**:持久 PTY shell后端仅限 Linux/macOSConPTY 属路线图)。sandbox 升级随 [Windows ACL sandbox 决策](2026-08-08-windows-acl-restricted-token-sandbox.md) 稍后交付——pwsh 工具现在携带 sandbox 拒绝渲染与同轮次 `sandbox_permissions` 升级面,外加其描述中的 Windows ConstrainedLanguage 契约。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。
## 备选方案
@@ -28,7 +28,7 @@ Status: implemented
## 后果
- bash 与 pwsh 工具在前台后台 shell 工作(减 sandbox上行为可互换pwsh 的 prompt/描述句每句都有渲染器背书。
- bash 与 pwsh 工具在前台后台与沙箱化 shell 工作上行为可互换sandbox 面随 Windows ACL sandbox 决策到来pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过
- 对齐也反向发生过一次pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。
- `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash``dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`spine bundle 已如此)。
- Windows 专属语义CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。

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 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
2026-08-04-claude-code-and-codex-subagent-backends.md: e81d1fb14f719331c503dba539d6a5ec0f1eed4f
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: aa6d80e38c12a2808a27e93bde8aec5d71e6a509
2026-08-04-claude-code-and-codex-subagent-backends.md: 2063b99a7b0ca34f434b3e56628f3ce765d90d81
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 71d4a8e9fc060f8c96483b22641fd67ee1f71c08

View File

@@ -39,11 +39,11 @@ Before publication, the provider validates a non-empty text-only task, starts th
`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`.
For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.146.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply.
For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply.
An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable.
Codex 0.146.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively.
Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively.
## Claude Code provider
@@ -61,7 +61,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract
Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped user configuration, verifies both fixed foreground-only tools in one context, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret.
The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`.
The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`.
The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit.

View File

@@ -39,11 +39,11 @@ fixed tool → shared subagent service → product provider → official product
`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"``agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`
对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.146.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应或是未知服务器请求此次运行就会失败而不会等待本提供方没有提供的用户界面。
对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应或是未知服务器请求此次运行就会失败而不会等待本提供方没有提供的用户界面。
若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。
Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSEServer-Sent Events生命周期。该桥接层既不是生产代理也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。
Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSEServer-Sent Events生命周期。该桥接层既不是生产代理也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。
## Claude Code 提供方
@@ -61,7 +61,7 @@ Codex 0.146.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端
每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活都会使这项必跑测试失败。Loader 层级会启动 README 所示形态的用户配置,在同一个上下文中验证两个固定且只支持前台执行的工具,并且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。
Codex 证据锁定 `@openai/codex@0.146.0``codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。
Codex 证据锁定 `@openai/codex@0.147.0``codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。
带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型不存储任何凭据或请求载荷要求上游恰好完成一次响应将去除首尾空白后的产品答案与该随机数逐字节比较并等待所有受管句柄退出。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md
2026-08-05-per-agent-tool-presentation.md: 348f7ab0a26e9b39057dbac885304e0d52e0b1fb
2026-08-05-per-agent-tool-presentation.zh.md: 4920ee6eb061d44934bfc9f5176e244f5aac8553

View File

@@ -0,0 +1,46 @@
# Agent Note: Per-agent tool presentation, and the `code` preset
Status: implemented
English | [中文](2026-08-05-per-agent-tool-presentation.zh.md)
## Problem
Agent presets compose an agent's tools per session, but not the FORM those tools reach the model in. Code Mode — one `run_code` tool plus a generated TypeScript SDK, replacing a call sequence with one program — was a deployment-wide `mode` field on the host's `dsh-tools` row. A deployment either ran every session in Code Mode or none, so the obvious product shape ("代码模式" beside 标准/极简/创造 in the preset picker) had nothing to hang on.
The naive reading of "move tools down to the agent plane" does not work. `ctx.tools` has host-plane consumers that cannot follow it: `dsh-agent-loop` reads the registry's private scheduler seam, `dsh-apiproxy` reads its presenters to render tool cards, and every tool plugin registers into it. By the stack's own rule — a service moves into a preset only when ALL of its consumers move with it — the registry stays where it is.
## Decision
Split the registry from its projection. The registry stays host-plane; the **presentation** becomes per-agent state inside it, alongside the per-agent restrictions and guards that already live there.
`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the agent that declared it. `modeFor(scope)` resolves that cell against the config `mode`, which becomes the default for agents declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's.
Two consequences fell out and are load-bearing:
- **`run_code` is appended per scope.** Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find `run_code` in its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need.
- **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted.
The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per agent by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section.
The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-mode`, whose whole body is a `presentAs` call. A code mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act.
## Alternatives considered
**A second `ToolRegistry` inside the preset's isolate realm.** Rejected: `dsh-agent-loop` resolves the registry once from the host context through a private symbol, so a per-agent registry would be invisible to the scheduler. Making the loop registry-per-agent is a far larger change than making one field scope-aware.
**A top-level key in the preset's own YAML.** Rejected for the reason preset display metadata went to a separate `preset.yml`: the composition is a top-level list of plugin rows and cannot carry sibling keys.
**Naming the package `dsh-tool-mode`.** Rejected by a gate, correctly. `gen-tool-catalog` globs `packages/*/tool-*` and requires every match to publish a model-facing tool schema, because that prefix means "ships a tool" in this repo. This row ships none.
**Registering the SDK section unconditionally from the constructor.** Rejected after trying it: `renderPrompt` drops empty sections but `PromptAssembly.sections` retains them, so every native deployment would carry a `tools:sdk` entry rendering nothing, and two existing assertions on that list would have had to be weakened to accommodate it.
**Sharing `standard`'s composition by include.** Rejected per the stack's own convention: `cordis` already duplicates `standard`, and a preset's value is that its whole composition is readable in one file. The cost — a third copy of ~240 lines that must move together — is real and is the strongest argument for a future include mechanism.
## Consequences
Two sessions in one process can now present differently, so "which tools does the model see" is no longer answerable from the deployment config alone; it requires the agent. Every diagnostic that quotes a mode now quotes the scope's, not the service's.
`ctx.tools.schemas(agent)` remains the agent's CAPABILITY catalog and is unchanged by presentation — only the assembly's tools collapse. Tests asserting what the model receives must read the assembly; `web-agent-presets.spec.ts` asserts both sides of that distinction for the shipped `code` preset.
The shipped roster is four presets (标准/代码/极简/创造), so any golden listing them moves. A deployment that composes no code runtime can compose no code-mode preset; the shipped Web overlay carries one, the base composition does not.

View File

@@ -0,0 +1,46 @@
# Agent Note: 按 agent 的工具呈现方式,以及 `code` 预设
Status: implemented
[English](2026-08-05-per-agent-tool-presentation.md) | 中文
## Problem
agent preset 已经能按会话组装一个 agent 的工具,却管不了这些工具以何种**形态**抵达模型。Code Mode——一个 `run_code` 工具加一份生成的 TypeScript SDK用一段程序替代一串调用——此前是宿主 `dsh-tools` 那一行上的部署级 `mode` 字段。一个部署要么所有会话都跑 Code Mode要么一个都不跑于是那个显而易见的产品形态预设选择器里「代码模式」与标准/极简/创造并列)无处安放。
「把 tools 下沉到 agent 平面」这个字面读法行不通。`ctx.tools` 有一批跟不下来的宿主平面消费者:`dsh-agent-loop` 读它私有的调度器 seam`dsh-apiproxy` 读它的 presenter 来渲染工具卡,每个工具插件都往里注册。按本 stack 自己的规则——只有**所有**消费者一起下沉,服务才能下沉——注册表必须留在原地。
## Decision
把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 agent 的状态,与已经住在那里的按 agent 限制和守卫并列。
`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 agent 一起卸载。`modeFor(scope)` 将该单元与 config 的 `mode` 一并解析,后者于是成为「未作声明的 agent」的默认值而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。
有两个随之而来的结果,且都是承重的:
- **`run_code` 按 scope 追加。** 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到 `run_code`——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。
- **保留名现在无条件生效。** `run_code` 此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。
SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 agent 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。
preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-mode`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset会在操作者能够动手的地方失败。
## Alternatives considered
**在 preset 的 isolate realm 里再起一个 `ToolRegistry`。** 否决:`dsh-agent-loop` 通过一个私有 symbol 从宿主上下文一次性解析注册表,因此按 agent 的注册表对调度器不可见。把 loop 改成按 agent 解析注册表,远比把一个字段变成 scope 感知的改动大。
**在 preset 自己的 YAML 里加一个顶层键。** 否决,理由与 preset 展示元数据落到独立 `preset.yml` 相同:组装是一个顶层的插件行列表,装不下并列的键。
**把包命名为 `dsh-tool-mode`。** 被一道 gate 否决,而且它是对的。`gen-tool-catalog``packages/*/tool-*` 通配,并要求每个命中项发布一个面向模型的工具 schema——因为在本仓库里这个前缀就意味着「带工具」。而这一行不带任何工具。
**在构造函数里无条件注册 SDK 段。** 试过之后否决:`renderPrompt` 会丢弃空段,但 `PromptAssembly.sections` 会保留它们,于是每个 native 部署都将携带一个什么也不渲染的 `tools:sdk` 条目,而两处既有断言不得不为此放宽。
**用 include 共享 `standard` 的组装。** 按本 stack 自己的惯例否决:`cordis` 已经复制了一份 `standard`,而 preset 的价值恰在于整份组装能在一个文件里读完。代价——第三份约 240 行、且必须同步演进的副本——是真实的,也正是未来引入 include 机制最有力的论据。
## Consequences
同一进程内的两个会话现在可以有不同的呈现方式,因此「模型看到哪些工具」不再能只凭部署配置回答,必须给出 agent。凡是引用模式的诊断信息现在引用的都是该 scope 的,而不是服务的。
`ctx.tools.schemas(agent)` 仍然是该 agent 的**能力**清单,不受呈现方式影响——坍缩的只是 assembly 里的工具。断言「模型收到什么」的测试必须读 assembly`web-agent-presets.spec.ts` 对随附的 `code` 预设同时断言了这个区分的两侧。
随附的名单变成四个预设(标准/代码/极简/创造),因此任何列出它们的 golden 都会变动。未组装 code 运行时的部署无法组装任何 code 模式的 preset随附的 Web overlay 带了一个base 组装没有。

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 .agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md
2026-08-05-pwsh-ui-bash-parity.md: ba92f482e957acd00792a2a8053716047a4da27d
2026-08-05-pwsh-ui-bash-parity.zh.md: 693b3fc26e632718ea2135282d63daa32e63e1c5
2026-08-05-pwsh-ui-bash-parity.md: a59ae95ab64ae28babc913958a1a6ba424898210
2026-08-05-pwsh-ui-bash-parity.zh.md: 8e065640e56bf6d6b92e62cb746fa9dd53f6f2b5

View File

@@ -6,7 +6,7 @@ English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md)
## Problem
The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects.
The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2, but the TUI package was removed, leaving the Web surface as the only UI the gap affects.
## Decision

View File

@@ -6,7 +6,7 @@ Status: implemented
## Problem
[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。
[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../implemented/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2但 TUI 包已被移除,使 Web 表面成为该缺口唯一影响的 UI。
## Decision

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-web-queue-steer-all-gesture.md
2026-08-06-web-queue-steer-all-gesture.md: e546f68647dfc9b91ce4699cef4a64694ebc4f76
2026-08-06-web-queue-steer-all-gesture.zh.md: fb36852f66a86408af12df59001230b2024eccaf

View File

@@ -0,0 +1,33 @@
# Agent Note: Steer the whole Web queue with an empty-draft Cmd/Ctrl+Enter
Status: implemented
English | [中文](2026-08-06-web-queue-steer-all-gesture.zh.md)
## Problem
While a primary session runs, the Web queue accumulates messages the user typed with plain Enter (or queued while the busy-Enter preference was Queue). Flushing them into the current turn required clicking the per-row 插话发送 button once per message; an empty composer draft had no keyboard gesture at all — the input machine rejects empty drafts, so Enter and Cmd/Ctrl+Enter were both no-ops. With several queued messages, steering them one by one is the obvious multi-click friction, and the empty-draft accelerated chord is the natural slot for "steer everything".
## Decision
Empty-draft Cmd/Ctrl+Enter now steers every still-pending `queued`-placement inbox row into the running turn, in FIFO order, on a primary session that reports running. The gesture decodes in `InputBar.onKeyDown`: accelerated Enter with a trimmed-empty draft, `running`, no subagent address, and at least one `queued` row calls the new `ComposerKeyboard.steerQueue()` verb instead of `submit()`. `SessionInputShell.steerQueue()` delegates to a hub-wired choreography that re-reads the authoritative `session/queue` snapshot, filters `placement: 'queued'` (pending steering rows are already in the turn), and applies the queue dock's exact strict-steer operation — `session.updateQueue(itemId, { kind: 'steer' })` — sequentially, so FIFO ordering is guaranteed at the host. A `steer-unavailable` (turn closed mid-flush) or `queue-item-not-found` (row claimed meanwhile) converges silently; any other failure surfaces one composer notice (`插话发送失败,请重试。`). No wire, on-disk, or agent-loop change: the host already owns the strict-steer boundary.
The gesture is strictly the accelerated chord. Plain Enter with an empty draft stays a no-op even under the busy-Enter Steer preference, draft content outranks the queue (accelerated Enter steers only the draft), and idle or subagent sessions keep the existing empty-draft no-op because steering has no live turn to enter.
The same computed availability gate drives discovery: while the draft is empty, the input is unlocked and not in a transient machine lock, the command menu is closed, an ordinary primary session is running, and at least one row remains `queued`, the textarea placeholder advertises that Cmd/Ctrl+Enter steers all queued messages. An owner-supplied placeholder still takes precedence, and the steer hint deliberately outranks the plan-mode placeholder while available (the gesture genuinely works in that window).
## Consequences
One keyboard gesture now replaces N clicks while keeping a single strict-steer path and a single authority for convergence. The per-row button and the gesture are the same host operation, so races and failure semantics stay identical. The gesture and its placeholder share one presentation-layer gate, while the hub re-checks the snapshot at execution time, so the client gate remains advisory and the host remains authoritative.
## Related
The per-row 插话发送 action and its strict-steer boundary are owned by [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md); this note only adds the whole-queue keyboard gesture on top of that decision.
## Alternatives considered
- **Intercepting inside the input machine.** Rejected: the machine is queue-agnostic by design (the wiring layer overlays the queue projection) and cannot distinguish the accelerated chord from plain Enter, which must stay a no-op.
- **Steering via `session.prompt(mode: 'steer')` per row.** Rejected: that mints new messages instead of transferring the pending occurrences and would split the dock's immutable-message contract; `updateQueue({ kind: 'steer' })` already atomically transfers the exact occurrence.
- **Firing all row steers concurrently.** Rejected: arrival order at the host is not guaranteed, and steering order is model-visible; sequential awaits preserve FIFO.
- **A new host RPC for steer-all.** Rejected: the existing per-item operation is idempotent enough — each row is one strict steer, and mid-flush closure converges silently — so a protocol change buys nothing.
- **A send-button tooltip.** Rejected: the primary button is Stop while an ordinary session is running, which is the only window where the whole-queue gesture is available. The empty-draft placeholder occupies that exact window and can describe the keyboard action directly.

View File

@@ -0,0 +1,33 @@
# Agent Note: 空输入时 Cmd/Ctrl+Enter 将 Web 排队消息全部插话
Status: implemented
[English](2026-08-06-web-queue-steer-all-gesture.md) | 中文
## Problem
主会话运行时,用户用普通 Enter或在 busy-Enter 偏好为 Queue 时)输入的消息会在 Web 队列里累积。把它们灌进当前轮次需要逐条点击「插话发送」按钮而输入框草稿为空时没有任何键盘手势——输入机对空草稿直接拒绝Enter 与 Cmd/Ctrl+Enter 都是空操作。排队消息一多,逐条插话是明显的多点摩擦,空草稿 + 加速 Enter 正是「全部插话」的自然位置。
## Decision
空草稿的 Cmd/Ctrl+Enter 现在会把仍在排队(`placement: 'queued'`)的 Inbox 行按 FIFO 顺序全部插话进运行中的轮次,仅限报告 running 的主会话。手势在 `InputBar.onKeyDown` 解码:加速 Enter + 去空白后为空草稿 + `running` + 无 subagent 地址 + 至少一条 `queued` 行时,改走新的 `ComposerKeyboard.steerQueue()` 动词而不是 `submit()``SessionInputShell.steerQueue()` 委托给 hub 编排的流程:重新读取权威的 `session/queue` 快照,过滤 `placement: 'queued'`pending steering 行已经在本轮内),并逐条顺序执行 Queue 面板的严格 steer 操作 `session.updateQueue(itemId, { kind: 'steer' })`,从而在 host 侧保证 FIFO 顺序。`steer-unavailable`flush 中途轮次关闭)或 `queue-item-not-found`(行已被占用)静默收敛;其他失败弹出一条 composer 通知(「插话发送失败,请重试。」)。无 wire、磁盘或 agent-loop 改动:严格 steer 边界本来就在 host 侧。
该手势严格限定为加速组合键。空草稿 + 普通 Enter 仍然无操作(即使 busy-Enter 偏好为 Steer草稿内容优先于队列加速 Enter 只插话当前草稿idle 或 subagent 会话保持原有空草稿无操作,因为没有可插入的运行中轮次。
同一套计算得出的可用性门控也负责提示该手势当草稿为空、输入框未锁定且不处于瞬态机器锁adjudicating/submitting、命令菜单未打开、普通主会话正在运行且至少一行仍为 `queued` 时,文本框 placeholder 会提示 Cmd/Ctrl+Enter 将全部排队消息插话发送。owner 提供的 placeholder 仍然优先;可用时 steer 提示会刻意优先于 plan 模式 placeholder该窗口内手势确实可用
## Consequences
一个键盘手势替代 N 次点击,同时保持单一严格 steer 路径与单一收敛权威。逐条按钮与手势是同一个 host 操作,竞态与失败语义完全一致。手势及其 placeholder 共用一个呈现层门控hub 在执行时会重新读取快照因此客户端门控仍只是建议性的host 仍是权威。
## Related
逐条「插话发送」动作及其严格 steer 边界由 [Steer a queued Web message into the active turn](../feature/2026-07-30-web-queue-steer-action.md) 记录;本笔记只在其之上增加整队列键盘手势。
## Alternatives considered
- **在输入机内拦截。** 已拒绝:输入机按设计不感知队列(队列投影由 wiring 层叠加),且无法区分加速 Enter 与必须保持空操作的普通 Enter。
- **逐条用 `session.prompt(mode: 'steer')` 插话。** 已拒绝:那会铸造新消息而不是转移 pending 行,破坏 dock 的不可变消息契约;`updateQueue({ kind: 'steer' })` 已经原子地转移了确切的那条。
- **并发触发所有行。** 已拒绝host 到达顺序无法保证,而插话顺序对模型可见;顺序 await 保证 FIFO。
- **为 steer-all 新增 host RPC。** 已拒绝:现有逐条操作已足够幂等——每行一次严格 steer中途关闭静默收敛——协议改动没有收益。
- **发送按钮 tooltip。** 已拒绝:普通会话运行时,主按钮是 Stop这也是整队列手势唯一可用的窗口。空草稿时的 placeholder 恰好在该窗口显示,可以直接说明这项键盘操作。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
2026-08-08-windows-acl-restricted-token-sandbox.md: 7e8f229269233d9ac9baa65241ca02a4cf4c3f7c
2026-08-08-windows-acl-restricted-token-sandbox.zh.md: eeb346b228b3559f487448e5d4ec525b7bb89525

View File

@@ -0,0 +1,43 @@
# Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer
Status: implemented
English | [中文](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)
## Problem
The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` empty, so shipped Windows profiles degrade to danger-full-access because no confining executor exists. The win32 rung must confine the two file-effect modes the sandbox vocabulary promises — `read-only` (zero writes) and `workspace-write` (writes under the workspace root plus a backend-defined temp area) — while leaving reads, network, and process visibility alone, because every mode permits reading.
## Decision
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam derives the session's PRIVATE temp subdirectory from the session id + workspace (sha256, 16 hex — stored nowhere, so no tamper surface exists) and creates it exclusively; it is removed on provider dispose, and a crash leaves it as `%TEMP%` litter whose next resume fails loudly at the exclusive creation until temp hygiene reclaims it. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root). The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`<temp>\dsh-<16 hex>` derived from the session id + workspace — created exclusively, reparse points rejected, removed on provider dispose — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack.
## How the restriction works (why no new identity)
The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the orphan-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement.
## Alternatives considered
### Why not mxc (Microsoft xContainer)?
Two disqualifiers. First, the OS floor is too new: the [mxc OS-version policy](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) sets the product floor at Windows 11 24H2 (build 26100), and the BaseContainer tier (T1, `Experimental_CreateProcessInSandbox`) exists only on 25H2+ (build 26600+) with the OS feature enabled — on every supported release at or below 25H2 the filesystem policy falls back to T3, AppContainer plus host-side DACL ACE augmentation. Second, supporting arbitrary-path reads under either tier means granting read access by writing ACLs over every path the child may read: a model that reads the whole workspace and arbitrary files would require wholesale host DACL mutation — a standing side effect and a cost a write-only restriction does not need.
### Why not AppContainer?
An AppContainer token carries no ambient read access: every readable path must be pre-granted through capabilities or explicit ACEs, so arbitrary-path reads — the harness's read model — are unsupported without the same wholesale grants. The restricted token needs no read grants at all: it intersects write access only.
### Why not landstrip?
The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) was rejected before implementation (not battle-tested; the in-house launcher plan won), and its Windows backend is AppContainer-shaped, inheriting the same arbitrary-read problem.
## Consequences
Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose together with the derived private temp directory — a crash leaves both behind and the next resume fails loudly at the exclusive creation until temp hygiene reclaims the directory); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description.
## Testing
The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` (the derived private-temp identity — deterministic per session + workspace, distinct across sessions — one-shot materialization, exclusive temp creation with reparse-point rejection and self-cleanup on failure, clean-restart re-grant of the same derived directory, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite).
## Related
The [pwsh executor decision](2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes.

View File

@@ -0,0 +1,43 @@
# Agent Note: Windows sandbox rung: raw ACL restricted tokens over mxc and AppContainer
Status: implemented
[English](2026-08-08-windows-acl-restricted-token-sandbox.md) | 中文
## Problem
[沙盒决策](2026-07-06-sandbox.md)把 `PLATFORM_CHAINS.win32` 留空,交付的 Windows profile 因为没有可用的隔离执行器而退化为 danger-full-access。win32 档必须实现沙盒词汇表承诺的两个文件效果模式——`read-only`(零写入)与 `workspace-write`(仅工作区根目录加后端定义的临时区域可写)——同时保持读、网络与进程可见性不受影响,因为所有模式都允许读取。
## Decision
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken``WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closedPOC 因忽略返回值而 fail-open。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 从会话 id + 工作区派生会话的**私有**临时子目录sha256、16 位 hex——任何地方都不存储因此不存在篡改面并独占创建它在提供方 dispose 时移除,崩溃则把它留作 `%TEMP%` 垃圾其下一次恢复会在独占创建处大声失败直到临时目录卫生机制将其回收。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose资源释放时撤销因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久)。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SIDread-only = [登录 SID、Everyone]workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone没有它们早期 DLL init 会以 0xC0000142 死亡CNG 会让 pwsh 以 0xE0434352 崩溃。Read-only 不含写入 SID先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败0x80041003因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE在两种模式下都被关闭INTERACTIVE/LOCAL 同样在两种列表中都缺席Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住。Workspace-write 子进程看到的是私有的每会话临时子目录(`<temp>\dsh-<16 hex>`——由会话 id + 工作区派生、独占创建、拒绝 reparse point、提供方 dispose 时移除——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE`SetTokenInformation(TokenDefaultDacl)`此后不带显式安全描述符创建的新对象匿名管道——CreatePipe、同步对象自带 restricting SID ACE创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付Windows 平台层在受限 pwsh 栈之上重新启用完整权限面sandbox/sandbox-policy/permission/approval/fs-sandbox
## How the restriction works (why no new identity)
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线landstrip 的 restricted-user、AppContainer用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID一次按 restricting SIDs——只有两次都放行写类访问才被授予。读只凭正常检查即可通过调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过孤儿 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
## Alternatives considered
### 为什么不选 mxcMicrosoft xContainer
两个否决理由。其一OS 版本要求太新:[mxc 的 OS 版本支持文档](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md)把产品下限设在 Windows 11 24H2build 26100而 BaseContainer 档T1`Experimental_CreateProcessInSandbox`)只在 25H2+build 26600+)且启用 OS feature 时存在——在 25H2 及以下的所有受支持版本上,文件系统策略都会回退到 T3即 AppContainer 加宿主侧 DACL ACE 改造。其二,在任一档下支持任意路径读都意味着要为子进程可读的每个路径写 ACL 授予读权限:模型要读整个工作区和任意文件,就需要全盘改写宿主 DACL——对只做写限制的需求而言这是不必要的驻留副作用与代价。
### 为什么不选 AppContainer
AppContainer 令牌没有环境读访问:每个可读路径都必须预先通过 capability 或显式 ACE 授予因此任意路径读——harness 的读模型——在不做同样的全盘授予时无法支持。受限令牌完全不需要读授予:它只对写访问做交集。
### 为什么不选 landstrip
[landstrip 评估](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)在实现前已被否决(未经实战检验;自建 launcher 方案胜出),且其 Windows 后端是 AppContainer 形态,继承同样的任意路径读问题。
## Consequences
所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响与模式词汇表一致、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 连同派生的私有临时目录一起回收——崩溃会把两者都留下,下一次恢复会在独占创建处大声失败,直到临时目录卫生回收该目录);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒因按工作区身份每台机器每个工作区只付一次CIM 在**两种**受限模式下都不可用AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL目标在两种模式下仍可写没有可做交集的安全描述符——作为历史残留处理不支持、仅警告、已在 README 中记录NULL DACL 目录在 grant+revoke 往返下不保持身份记录在案的边角POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::``[math]::`、COM 对象与反射都会以“only core types”错误失败`-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。
## Testing
产品可见的 Windows 阵容切换仅存在于 win32而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec[`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/``packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` 钉住(派生的私有临时身份——按会话 + 工作区确定性、跨会话相异——一次性物化、独占临时目录创建并拒绝 reparse point、失败时自我清理、干净重启时对同一派生目录的重新授权、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts``--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。
## Related
[pwsh 执行器决策](2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。

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 .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
2026-07-21-serial-cross-platform-ci-reference.md: 71b364f72a094f7899a0929eec29f3a16ccc8a93
2026-07-21-serial-cross-platform-ci-reference.zh.md: bc8b210e4f32c4e2fe3154bb7ffe9d89210d5087
2026-07-21-serial-cross-platform-ci-reference.md: 07dc430e6fed3fe75a006ca03523bd6e4fc969d0
2026-07-21-serial-cross-platform-ci-reference.zh.md: 8bbb60cdead2957069de22ecaddf01c6cd9fb305

View File

@@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite
## Decision
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active reference is `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
@@ -28,13 +28,13 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses standard `windows-2025` under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
## Alternatives considered
- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression.
- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts.
- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Wine-hosted Windows contracts, and the independent native job supplies the complete Windows result.
- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its four statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal.
- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.

View File

@@ -16,7 +16,7 @@ Status: implemented
## 决策
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 约定。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux``serial / macos``serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,用矩阵或可复用工作流操作系统差异隐藏起来`workflow_dispatch` 仅用于运行器基准测试。
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux``serial / macos``serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。
每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci``DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrapWindows 则在安装采用符号链接的工作区前启用开发人员模式。
@@ -28,13 +28,13 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest``macos-latest``windows-2025` 标签`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest``macos-latest``windows-2025` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)使用标准 `windows-2025``serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
## 曾考虑的替代方案
- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。
- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 约定。
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和由 Wine 承载的 Windows 约定,而独立原生作业提供完整的 Windows 结果
- **在每个拉取请求上运行真实内核 Sandbox 矩阵**不予采纳因为它的四个状态不参与分支保护而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。
- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。

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 .agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md
2026-07-22-evidence-based-larger-hosted-runners.md: 53cc86efce9061c8f9836a17cb35ebb128085b7a
2026-07-22-evidence-based-larger-hosted-runners.zh.md: c9daf5273e190d3828e80ebe81369e72f5d7a74d
2026-07-22-evidence-based-larger-hosted-runners.md: 84c951809891b4936549a2f429dc7efc99833c1b
2026-07-22-evidence-based-larger-hosted-runners.zh.md: e097f8b18c7a03a4760e9cb4f45b6385c44b051c

View File

@@ -24,7 +24,7 @@ The gate dependencies remain explicit. Coverage consumes source and does not wai
The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails.
Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim.
Within this enterprise required topology, Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts, while Linux owns the duplicate lint, coverage, and snapshot inventories. The later [dual Windows pull-request topology](2026-08-08-native-windows-pull-request-ci.md) adds a separate non-blocking standard-hosted native job that independently enforces supported-source coverage without extending this paid required path.
An exact-head all-size benchmark ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction:

View File

@@ -24,7 +24,7 @@ Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行
产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API该视图由每个 manifest元数据清单声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布约定只要遗漏一个运行时分片,检查仍会失败。
Windows 一次 32 核环境设置同时承载阻塞性构建、生产网站观测性构建产物约定重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台约定
在这项企业级必需拓扑中,Windows 通过一次 32 核环境设置同时承载阻塞性构建、生产网站观测性构建产物约定重复的 lint、覆盖率和快照清单由 Linux 负责。后续的[拉取请求双 Windows 拓扑](2026-08-08-native-windows-pull-request-ci.md)新增一个独立且不阻断的标准托管原生作业;该作业会独立强制执行受支持源码覆盖率,同时不延长这条付费必需路径
一次分支头精确的全规格基准测试在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程:

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 .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
2026-07-23-portable-required-pull-request-ci.md: 1a6939e8386e381cba114a7be71993a644457a45
2026-07-23-portable-required-pull-request-ci.zh.md: 83737b61bc72e3c1ee6afb805d30256d572211a2
2026-07-23-portable-required-pull-request-ci.md: b7c637408afa32611c4e70073ddec9aaabfba7f5
2026-07-23-portable-required-pull-request-ci.zh.md: ee88412d982da5de613d8dfdbdcdd9977c899d2d

View File

@@ -12,9 +12,9 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei
## Decision
[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)), keeping the pull-request Windows contract independent of any Windows runner allocation; the complete native-kernel Windows inventory lives in the master serial reference. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and the serial references remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix.
@@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md)
## Consequences
Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine-hosted Windows job keeps its verdict on standard Linux allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine job keeps the required Windows verdict on standard Linux allocation. The independent native job uses standard Windows allocation without delaying or changing the aggregate. A live exact-head run distinguishes the commands branch protection consumes from the separate diagnostic contract; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work.
Standard compatibility, required Wine, and diagnostic native Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work.

View File

@@ -12,9 +12,9 @@ Status: implemented
## 决策
[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node覆盖阻断性检查范围([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)),使拉取请求的 Windows 约定不依赖任何 Windows 运行器分配;完整的原生内核 Windows 清单位于 master 串行参考流程中。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程[Windows 决策](2026-08-08-native-windows-pull-request-ci.md)。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e``all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。
两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e``all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。
当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。
@@ -30,6 +30,6 @@ Status: implemented
## 后果
普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 托管的 Windows 作业让其判定保持在标准 Linux 运行器分配上。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt``completedAt` 的执行区间分开报告。
普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 作业让必需的 Windows 判定继续使用标准 Linux 运行器容量。独立原生作业使用标准 Windows 运行器容量,不会延迟或改变聚合流程。一次实际的分支头精确运行会区分分支保护用的命令与单独的诊断约定;排队延迟与每个作业从 `startedAt``completedAt` 的执行区间分开报告。
企业级运行器分配能力下降时,标准兼容性作业必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。
企业级运行器分配能力下降时,标准兼容性作业必需的 Wine 作业与诊断性原生 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。

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 .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 79a7ff1c573019a3a1877eb49177d3410aea5ed7
2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 76d755656ecfdd47bac2191efac4ce5bba53f4fb
2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 499141ca6a3703a12d10c195b732441d49419599
2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: c57b7d32cd7204a845ab1884e9ae0b9045749a1c

View File

@@ -13,8 +13,8 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i
`pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes:
- **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm``e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms.
- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based pull-request Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm.
- **Cache-less or persistent** (no store-cache action): native serial-windows and serial-macos plus `sandbox.yml` install from a cold or runner-local store. The self-hosted standby and failover jobs reuse their VM's persistent pnpm store without transferring a hosted cache archive.
- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm.
- **Cache-less or persistent** (no store-cache action): the independent native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive.
## Alternatives considered

View File

@@ -13,8 +13,8 @@ Status: implemented
`pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI命令行界面因此用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态:
- **对称缓存**(既恢复也保存):带 `cache: pnpm``actions/setup-node`——`e2e.yml``docs-pages.yml``pi-ai-provider-e2e.yml``build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linuxconsolidated benchmark 在两个平台上都启用缓存。
- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PRPull Request作业基于 Wine 的拉取请求 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store并解析该路径从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。
- **无缓存或持久化**(不使用 store 缓存 action原生 serial-windows 和 serial-macos 加上 `sandbox.yml` 从冷 store 或 runner 本地 store 安装。自托管热备与故障切换作业复用其 VM 的持久 pnpm store不传输托管缓存归档。
- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PRPull Request作业基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store并解析该路径从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。
- **无缓存或持久化**(不使用 store 缓存 action独立的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业复用其 VM 的持久 pnpm store不传输托管缓存归档。
## 曾考虑的替代方案

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
2026-08-08-native-windows-pull-request-ci.md: 6a62fddb79670c3ab4cc0446796dbffd7130aed9
2026-08-08-native-windows-pull-request-ci.zh.md: 990b8ed1434934337b8ff20c5f3be2c03cd9c61b

View File

@@ -0,0 +1,57 @@
# Agent Note: Dual Wine and native Windows pull-request CI
Status: implemented
English | [中文](2026-08-08-native-windows-pull-request-ci.zh.md)
## Problem
The required pull-request Windows verdict needs a fast win32 toolchain signal without making the aggregate wait for scarce Windows capacity. Wine provides that critical-path signal but runs over a Linux kernel and case-sensitive ext4, uses a hoisted dependency layout, and cannot prove NTFS, DACL, ConPTY, crash durability, or native process behavior. With the native serial references disabled, every pull-request head also needs an automatic real Windows-kernel result.
A coverage audit found that stale branch state had restored temporary exclusions for supported LSP sources. Native Windows therefore needed to execute the complete supported source inventory at the same 100%-per-file threshold instead of relying on a smaller platform-specific denominator.
## Decision
The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 60-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.
The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage.
The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources remain in the denominator; only intrinsically peer-platform source arms use narrow annotated V8 ignores, with their behavior tests retained on the owning platform.
The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement.
The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias.
Portable filesystem fixtures derive paths with `node:path`, compare native realpath identities, preserve file URLs at Node launcher boundaries, normalize only API-owned separators or line endings, and use filenames legal on every host. POSIX-only signal, mode-bit, unreadability, and writer-lock cases are platform-gated; portable failure contracts instead assert structured error codes, rollback, last-good state, atomic replacement, and absence of temporary residue through conflicts available on every host. Credentials permission validation uses an invalid-path fixture whose pre-lookup `ERR_INVALID_ARG_VALUE` is non-absence on every host, rather than depending on whether a file ancestor produces `ENOTDIR` or `ENOENT`. Worker-death fixtures drive real termination from the host after observing their protocol preconditions instead of calling `process.exit()` inside a nested Windows Worker; this preserves the worker-exit contract without exposing the enclosing Vitest fork to Node's process-wide native exit assertion. Stress and integration workloads keep their original assertions and receive explicit bounded time budgets where Windows instrumentation or process teardown can exceed Vitest's default ceiling.
Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing ancestor, prove it is an enumerable directory when a suffix is missing, and restore that suffix. This prevents Windows 8.3 aliases from being mixed with long-form libuv events and preserves `ENOTDIR` for a regular-file ancestor on every host. Settings, credentials, skill roots, and Cordis HMR retain configured paths for discovery and diagnostics; module HMR uses the canonical spelling for Node's load-cache identity, attaches listeners, and awaits its main watcher before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. A skill root that is itself a symbolic link remains unexpanded when `watchFollowSymlinks: false`, allowing Chokidar to enforce that boundary.
Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration, retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT`; nested Git-package installation therefore cannot rejoin the enclosing workspace or let an inherited Windows pnpm executable outrank the transaction-owned wrapper.
Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write.
Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.147.0 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host.
## Alternatives considered
**Make native Windows a dependency of `all checks passed`.** This gives the aggregate the highest-fidelity Windows verdict, but makes every merge wait for the slowest hosted job and for Windows capacity. The independent result keeps the signal automatic without changing the existing required path.
**Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken.
**Mark the native job `continue-on-error`.** That would make its check appear successful after a gate failure. Keeping an ordinary independent job preserves the diagnostic conclusion; omission from aggregate `needs` is the only non-blocking mechanism.
**Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures.
**Keep GitHub's standard `windows-2025` runner.** That portable two-core image completed the exact inventory reliably, but its 32-minute serial result made the automatic native signal substantially less useful than the selected 16-core runner.
**Use a 32-core or larger runner.** The 32-core comparison improved aggregate gate time by only 1.47 seconds over 16 cores and failed in Node's CJS lexer; earlier high-concurrency 32-core and 64-core trials failed in the same class. More capacity therefore added allocation cost without a stable end-to-end gain.
## Consequences
Wine preserves the required aggregate's existing critical path and job identity. Native Windows can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine while reviewers and follow-up automation consume the separate native result.
Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native job duplicates setup and the two blocking builds and is materially slower on the standard image, but it also exposes path, watcher, lifecycle, and fixture defects hidden by the compatibility lane.
Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native job uses the immutable workspace on the organization-owned 16-core Windows runner. A failure unique to either job must be classified against that boundary rather than weakened or silently skipped.

View File

@@ -0,0 +1,57 @@
# Agent Note: Wine 与原生 Windows 双通道拉取请求 CI
Status: implemented
[English](2026-08-08-native-windows-pull-request-ci.md) | 中文
## 问题
拉取请求必需的 Windows 判定既需要快速的 win32 工具链信号,也不能让聚合流程等待稀缺的 Windows 容量。Wine 提供这项关键路径信号,但它运行在 Linux 内核与区分大小写的 ext4 之上,采用 hoisted 依赖布局,且无法证明 NTFS、DACL、ConPTY、崩溃持久性或原生进程行为。原生串行参考流程停用期间每个拉取请求分支头还需要自动取得真实 Windows 内核结果。
覆盖率审计发现,陈旧分支状态恢复了针对受支持 LSP 源码的临时排除项。因此,原生 Windows 需要按同一逐文件 100% 阈值执行完整的受支持源码清单,而不能依赖缩小后的平台专用分母。
## 决策
[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时60 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。
原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。
16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture系统会使用显式的有界等待而不改变其断言结果。LSP 源码继续计入分母;只有本质上属于另一平台的源码分支使用窄范围且带注释的 V8 ignore其行为测试仍保留在所属平台。
16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。
首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%``C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher以及 Cordis 的模块 HMR热模块替换与精确配置 HMR现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件之后插件启动才会完成因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。
可移植文件系统 fixture测试前置数据通过 `node:path` 派生路径、比较原生 realpath 标识、在 Node 启动器边界保留文件 URL只规范化由 API 负责的分隔符或行尾,并使用每个宿主均允许的文件名。仅适用于 POSIX 的信号、模式位、不可读状态和 writer lock 场景按平台设门禁;可移植故障约定则通过每个宿主均可构造的冲突,断言结构化错误码、回滚、最后有效状态、原子替换及不存在临时残留。凭据权限验证采用无效路径 fixture该路径在每个宿主上都会于系统查找前产生表示“非缺失”的 `ERR_INVALID_ARG_VALUE`,而不依赖文件祖先究竟产生 `ENOTDIR` 还是 `ENOENT`。worker 死亡 fixture 会先观察其协议前置条件,再由宿主触发真实终止,而不在嵌套 Windows Worker 中调用 `process.exit()`;这样既保留了 worker 退出约定,也不会让外围 Vitest fork 暴露于 Node 进程级的原生退出断言。压力与集成工作负载保留原有断言;如果 Windows 插桩或进程拆卸可能超过 Vitest 默认上限,就为其设置显式的有界时间预算。
原生 watcher 使用 `canonicalizeWatchPath()` 对层级最深的现有祖先执行 realpath 解析;后缀缺失时,先证明该祖先是可枚举目录,再拼回后缀。这可避免 Windows 8.3 别名与长格式 libuv 事件混用,并让所有宿主在祖先为普通文件时都保留 `ENOTDIR`。设置、凭据、skill技能根与 Cordis HMR热模块替换在发现和诊断时保留配置路径模块 HMR 则使用规范写法作为 Node 加载缓存标识、挂接监听器并在插件启动完成前等待主 watcher 就绪,因此启动后立即发生的编辑不会与初始扫描形成竞态。`watchFollowSymlinks: false` 时,若 skill 根本身是符号链接,系统不会展开最后这一级链接,从而让 Chokidar 强制执行该边界。
Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法并仅对后代路径与暂存路径应用扩展长度命名空间。ACPAgent Client Protocol拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,保留 `PNPM_HOME` 作为 pnpm 数据配置,同时从生命周期 `PATH` 中移除该目录,并在 `PATHEXT` 中优先选择 `.CMD`;因此,嵌套 Git 包安装既不会重新加入外层 workspace也不会让继承的 Windows pnpm 可执行文件抢在事务持有的 wrapper 之前。
启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时系统才会隔离并发设置错误无关 HMR 故障仍会响亮失败。[进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。
Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization词元化预算前预热每种启动语法从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.147.0 schema并选择实际提供的命令工具与对应参数形态这样既保留由提供方负责的协议也能在每种宿主上证明无人值守拒绝和整棵进程树退出。
## 曾考虑的替代方案
**让原生 Windows 成为 `all checks passed` 的依赖项。** 这会为聚合流程提供保真度最高的 Windows 判定,但也会让每次合并等待最慢的托管作业与 Windows 容量。独立结果能让该信号保持自动产生,而不改变现有必需路径。
**只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏也可能报告绿灯。
**将原生作业标记为 `continue-on-error`。** 门禁失败后,该设置会让其检查显示为成功。保留常规独立作业可维持诊断结论;仅从聚合流程的 `needs` 中省略它,才是不阻断的机制。
**排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注可移植结果继续计入分母并通过符合真实宿主行为的 fixture 验证。
**保留 GitHub 标准的 `windows-2025` 运行器。** 该可移植双核镜像能可靠完成这份完整清单,但其 32 分钟的串行结果使自动原生信号的实用性远低于所选的 16 核运行器。
**使用 32 核或更大的运行器。** 32 核对比仅比 16 核将聚合门禁时间缩短 1.47 秒,且仍因 Node 的 CJS lexer 失败;先前高并发的 32 核和 64 核试验也以同类故障失败。因此,增加容量只会提高资源分配成本,却不能带来稳定的端到端收益。
## 后果
Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生 Windows 仍可能处于待处理或红灯状态,因此分支保护采用 Wine 结果,而评审者和后续自动化采用独立的原生结果。
尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程与两项阻断构建在标准镜像上明显更慢但它也会暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。
维护者必须保留两种有意设计的执行拓扑Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用不可变工作区。任一作业独有的失败都必须依据该边界分类,不得削弱或静默跳过。

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 .agents/notes/implemented/process/2026-08-09-committed-artifact-citations.md
2026-08-09-committed-artifact-citations.md: 31b2d52b423a080579b3ca95cf077859d0bf4c91
2026-08-09-committed-artifact-citations.zh.md: b69bb8743d10e2d1e194905d6d0de2d3d3667b1d
2026-08-09-committed-artifact-citations.md: a578b1e32268af2669ee26c59d19c738bc9f707b
2026-08-09-committed-artifact-citations.zh.md: d525dc0f58884bdf808b597213a7e23c1d6a0301

View File

@@ -18,7 +18,7 @@ Durable prose — comments, JSDoc, docs, notes, test comments and titles — cit
- Implemented notes state shipped reality: a "deferred to a later PR" claim whose target shipped names the shipped note instead.
- Recorded fixtures, snapshots, and archived notes are exempt: recorded model output and sealed history keep their original voice. Inside a note's change-story sections, a historical stage name ("the first cut shipped X") is current-state-safe; indexical stamps ("this cut") stay banned everywhere.
One repo-wide purge applied these rules across the prose surfaces, including the generator-owned templates (`scripts/gen-doc-graphs.ts`, `scripts/gen-tool-catalog.ts`, the typert generator's page notice) with regeneration, the type-equiv source JSDoc with page re-pastes, and the bilingual counterparts with pair re-records.
One repo-wide purge applied these rules across the prose surfaces, including the generator-owned templates (`scripts/gen-doc-graphs.ts`, `scripts/gen-tool-catalog.ts`, the typert generator's page notice) with regeneration, the type-equiv source JSDoc with page re-pastes, and the bilingual counterparts with pair re-records. The [dsh-trim-cot-leakage skill](../../../skills/dsh-trim-cot-leakage/SKILL.md) operationalizes these rules: the audit taxonomy, the committed recall batteries, and few-shot calibration for the keep/delete boundary.
## Alternatives considered
@@ -28,7 +28,7 @@ One repo-wide purge applied these rules across the prose surfaces, including the
## Verification
The audit's grep batteries (English and Chinese, comments and prose, `--hidden` for `.agents/`) return no design-ordinal citations outside recorded fixtures and archived notes; `verify-type-equiv`, the `gen-*` freshness checks, and `verify-translation-pairing` pin the regenerated and re-recorded surfaces. Coverage gap: no gate rejects a new ordinal citation — review owns the rule.
The audit's grep batteries (English and Chinese, comments and prose, `--hidden` for `.agents/`) return no design-ordinal citations outside recorded fixtures, archived notes, the trim skill's own files, and this note's quoted evidence; `verify-type-equiv`, the `gen-*` freshness checks, and `verify-translation-pairing` pin the regenerated and re-recorded surfaces. Coverage gap: no gate rejects a new ordinal citation — review owns the rule.
## Consequences

View File

@@ -18,7 +18,7 @@ Status: implemented
- 已实现的 Agent Note 陈述已交付的现实:「推迟到后续 PR」的说法若其目标已经交付就改为点名那篇已交付的 note。
- 已录制的 fixture测试前置数据、快照与已归档的 Agent Note 不受此约束:已录制的模型输出与封存的历史保持原有行文。在 note 的变更故事段落内,历史阶段名称(「首版交付了 X」属于安全的现状表述指示性切次戳"this cut")在任何地方都仍被禁止。
一次全仓库清理把这些规则应用到了各个行文表面,包括生成器持有的模板(`scripts/gen-doc-graphs.ts``scripts/gen-tool-catalog.ts`、typert 生成器的页面提示语改后重新生成、type-equiv 源码 JSDoc改后把文档页重新粘贴以及双语对侧文件改后重新记录配对
一次全仓库清理把这些规则应用到了各个行文表面,包括生成器持有的模板(`scripts/gen-doc-graphs.ts``scripts/gen-tool-catalog.ts`、typert 生成器的页面提示语改后重新生成、type-equiv 源码 JSDoc改后把文档页重新粘贴以及双语对侧文件改后重新记录配对[dsh-trim-cot-leakage 技能](../../../skills/dsh-trim-cot-leakage/SKILL.md)把这些规则落地为可执行工作流:审计分类法、已提交的成批召回检索,以及校准保留/删除边界的少样本示例。
## 曾考虑的替代方案
@@ -28,7 +28,7 @@ Status: implemented
## 验证
审计的成批 grep 检索(英文与中文、注释与行文、对 `.agents/``--hidden`)在已录制 fixture已归档 Agent Note 之外没有命中任何设计序号引用;`verify-type-equiv`、各 `gen-*` 新鲜度检查与 `verify-translation-pairing` 把重新生成与重新记录的表面固定下来。覆盖缺口:没有门禁会拒绝新的序号引用,这条规则由评审把关。
审计的成批 grep 检索(英文与中文、注释与行文、对 `.agents/``--hidden`)在已录制 fixture已归档 Agent Note、该清理技能自身文件与本笔记引用的证据之外没有命中任何设计序号引用;`verify-type-equiv`、各 `gen-*` 新鲜度检查与 `verify-translation-pairing` 把重新生成与重新记录的表面固定下来。覆盖缺口:没有门禁会拒绝新的序号引用,这条规则由评审把关。
## 后果

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md
2026-08-08-copy-only-preset-authoring.md: c16518b087c7acedbee3d89ce5cc8dbcaa0a0cde
2026-08-08-copy-only-preset-authoring.zh.md: dc2d7924cb0fd9363efa9387bc8ba68bf11af5d7

View File

@@ -0,0 +1,30 @@
# Agent Note: Copy-only preset authoring, and the way into a preset's files
Status: implemented
English | [中文](2026-08-08-copy-only-preset-authoring.zh.md)
## Problem
The agent-preset settings page carried a web YAML editor: `agentPreset.write` accepted arbitrary composition text, the page held a textarea with no completion, highlighting, or diff, and the shape check leaned on the Loader's own `entryListSchema` — whose dialect includes `!!js`, so "shape-checked text" was still arbitrary code on the next mount. Weak as an editor, wide as a capability, and the source of the editor-vs-roster races the section had to defend against.
## Decision
Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `agentPreset.openDocument { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`, pinned by the gateway's `nativeOpen` config where `canOpenNativePath` platform detection would mislead, e.g. e2e and containers).
## Consequences
- No composition text and no path crosses the browser wire in either authoring direction; the `entryListSchema`/`!!js` concern dissolves with `assertComposition` itself (deleted). The privileged set is now `read`/`copy`/`openDocument`/`remove` — none accepts a filesystem target.
- With the editor gone, hand-editing `agent.cordis.yml` is the ONLY composition edit, so the standing-mount layer grew stamp-keyed generations: `ensureStanding` compares the file's mtime+size and starts the next generation for later sessions ([standing-mounts note](../architecture/2026-08-08-per-preset-standing-mounts.md), updated in place). Without this, an edited file would serve stale compositions until process restart.
- A copy is a full snapshot that drifts from an upgraded shipped source — accepted; the preset layer has no patch semantics (that is the bundle layer's `cordis.patch.yml`), and the shipped set itself pays the same cost (`cordis`/`code` are full copies of `standard`) for one-file readability.
- `read` dropped `writable` (no editor to gate) and builtin directories are never opened (`openDocument` refuses non-`user` trust like `remove`): the install is overwritten by upgrades, and pointing an editor into it invites edits an upgrade silently discards.
## Load-bearing details
- **Copy target refusal is two checks on purpose.** The roster check refuses any id a root supplies — a user directory named like a shipped preset would be shadowed, so "create" would land a file nothing ever lists; the disk check (`PresetExistsError` before `cp` with `errorOnExist` as the race backstop) refuses a directory occupying the name without being a preset, which discovery cannot see.
- **The revealed path is response-direction disclosure, loopback-pinned.** The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the loopback user is the fallback the plan requires. It never rides the unprivileged `list`.
- **The e2e lane pins `nativeOpen: false`** (`agent-preset-authoring.overlay.yml`) — both so goldens render the same branch on macOS dev and headless Linux CI, and so test runs never pop a real file manager. The revealed directory is tokenized as `{{presetRoot}}` by the lane itself, since `normalizeAria` only knows the workspace cwd.
## Alternatives considered
Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side `host.openPath` with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter.

View File

@@ -0,0 +1,30 @@
# Agent Note: 仅复制的 preset 创作,与通往 preset 文件的入口
Status: implemented
[English](2026-08-08-copy-only-preset-authoring.md) | 中文
## Problem
agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` 接收任意组装文本,页面是一个没有补全、高亮或 diff 的文本域,形状检查依赖 Loader 自己的 `entryListSchema`——其方言含 `!!js`,所以「过了形状检查的文本」在下一次挂载时仍是任意代码。作为编辑器很弱,作为能力很宽,还是该分区不得不防御的「编辑器 vs 名单」竞态的来源。
## Decision
创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`agentPreset.openDocument { agentPreset }` 在宿主端解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供卡片以文本展示(`list` 上的 `hasDocument`;在 `canOpenNativePath` 平台探测会失真处由网关的 `nativeOpen` 配置钉死,例如 e2e 与容器)。
## Consequences
- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。特权集现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标。
- 编辑器移除后,手改 `agent.cordis.yml` 成为**唯一**的组装编辑方式,因此常驻挂载层增加了以 stamp 为键的代际:`ensureStanding` 比对文件的 mtime+大小,为后续会话开启下一代际([常驻挂载 note](../architecture/2026-08-08-per-preset-standing-mounts.md),已就地更新)。没有它,改过的文件要等进程重启才生效。
- 副本是完整快照会随随附来源升级而漂移——接受preset 层没有 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力),随附集合自己也为「一个文件读完整份组装」付了同样的代价(`cordis`/`code` 就是 `standard` 的完整副本)。
- `read` 去掉了 `writable`(没有编辑器可门控),内置目录绝不被打开(`openDocument``remove` 一样拒绝非 `user` 信任):安装目录会被升级覆盖,把编辑器指向它等于招揽会被升级悄悄丢弃的编辑。
## Load-bearing details
- **复制目标的拒绝刻意分两道检查。** roster 检查拒绝任一根目录提供的 id——与随附 preset 同名的用户目录会被遮蔽,「创建」只会落下一个永远不被列出的文件;磁盘检查(`cp` 之前的 `PresetExistsError``errorOnExist` 作竞态兜底)拒绝占着名字却不是 preset 的目录,那是 discovery 看不见的。
- **展示的路径是响应方向的披露,且钉在环回。**「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给环回用户正是方案要求的降级。它绝不搭乘非特权的 `list`
- **e2e lane 钉死 `nativeOpen: false`**`agent-preset-authoring.overlay.yml`)——既让 golden 在 macOS 开发机与无头 Linux CI 上渲染同一分支,也让测试运行永不弹出真实文件管理器。揭示的目录由 lane 自己 token 化为 `{{presetRoot}}`,因为 `normalizeAria` 只认识 workspace cwd。
## Alternatives considered
保留 write 换个更好的编辑器CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本「standard 加这点 diff」bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 `host.openPath`:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。

View File

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md
2026-08-01-windows-pwsh-default.md: 64d713aecda60d8747199aff5334a80bf1867d92
2026-08-01-windows-pwsh-default.zh.md: 74ba6c43dea984384a9b7dd2675b681ee025d6ca

View File

@@ -1,39 +0,0 @@
# Agent Note: Windows defaults to pwsh (roadmap)
Status: proposed
English | [中文](2026-08-01-windows-pwsh-default.zh.md)
## Problem
The harness's shipped execution profile is bash-first on every platform. Windows hosts must install a bash shim (WSL or Git-Bash) or fall back to the POSIX-only `dsh-bash-local` behavior; the model-facing bash tool teaches the bash dialect, and the TUI/Web surfaces render terminal output in bash-shaped expectations. The first Windows-native foundation shipped in the [pwsh executor and tool decision](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md): a PowerShell implementation of the `ctx.bash` seam and a parity `pwsh` tool — but nothing yet defaults Windows hosts to them.
## Proposal
Two follow-up stages, each independently shippable. The bash-tool parity twin shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface.
1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end.
2. **pwsh GUI rendering** — the Web surface renders pwsh calls with the bash-shaped terminal presentation (terminal card with exit-status pill), the counterpart of the bash terminal cards. Shipped in the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) with a keyless web lane; the TUI was removed, so no terminal twin remains. A PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains unclaimed.
The stages are ordered by dependency only where one exists: the rendering stage shipped first with the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) because it is platform-independent and its keyless web lane runs on any host, while the Windows default composition remains the only unshipped stage. Nothing in this proposal changes POSIX behavior.
## Alternatives considered
**Default Windows to pwsh inside `dsh-bash-local` (one executor, dialect switch).** Rejected for the same reason the executor decision rejected a mode switch: the executor's identity is the shell it spawns, and platform-gated composition is a deployment choice, not an executor config.
**Ship the Windows default in the same change as the executor/tool.** Rejected: the roster change needs its own evidence (what breaks when the shipped Windows tree stops mounting bash, which tools depend on bash semantics), and it belongs to a composition decision with the approval/PTY surface visible.
**Keep bash on Windows via a shim and skip PowerShell defaults.** Rejected: it perpetuates the install-tax and the dialect mismatch the roadmap exists to remove; the shim is a deployment requirement, not a product behavior.
## Acceptance criteria
- A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there.
- POSIX hosts are byte-for-byte unaffected (same roster, same executor).
- The shipped-composition e2es assert the platform-gated roster on both families.
- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 landed with the web `pwsh-terminal` rendering lane (the TUI's removal left no terminal surface to snapshot).
## Risks
- **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch.
- **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed.
- **Rendering conventions** — the bash-shaped terminal twin shipped with the Web lane; a PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains a UI design decision with snapshot surface, deferred with stage 1.

View File

@@ -1,39 +0,0 @@
# Agent Note: Windows 默认改用 pwsh路线图
Status: proposed
[English](2026-08-01-windows-pwsh-default.md) | 中文
## 问题
harness 交付的执行配置在每个平台都是 bash 优先。Windows 主机必须安装 bash 垫片WSL 或 Git-Bash或退回到仅 POSIX 的 `dsh-bash-local` 行为;面向模型的 bash 工具教的是 bash 方言TUI/Web 界面按照 bash 风格的预期渲染终端输出。第一块 Windows 原生基础已随 [pwsh 执行器与工具决策](../../implemented/feature/2026-08-01-pwsh-tool-and-executor.md) 交付:`ctx.bash` seam 的 PowerShell 实现与对等的 `pwsh` 工具——但还没有任何东西让 Windows 主机默认使用它们。
## 提案
两个阶段各自可独立交付。bash 工具对等孪生已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在除 sandbox 接口外,在前台与后台工作方面均与 `tool-bash` 对齐,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装后形态的 keyless 应用快照。
1. **Windows 默认组合**——交付的 CLI命令行界面组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bashPOSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。
2. **pwsh GUI 渲染**——Web 界面使用 bash 风格的终端呈现来渲染 pwsh 调用(带胶囊状退出状态标签的终端卡片),与 bash 终端卡片相对应。已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 及 keyless web 通道交付TUI 已移除,不再有对应的终端界面。超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 信息)仍无人认领。
各阶段仅在有依赖关系时排序:渲染阶段已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 先行交付(平台无关,其 keyless web 通道可在任意宿主运行),而 Windows 默认组合仍是唯一未交付的阶段。本提案不改变任何 POSIX 行为。
## 备选方案
**在 `dsh-bash-local` 内部让 Windows 默认 pwsh一个执行器方言开关** 否决,理由与执行器决策否决模式开关相同:执行器的身份就是它 spawn 的 shell而按平台门控的组合是部署选择不是执行器配置。
**把 Windows 默认与执行器/工具一起交付。** 否决:清单变更需要自己的证据(交付的 Windows 树停挂 bash 后什么会坏、哪些工具依赖 bash 语义并且它属于会在审批PTY 界面上显现的组合决策。
**用垫片在 Windows 上保留 bash跳过 PowerShell 默认。** 否决:这延续了安装税与路线图要消除的方言错配;垫片是部署要求,不是产品行为。
## 验收标准
- 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`
- POSIX 主机逐字节不受影响(清单相同,执行器相同)。
- 交付组合 e2e 在两个平台族上断言按平台门控的清单。
- 阶段 1 落地时parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 已随 web `pwsh-terminal` 渲染通道落地TUI 的移除意味着不再有可供快照测试的终端界面)。
## 风险
- **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell 钩子的钩子桥接、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。
- **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 暴露出来;这些覆盖必须按阶段扩展,不能想当然地认为已经具备。
- **渲染约定**——与 bash 风格一致的终端呈现已随 web 通道交付;超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 信息)仍是一项需要快照覆盖的 UI 设计决策,随阶段 1 一起延期。

View File

@@ -37,12 +37,11 @@ Then check constraints that make placement expensive or wrong:
After the structural pass, hunt the standard's slop checklist with the cheapest probes first. Verify and fetch the PR's live base, then run `pnpm --silent run change-scope --base <verified-base-ref>` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and audit prose introduced by the new base.
1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers.
2. Hunt narrated history: `rg -n "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts' --glob '!vendor/**'` and keep only contrasts against a live alternative. Keep the vendor exclusion last so include globs cannot override it.
3. Inspect long comments for reasoning transcripts: control-flow narration, test walkthroughs, proof of obvious branches, review findings, rejected local alternatives, and the same rationale repeated beside sibling methods. Preserve only a non-obvious contract or durable rationale; otherwise delete the comment.
4. Hunt duplication by grepping distinctive phrases. Keep one home and replace other copies with links.
5. Replace hand-written catalogs, test/status inventories, and JSDoc restatements with the authoritative tree, script, or generated reference.
6. In `implemented/` Agent Notes, remove migration plans, acceptance-task checklists, and future-tense spec language. Keep concise verification contracts that identify the behaviors and tiers pinning the shipped decision, plus named coverage gaps.
7. If removing prose changes a promised behavior rather than its explanation, use a proposed Agent Note first (follow [dsh-find-simplifications](../dsh-find-simplifications/SKILL.md)).
2. Hunt reasoning-transcript leakage — narrated history, dead design-session citations, review choreography, control-flow narration, test walkthroughs — with [dsh-trim-cot-leakage](../dsh-trim-cot-leakage/SKILL.md), which owns the taxonomy, recall batteries, and the keep/delete boundary. Preserve only a non-obvious contract or durable rationale; the same rationale repeated beside sibling methods keeps one home.
3. Hunt duplication by grepping distinctive phrases. Keep one home and replace other copies with links.
4. Replace hand-written catalogs, test/status inventories, and JSDoc restatements with the authoritative tree, script, or generated reference.
5. In `implemented/` Agent Notes, remove migration plans, acceptance-task checklists, and future-tense spec language. Keep concise verification contracts that identify the behaviors and tiers pinning the shipped decision, plus named coverage gaps.
6. If removing prose changes a promised behavior rather than its explanation, use a proposed Agent Note first (follow [dsh-find-simplifications](../dsh-find-simplifications/SKILL.md)).
Exclude `.agents/notes/archived/` from corpus audits and edits. Active prose may repair, redirect, or delete an inbound link, but never follow an archive-wide cleanup into the frozen target.

View File

@@ -5,7 +5,7 @@ description: Use when writing, reviewing, restoring, trimming, or auditing prose
# DeepSeek Harness Prose Standard
Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates. It is guidance, not a script.
Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates, and [dsh-trim-cot-leakage](../dsh-trim-cot-leakage/SKILL.md) for hunting and fixing reasoning-transcript leakage. It is guidance, not a script.
Comments describe non-obvious contracts or rationale that code cannot express; they do not restate what code already implies.

View File

@@ -0,0 +1,45 @@
---
name: dsh-trim-cot-leakage
description: Use when auditing or fixing prose that reads like a leaked reasoning transcript — dead design-session citations such as (decision N), audit item codes, or §N of uncommitted drafts; change narration such as "used to", "no longer", "this cut"; stack or review vantage ("a later PR in this stack", "rejected in review"); reviewer-addressed justifications; control-flow narration; or hedged planning residue in comments, JSDoc, docs, or Agent Notes.
---
# Trimming Chain-of-Thought Leakage
Chain-of-thought leakage is prose whose vantage is the authoring session rather than the repository: it cites artifacts only that session could see, narrates the change instead of the state, or argues with a reviewer who has left. The fix is never deletion alone when a passage carries factual clauses — restate each so it stands at HEAD, then delete the transcript around it; a passage carrying none (an audit code, control-flow narration) is deleted outright. **REQUIRED BACKGROUND:** [dsh-prose-standard](../dsh-prose-standard/SKILL.md) owns the complete-proposition rule this skill applies; the [committed-artifact-citations note](../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) owns the citation rule's rationale. It is guidance, not a script.
## The one test
For every suspect passage ask: **could a reader at HEAD, with no access to any session transcript, PR thread, or uncommitted draft, resolve every reference and verify every claim?** If no, restate the surviving facts from the repository's vantage and delete the rest. If yes, it is not leakage, however historical it sounds — but resolvability only clears this skill's bar: on current-state surfaces (READMEs, docs, JSDoc) a resolvable change story is still change narration, and class 3 routes it to its sanctioned home.
## Taxonomy
1. **Dead design-session citations**`(decision 7)`, `(audit C2)`, `design §4.7`, `plan §1.4`, phase labels (`T4`, `W3`, `P-I`), "the design ledger", "(B ruling)". If the decision has a committed owner, cite it by name and path; otherwise delete the citation and restate its factual clause to stand alone.
2. **Stack and PR vantage** — "a later PR in this stack", "this PR adds", "the previous commit". State the shipped mechanism or the extension point; deferred work moves to a `TODO` marker or an issue reference.
3. **Change narration and version stamps** — "used to", "no longer", "the old X", and indexical stamps ("v1", "this cut", "today", "now" contrasting with a past state). State the present behavior; a fixed regression becomes a present-tense counterfactual ("without X, Y happens"), never repo history ("used to Y").
4. **Review choreography** — "Rejected in review:", "the reviewer confirmed", draft ordinals ("v5 of this note"), round attributions. Keep the surviving decision and rationale as plain fact; delete who said it when.
5. **Reviewer-addressed justification** — "the cast is safe — it simply…", "this is correct because…". A comment arguing its own correctness addresses a reviewer, not a maintainer. State the invariant that makes the code safe, or delete the comment if the code shows it.
6. **Restatement and derivation transcripts** — control-flow narration ("first we X, then we Y"), test walkthroughs, proofs of obvious branches. Delete; keep only a non-obvious contract or invariant.
7. **Hedges and planning residue** — "probably fine for now", "should be enough", deferrals with no marker. Promote to `TODO`/`FIXME` or restate as the actual bound; delete the hedge.
8. **Authoring-language slips** — untranslated working-language fragments (端, 设计稿, `---- 私有 ----` separators) in prose whose language is otherwise English, or the reverse in a zh counterpart. Translate or delete.
## What is not leakage
The citation boundary is where unaided passes fail in both directions — deleting durable references and keeping dead ones. Apply these keeps as written; [examples](references/examples.md) calibrates each:
- **Issue references** — `#1470`, `TODO(name):`, "issue #N owns the follow-up" resolve at HEAD; keep them on any surface, including READMEs. Do not relocate them to Agent Notes.
- **Merged-PR and issue citations inside Agent Notes and postmortems** — sanctioned evidence per the [documentation standard](../../../docs/AGENTS.md)'s change-story routing.
- **Suppression justifications** — `oxlint-disable … -- reason`, coverage-ignore reasons, empty-catch explanations are required prose; fix a false reason, never delete it.
- **Counterfactual-present regression pins** — "without X, Y happens", "a naive X would…".
- **Measured bounds** — "(measured: 512 nests ≈ 0.15s)" calibrating a constant; the provenance word "measured" is load-bearing.
- **Runtime old/new states** — "the old connection drains before the new one accepts" is runtime lifecycle, not change history.
- **Historical stage names inside a note's change-story sections** — "the first cut shipped X" is current-state-safe there; indexical stamps ("this cut") stay banned everywhere.
- **External references that resolve outside the repo by design** — standards sections (RFC 9110 §10.1.5), Figma frame names; the §-ban covers uncommitted internal drafts, not external standards or committed docs that own their §-numbering.
- **Project voice and genre forms** — "we" as project voice; a note's Alternatives-considered section.
## Workflow
1. Scope and exclusions per [dsh-prose-standard](../dsh-prose-standard/SKILL.md): require an explicit scope; never touch `vendor/`, `.agents/notes/archived/`, or recorded fixtures and snapshots — recorded model output and sealed history keep their original voice.
2. Audit read-only first: run the [recall batteries](references/recall-batteries.md) (with `--hidden` so `.agents/` is searched), then judge every hit semantically. The batteries are probes, not the definition — each review round of the original purge surfaced shapes the batteries missed, so also read the densest prose in scope (module JSDoc, READMEs, Agent Notes) without a pattern in hand.
3. Fix owner-first per surface: generated catalogs → fix the source JSDoc or generator template, then regenerate; type-equivalence fences → fix the source JSDoc, then re-paste both bilingual pages (`verify-type-equiv` pins them); bilingual pairs → update the counterpart and re-record per [dsh-translate-docs](../dsh-translate-docs/SKILL.md); model-visible strings → wording is behavior, so flag for a snapshot-backed change instead of silently rewording.
4. Before deleting anything, enumerate the passage's propositions (prose-standard) and check the [overcorrection traps](references/examples.md#overcorrection-traps): trims that flip an obligation into an endorsement, promote a hypothetical to a shipped feature, delete a true fact, or drop provenance.
5. Verify: re-run the batteries expecting only sanctioned keeps, this skill's own directory, and the owning note's quoted evidence; confirm every remaining citation resolves at HEAD; run the gates for touched surfaces (`doc-sync` for docs, `verify-type-equiv`, `verify-translation-pairing`).

View File

@@ -0,0 +1,253 @@
# Few-shot leakage examples
Distilled from the 2026-08 repo-wide purge and its review rounds. Use them to identify the governing principle, not as text templates. This file deliberately quotes leaked shapes as calibration material — the [recall batteries](recall-batteries.md) exclude the skill's directory, and its wording is not a license elsewhere.
## Dead citations
### Decision ordinal with a committed owner
**Leaked:** "Slash input resolves against the visible catalog (decision 21)."
**Fixed:** "Slash input resolves against the visible catalog — the plain-text-reference decision, owned by [the web input-machine note](../../../notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)."
The ordinal resolves nowhere at HEAD; the decision's name and owning note path do. Name the owning note's path at least once per file — as a link where the surface supports one — and later mentions may use the searchable name alone.
### Decision ordinal without an owner
**Leaked:** "The registry rejects duplicate names (decision 7: names are flat, no namespacing)."
**Fixed:** "The registry rejects duplicate names; names are flat, with no namespacing."
No committed artifact owns "decision 7", so the citation is deleted — but its factual clause (flat names) is restated to stand alone, not deleted with it.
### Audit item codes
**Leaked:** "Rendering is pure: same snapshot, same string (audit R3)."
**Fixed:** "Rendering is pure: same snapshot, same string."
There is no audit document in the repo; the code is pure session shorthand carrying zero propositions.
### Section numbers of uncommitted drafts
**Leaked:** "Layering follows the design (v2 §3.2): `src/core/` is the pure core."
**Fixed:** "Layering: `src/core/` is the pure core."
`§N` of a draft nobody committed is unresolvable. Contrast: "escapes per RFC 9110 §10.1.5" stays — an external standard resolves outside the repo by design, and a committed doc that owns its §-numbering may be cited by section.
### Plan-phase labels
**Leaked:** "`src/client/` is the shell (T4); the P-I migration owns the adapters."
**Fixed:** "`src/client/` is the shell; the adapters live in `src/client/adapters/`."
Phase labels index a plan that never landed. Replace the label with what the phase produced.
## Stack and PR vantage
### Stack position in durable prose
**Leaked:** "A future remote backend implements this interface (the sandbox backend is a later PR in this stack)."
**Fixed:** "A remote backend can implement this interface without changing the render layer."
Durable prose cannot see the stack. Keep the extension-point contract; the pending work's home is the PR itself, a `TODO`, or an issue.
### "This PR" in a README
**Leaked:** "This PR adds cursor-based pagination to the session list."
**Fixed:** "The session list paginates by cursor."
A README outlives every PR; state the mechanism as current fact.
## Change narration and version stamps
### War story with a PR number
**Leaked:** "Colors used to come from `--widget-*` tokens, which nothing defined, so it always rendered the fallbacks; the alias tokens fixed that (PR #88)."
**Fixed:** "Colors come from the alias tokens; an undefined token renders the fallbacks."
Both live facts survive — the current mechanism and the standing failure behavior — restated in the present. The bug's biography belongs to the PR and its Agent Note.
### Removal narration
**Leaked:** "The `probe` field is gone with the removal cut; badges ride the generic projection pair now."
**Fixed:** "Badges use the generic projection pair."
Readers who never saw `probe` learn nothing from its absence. "Now" contrasting with a deleted past is a version stamp.
### Fixed regression → counterfactual present
**Leaked:** "This used to double-encode multibyte labels."
**Fixed:** "Without the byte-length guard, multibyte labels double-encode."
The regression pin survives as a present-tense counterfactual that names the guard; "used to" pins it to repo archaeology instead.
### Indexical version stamps
**Leaked:** "Batch rendering is synchronous this cut; the async path is roadmap work."
**Fixed:** "Batch rendering is synchronous." (The deferral lives in `TODO(widget-batch):` at the call site.)
"This cut" / "v1" / "today" go stale the moment they merge. A historical stage name inside an Agent Note's change-story section ("the first cut shipped X") is current-state-safe; the indexical form never is.
## Review choreography
### Review verdicts as prose
**Leaked:** "Rejected in review: caching the resolved spec. We keep resolution per-call."
**Fixed (in an Agent Note's Alternatives considered):** "**Caching the resolved spec.** Rejected: the spec depends on per-call cwd, so a cache keyed by request would serve stale roots."
The alternatives-considered genre is the sanctioned home; the reviewer and the round are not part of the rationale.
### Draft ordinals
**Leaked:** "As of v5 of this note, the loader also validates manifests."
**Fixed:** "The loader validates manifests."
An implemented note states shipped reality; its own revision history lives in git.
## Reviewer-addressed justification
### Arguing a cast
**Leaked:** "The cast is safe — the SDK constructed the object, it simply doesn't declare the optionals strictly enough."
**Fixed:** "The SDK constructs this object with every optional populated; the declared type is looser than the runtime guarantee."
State the invariant a maintainer must not break. "It simply…" is a voice answering an objection nobody at HEAD raised. If the invariant is visible in the code, delete the comment instead.
### Appeal to review authority
**Leaked:** "This is correct because the reviewer confirmed the wrapping order."
**Fixed:** (deleted; the wrapping order is stated in the function's `@returns`.)
Correctness claims cite invariants or tests, never people.
## Restatement and derivation
### Control-flow narration
**Leaked:** "First we normalize the label, then we truncate it, then we wrap it."
**Fixed:** (deleted.)
The three lines below the comment say the same thing in code.
### Test walkthrough
**Leaked:** "This test creates a session, sends two messages, waits for the second reply, and then asserts the log has four entries."
**Fixed:** "Two round-trips must produce exactly four log entries — the projection dedupes the shared prefix."
Keep only the non-obvious assertion rationale; the walkthrough restates the test body.
## Hedges and planning residue
### Unmarked deferral
**Leaked:** "Probably fine to render eagerly for now."
**Fixed:** (deleted; the deferral already has its `TODO(widget-batch):` marker.)
A hedge without an owner is planning residue. If no marker exists, write one (`TODO(name): coalesce per animation frame`) instead of keeping the hedge.
### Vague sizing
**Leaked:** "A 64 KiB buffer should be enough for most cases."
**Fixed:** "64 KiB holds the largest observed frame (48 KiB) with headroom; a larger frame fails loudly in `decode`."
Replace the hedge with the actual bound and the failure behavior when it is exceeded.
## Authoring-language slips
**Leaked:** "The renderer runs on the client 端; see the 设计稿 for spacing. ---- 私有 ----"
**Fixed:** "The renderer runs on the client side; spacing follows the Figma frame `widget-badges`."
Working-language fragments and session separators are transcription residue. The Figma frame name stays: external provenance that resolves outside the repo by design.
## Keeps
### Issue references are durable on every surface
**Keep:** "The cap applies to the complete rendered value, wrappers included (issue #1470 owns the follow-up)."
An unaided pass deleted this, reasoning that issue citations belong in Agent Notes. Wrong direction: issues resolve at HEAD from any surface, and "#N owns the follow-up" is the sanctioned home for deferred work in a README. What Agent Notes and postmortems additionally sanction is citing *merged PRs* as evidence.
### Dead name-drops are not "naming the owner"
**Delete:** "Badge renderer over the widget seam (see the widget-rendering RFC)."
An unaided pass kept this as "naming the owning document by topic". The test is resolvability, not form: no committed file answers to "the widget-rendering RFC", so the pointer is dead. Retarget it to the committed owner if one exists; otherwise delete it.
### Suppression justifications
**Keep (after fixing):** `// oxlint-disable-next-line no-non-null-assertion -- the one-element literal guarantees index 0.`
The justification clause is required prose. When the stated reason is false (the original said "the loop guard above proves a frame exists" with no loop in sight), fix the reason; never delete it.
### Measured bounds
**Keep:** "Depth cap (measured: 512 nests ≈ 0.15s synchronous; 4096 blocks the loop)."
The measurement pins the constant against uninformed retuning, and "measured" is the provenance that distinguishes data from a guess.
### Runtime old/new is not change history
**Keep:** "The old connection drains before the new one accepts."
"Old" and "new" here name two live runtime objects during handover, not repository states. The change-narration ban is about repo history, not lifecycle vocabulary.
## Overcorrection traps
Every trap below shipped in the original purge and was caught in review. Enumerate a passage's propositions before trimming it.
### Flipping an obligation into an endorsement
**Original:** "These direct registrations are exceptions pending migration to slots."
**Overcorrected:** "These direct registrations are sanctioned exceptions."
**Right:** "These direct registrations are exceptions pending migration to slots."
"Pending migration" is an obligation; "sanctioned" blesses the status quo. The trim inverted the sentence's modality while shortening it.
### Promoting a hypothetical to a shipped feature
**Original:** "A future IPC-based shell subclasses the executor and overrides `spawn`."
**Overcorrected:** "An IPC-based shell subclasses the executor and overrides `spawn`."
**Right:** "A hypothetical IPC-based shell — no such shell exists — would subclass the executor and override `spawn`."
Deleting the future-marker alone turns a design illustration into a claim that the class ships. Mark the hypothetical explicitly instead of just unmarking the future.
### Deleting a true fact with the transcript around it
**Original:** "The gate notice narrates the check order; the notice text is also what `verify-doc-typecheck` compiles against."
**Overcorrected:** "…" (whole sentence deleted as narration.)
**Right:** "The notice text is what `verify-doc-typecheck` compiles against."
Half the sentence was narration; the other half was a load-bearing coupling. Delete clauses, not sentences, when propositions share a line.
### Dropping provenance while keeping the number
**Original:** "The 4 MiB ceiling is measured: the largest generated `py-types` module is 3.1 MiB."
**Overcorrected:** "The ceiling is 4 MiB; the largest generated `py-types` module is 3.1 MiB."
**Right:** keep "measured".
Without "measured" the 3.1 MiB reads as a definition rather than an observation, and nobody re-measures before raising the ceiling.

View File

@@ -0,0 +1,43 @@
# Recall batteries
Probes for [the taxonomy](../SKILL.md#taxonomy), tuned during the 2026-08 purge. Every hit needs semantic judgment — the batteries over-match by design, and they under-match by nature: each review round of the purge found shapes no battery caught, so pair them with an unpatterned read of the densest prose in scope.
## Invocation rules
- Add `--hidden --glob '!.git/**'` so `.agents/` is searched; ripgrep skips dot-directories by default and the purge's biggest miss risk was Agent Notes.
- Exclusions go last so a later include cannot re-admit them: `--glob '!vendor/**' --glob '!node_modules/**' --glob '!.agents/notes/archived/**' --glob '!.agents/skills/dsh-trim-cot-leakage/**'` (the skill's own files quote leaked shapes as calibration), plus recorded fixture and snapshot directories in scope. The [owning note](../../../notes/implemented/process/2026-08-09-committed-artifact-citations.md) also self-hits through its quoted evidence; judge it as evidence, not usage.
- Natural-language lines carry `-i` so sentence-initial capitals hit ("This PR adds…", "Probably fine…"); the code-shaped first line stays case-sensitive — `-i` would turn `\bT\d\b` and `\bP-I\b` into noise.
- A zero-hit pattern proves nothing until you have seen it match: test it against a known-positive string before trusting the negative.
## English battery
```sh
rg -n --hidden '\(decision \d|\(audit [A-Z]\d|design §|plan §|design ledger|\(B ruling|\bP-I\b|\bW\d\b|\bT\d\b' ...
rg -n --hidden -i 'this PR|this branch|this stack|later PR|previous commit|this commit' ...
rg -n --hidden -i 'used to |no longer|previously|the old |was renamed|was moved' ...
rg -n --hidden -i '\bv1\b|this cut|\bcut \d|\btoday\b|\bfor now\b|roadmap' ...
rg -n --hidden -i 'rejected in review|review round|reviewer|as of v\d' ...
rg -n --hidden -i 'probably |should be enough|should suffice|it simply|is safe —|is safe --' ...
rg -n --hidden '§\d' ...
```
## Chinese battery
```sh
rg -n --hidden '设计稿|评审|上一?轮|旧版|老的|不再|以前|本版|遗留|私有' ...
rg -n --hidden '(^|[^a-zA-Z])端([^a-zA-Z]|$)' --glob '*.md' ...
```
## Known false-positive families
Judged and kept during the purge; expect them again:
- **Instrumental "used to"** — "the key used to sign requests" is instrumental, not temporal. The temporal form has a subject state before it ("colors used to come from…").
- **Runtime old/new** — "the old connection drains before the new one accepts" names live objects during handover, not repo states.
- **"This PR" in process docs** — documentation *about* PR workflow ("the PR body should…", templates, this repo's process notes) legitimately says "PR"; the ban is on a doc adopting one PR's vantage about the code.
- **`v1` as protocol or path segment** — `/v1/chat` endpoints and wire-format names are identifiers, not version stamps.
- **`§N` with a committed owner** — external standards (RFC 9110 §10.1.5) and committed docs that own their §-numbering stay citable by section.
- **Contrastive "actually" and noun "wait"** — ordinary English, not hedging; no committed line probes them, so they surface only when you extend the battery with broader hedging patterns.
- **"Today" in generated timestamps and CLI output samples** — recorded output keeps its voice.
- **本版本 in zh prose** — a legitimate rendering of "this release" in versioned-artifact contexts; the banned indexical is 本版 as a bare stamp mirroring "this cut".
- **Alternatives-considered sections** — "rejected" inside an Agent Note's genre slot is the sanctioned home, not review choreography.

2
.github/AGENTS.md vendored
View File

@@ -1,3 +1,3 @@
# AGENTS.md — GitHub Actions
Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is not one of them: it runs Windows Node under Wine on hosted Linux, so its steps are bash — see the [Wine lane Agent Note](../.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md).
Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is the deliberate exception: it runs Windows Node under Wine on hosted Linux and blocks `all checks passed`; `windows-native` runs automatically on `windows-2025` but reports independently — see the [dual-lane Agent Note](../.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md).

View File

@@ -315,15 +315,14 @@ jobs:
# The required pull-request Windows signal: the two blocking win32 surfaces
# (workspace build, production site) execute with real, checksum-verified
# Windows Node under Wine on standard hosted Linux. The master
# serial-windows job below normally keeps the complete native-kernel
# inventory — including the observational portability gates this lane does
# not run —
# Windows Node under Wine on standard hosted Linux. The independent
# windows-native job below keeps the complete native-kernel inventory —
# including the observational portability gates this lane does not run —
# on real windows-2025. This job only provisions runner state (caches,
# apt); scripts/wine-windows-gates.sh owns the gate logic and is the same
# script the optional local gate `pnpm run check:windows-wine` runs.
# Layout, fidelity limits, and measured timings live in
# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
# Current topology and fidelity limits live in
# .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
windows:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
@@ -423,6 +422,49 @@ jobs:
cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/"
du -sh "$HOME/wine-debs"
# Every pull request also gets a real Windows-kernel signal. This job keeps
# its own unmasked conclusion but is deliberately absent from
# all-checks-passed.needs, so it never delays or changes that required verdict.
# See the dual Wine/native pull-request CI decision:
# .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
windows-native:
if: github.event_name == 'pull_request'
runs-on: dsh-windows-2025-16core
name: windows node 24 / native complete
timeout-minutes: 60
env:
DSH_COVERAGE_MAX_WORKERS: '2'
DSH_GATE_CONCURRENCY: '2'
DSH_PUBLINT_CONCURRENCY: '8'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Enable Developer Mode (symlink support)
shell: pwsh
run: >-
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
# Extracting the many-file pnpm store cache is slower than a clean install,
# and saving it adds more latency after the gates.
- name: Install (immutable)
shell: pwsh
run: pnpm install --frozen-lockfile
- name: Run complete native Windows gate inventory
shell: pwsh
run: pnpm run check:ci:windows-complete
# The hosted reference jobs below are temporarily disabled; the self-hosted
# standby remains active. Each enabled host executes the complete, unsharded
# primary Node aggregate with one gate worker, giving reviewers a simple
@@ -681,8 +723,8 @@ jobs:
with:
dest: ${{ runner.temp }}/setup-pnpm
# The Windows lanes deliberately skip the store cache like the required
# windows job; an empty cache input disables setup-node's caching.
# The benchmark's Windows lanes deliberately skip the store cache like
# the independent native Windows job; an empty input disables caching.
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
@@ -824,9 +866,10 @@ jobs:
# Single stable required check for branch protection: require "all checks
# passed" instead of enumerating matrix legs whose names change as lanes and
# node versions evolve. Every blocking job in THIS workflow must be listed in
# `needs`; observational Windows gates share the required Windows job but are
# marked non-blocking inside run-gates. (`needs` cannot reach across workflow
# files; e2e.yml stays its own check.)
# `needs`. The required Wine job is listed as `windows`; `windows-native` is
# deliberately absent so its independent result never delays or changes this
# verdict. (`needs` cannot reach across workflow files; e2e.yml stays its own
# check.)
# `if: always()` is load-bearing: without it a failed dependency
# would SKIP this job, and GitHub counts a skipped required check as passing
# — so this job always runs and fails on any non-success result, including

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ oxlint-contract-*.ts
.humanize/
tmp/
.claude/commands/
.claude/launch.json
.claude/settings.json
.vscode/
.DS_Store

View File

@@ -30,6 +30,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
workflow/ workflow capability + worker-thread provider + tool Consumer
todo/ todo_write tool
plan/ plan mode as logged state
preset/ per-session agent composition from preset cordis.yml files
guard/ loop-hygiene + tool-timeout plugins
self-modification/ the agent inspects/mounts its own plugins
hooks/ Claude Code/Codex hook bridges + wire-protocol library

View File

@@ -0,0 +1,240 @@
# The `code` agent preset: the standard coding agent, presented as Code Mode.
#
# Everything in `standard` is here unchanged. What is added is the `tool-mode`
# row: instead of one tool call per action, the model writes a TypeScript
# program against a generated SDK and `run_code` executes it, so a sequence
# that would be five round trips becomes one.
#
# The registry itself stays on the host plane — the agent loop's scheduler and
# the API proxy's presenters are its consumers — so what this preset owns is
# the PRESENTATION of that registry for this agent alone. Native sessions run
# beside this one in the same process, each seeing its own catalog.
#
# This file is an AGENT-PLANE composition. It is mounted under one agent's
# scope context, so every tool and prompt section it registers belongs to that
# session alone. The host composition (`base.cordis.yml` + `web.cordis.yml`)
# keeps everything a preset must not own: the registries themselves, the
# sandbox and approval stack, persistence, and the model route.
#
# A service row here MUST sit inside a group carrying an `isolate` realm.
# Without one it publishes into the root realm, where it is process-global
# rather than per-session and the second session mounting this preset collides
# with the first; `dsh-agent-presets` rejects that at mount. `true` means an
# entry-local realm — one private instance per mounted session, which is the
# default this deployment wants. A shared label would instead pool one instance
# across every session naming it.
# ── identity ────────────────────────────────────────────────────────────────
# The preset's own persona, shadowing the deployment default for this agent.
# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace.
- id: persona
name: '@deepseek-ai/dsh-persona'
config:
text: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
- id: workspace-context
name: '@deepseek-ai/dsh-workspace-context'
config:
maxBytes: 65536
# ── shell ───────────────────────────────────────────────────────────────────
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
# the criterion for host-plane ownership — injection resolves before any session
# exists, so there is no agent to key by. Behind a preset realm those variables
# never reached the model's shell at all. `tool-bash` consumes the host registry
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
# sandbox policy owns it.
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
# ── filesystem ──────────────────────────────────────────────────────────────
# All three register into the host `tools` registry and provide nothing, so
# they need no realm. The `fs` service and its policy stay in the host.
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
config:
sampleOverCapGlobResults: false
- id: tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000
# ── background tasks ────────────────────────────────────────────────────────
- id: tasks
name: cordis:group
group: true
isolate:
tasks: true
config:
- id: tasks-local
name: '@deepseek-ai/dsh-tasks-local'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# ── skills ──────────────────────────────────────────────────────────────────
# The skill REGISTRY lives in the host composition and is layered per scope:
# these rows register into THIS preset's layer of it, so they need no realm.
# `skill-local` contributes local-root discovery for agents on this preset, and
# `tool-skill` gives them the catalog and loader; the merged catalog also
# carries whatever the deployment registered globally (repository plugins).
- id: skill-local
name: '@deepseek-ai/dsh-skill-local'
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'
# ── goals ───────────────────────────────────────────────────────────────────
# Only the model-facing tool. The goal SERVICE, its session driver, and the
# `/goal` command stay on the host plane: the Gateway serves the goal domain as
# Remote endpoints whose receiver comes from a generated descriptor, so it
# resolves `goals` on the host and an entry-local realm here would hide it. The
# registry is keyed by session anyway, so one host instance serves every
# session. What a preset chooses is whether its agent can call the goal tool.
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# ── plan mode ───────────────────────────────────────────────────────────────
# Plan state is per-agent by nature, so an entry-local realm is not a
# workaround here — it is the correct lifetime.
- id: planning
name: cordis:group
group: true
isolate:
planMode: true
config:
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
# ── compaction ──────────────────────────────────────────────────────────────
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
# share this realm rather than sit outside it.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
toolResultPrune: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
config:
thresholdChars: 8192
headChars: 4096
tailChars: 1024
# ── delegation and workflows ────────────────────────────────────────────────
# The `subagents` registry and its spawn/fork backends live in the HOST
# composition: the registry is a process singleton whose cross-session queries
# the api-proxy serves to the browser, and a provider name may only be
# registered once. This preset contributes the delegation TOOLS, which resolve
# that host registry.
#
# `workflows` is different — nothing outside an agent reads it — so every row
# that reaches it shares one entry-local realm here, and a consumer left
# outside would resolve a host registry this preset does not populate.
- id: delegation
name: cordis:group
group: true
isolate:
workflows: true
config:
- 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
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
config:
provider: spawn
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
config:
subagentProvider: spawn
maxRounds: 64
# ── remaining model-facing rows ─────────────────────────────────────────────
- id: tool-ask-user
name: '@deepseek-ai/dsh-tool-ask-user'
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
config:
allowParallelInProgress: true
# The `web` service and its search provider stay in the host composition; only
# the model-facing tool is per-session.
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
fetch: false
searchTimeoutMs: 60000
# ── presentation ────────────────────────────────────────────────────────────
# Code Mode for this agent alone. The row waits for the host's `codeRuntime`
# rather than assuming it: a deployment that composes no TypeScript runtime
# fails this preset at mount, naming this id, instead of at the first request.
- id: tool-mode
name: '@deepseek-ai/dsh-agent-tool-mode'
config:
mode: code

View File

@@ -0,0 +1,3 @@
name: 代码模式
description: 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK一次执行代替多轮工具调用。
order: 2

View File

@@ -0,0 +1,240 @@
# The `cordis` agent preset: the standard coding agent, plus the ability to
# read and write the runtime it is running in.
#
# It exists so a person can ask an agent to author another agent. Everything in
# `standard` is here unchanged; what is added is the self-referential Cordis
# toolset, a skill that teaches composition authoring, and a persona that says
# which of the two planes an edit belongs to.
#
# TRUST: `cordis_mount` evaluates model-written JavaScript against the live
# runtime, and a composition this agent writes becomes a preset other sessions
# mount. Treat a session on this preset as shell access — the toolset's own
# documentation makes the same statement.
# The preset's own persona, shadowing the deployment default for this agent.
# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace.
- id: persona
name: '@deepseek-ai/dsh-persona'
config:
text: |-
You are a coding agent powered by the {{model}} model, running on the DeepSeek Harness. Your working directory is {{cwd}}.
You can read and modify the harness you run on. Its composition is Cordis: every capability is a plugin row in a `cordis.yml`, and an agent preset is one such file mounted for a single session.
Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it.
Presets you author live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`, one directory per preset. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy.
Load the `editing-cordis-compositions` skill before writing or changing a composition.
- id: workspace-context
name: '@deepseek-ai/dsh-workspace-context'
config:
maxBytes: 65536
# ── shell ───────────────────────────────────────────────────────────────────
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
# the criterion for host-plane ownership — injection resolves before any session
# exists, so there is no agent to key by. Behind a preset realm those variables
# never reached the model's shell at all. `tool-bash` consumes the host registry
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
# sandbox policy owns it.
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
# ── filesystem ──────────────────────────────────────────────────────────────
# All three register into the host `tools` registry and provide nothing, so
# they need no realm. The `fs` service and its policy stay in the host.
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
config:
sampleOverCapGlobResults: false
- id: tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000
# ── background tasks ────────────────────────────────────────────────────────
- id: tasks
name: cordis:group
group: true
isolate:
tasks: true
config:
- id: tasks-local
name: '@deepseek-ai/dsh-tasks-local'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# ── goals ───────────────────────────────────────────────────────────────────
# Only the model-facing tool. The goal SERVICE, its session driver, and the
# `/goal` command stay on the host plane: the Gateway serves the goal domain as
# Remote endpoints whose receiver comes from a generated descriptor, so it
# resolves `goals` on the host and an entry-local realm here would hide it. The
# registry is keyed by session anyway, so one host instance serves every
# session. What a preset chooses is whether its agent can call the goal tool.
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# ── plan mode ───────────────────────────────────────────────────────────────
# Plan state is per-agent by nature, so an entry-local realm is not a
# workaround here — it is the correct lifetime.
- id: planning
name: cordis:group
group: true
isolate:
planMode: true
config:
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
# ── compaction ──────────────────────────────────────────────────────────────
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
# share this realm rather than sit outside it.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
toolResultPrune: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
config:
thresholdChars: 8192
headChars: 4096
tailChars: 1024
# ── delegation and workflows ────────────────────────────────────────────────
# The `subagents` registry and its spawn/fork backends live in the HOST
# composition: the registry is a process singleton whose cross-session queries
# the api-proxy serves to the browser, and a provider name may only be
# registered once. This preset contributes the delegation TOOLS, which resolve
# that host registry.
#
# `workflows` is different — nothing outside an agent reads it — so every row
# that reaches it shares one entry-local realm here, and a consumer left
# outside would resolve a host registry this preset does not populate.
#
# `tool-subagent-report` is host-plane for the same reason as the registry,
# not because a preset may not want it: it registers a CONTINUABLE SETUP on
# that singleton rather than a tool this agent calls, and the setup list is
# not scope-aware — one copy per mounted preset means every child gets
# `report` registered once per live session, which throws on the second.
- id: delegation
name: cordis:group
group: true
isolate:
workflows: true
config:
- 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
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
config:
provider: spawn
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
config:
subagentProvider: spawn
maxRounds: 64
# ── remaining model-facing rows ─────────────────────────────────────────────
- id: tool-ask-user
name: '@deepseek-ai/dsh-tool-ask-user'
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
config:
allowParallelInProgress: true
# The `web` service and its search provider stay in the host composition; only
# the model-facing tool is per-session.
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
fetch: false
searchTimeoutMs: 60000
# ── self-modification ───────────────────────────────────────────────────────
# Read the live runtime, mount a temporary plugin, unmount it. The toolset is a
# trust boundary, not a sandbox — see this file's header.
- id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis'
# The composition-authoring skill travels with this preset rather than living
# in the user's skill root: it documents THIS deployment's two planes, and a
# preset is the unit that gets copied and edited. `baseUrl` is the preset's
# own directory, so the root resolves wherever the preset is installed.
# Both rows register into THIS preset's layer of the host skill registry, so
# they need no realm; the agent's merged catalog also carries whatever the
# deployment registered globally (repository plugins).
- id: skill-local
name: '@deepseek-ai/dsh-skill-local'
config:
customSkillDirs:
- !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))"
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'

View File

@@ -0,0 +1,3 @@
name: 创造模式
description: 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
order: 4

View File

@@ -0,0 +1,68 @@
---
name: editing-cordis-compositions
description: Use when creating or changing a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, or diagnosing a row that mounted but contributed nothing.
---
# Editing Cordis compositions
Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.
## Decide the plane first
Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared.
**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.
**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.
**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.
A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. The shipped presets live beside the deployment's composition; locally authored ones live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<name>/`.
## Authoring a preset
1. **Start from a copy.** Read a shipped composition close to what you want (the `standard` preset is the full coding agent) and copy its whole directory into `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/` — the id must be lowercase letters, digits, and hyphens, because it becomes the directory name. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.
2. **Expect the file sandbox.** The preset root lies outside the session workspace, so under the default `workspace-write` policy the first write is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands.
3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster.
4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above.
The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete.
## The rule that catches people
**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.
Whether a row publishes a service is not visible from its name. `tool-bash` reads like a tool but provides `bashEnv`. Check the package's README, or mount the preset and read the rejection — it names the offending service.
When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm:
```yaml
- id: tasks
name: cordis:group
group: true
isolate:
tasks: true
config:
- id: tasks-local
name: '@deepseek-ai/dsh-tasks-local'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
```
`true` means a realm private to each mounting session. A string label instead pools one instance across every subtree naming that label — use it only for something genuinely expensive to duplicate.
A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing.
Registry-shaped host capabilities need no realm at all: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally.
## Verifying a change
Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it.
To check a preset you authored, re-read the files you wrote and walk the shape: a top-level YAML list, every row a map with a `name`, every group carrying its own list, service-publishing rows behind an `isolate` realm. The settings page's preset roster runs the same shape check and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself.
`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.
## What not to move into a preset
`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.

View File

@@ -0,0 +1,31 @@
# The `minimal` agent preset: the two-tool benchmark surface.
#
# The native model surface is exactly persistent `bash` plus
# `str_replace_editor`. Everything else a session could reach — skills, goals,
# plan mode, delegation, workflows, todo, web — is simply absent rather than
# disabled, because a preset composes what an agent has instead of subtracting
# from a shared default.
#
# The host composition is unchanged: this agent still runs inside the same
# sandbox, approval, persistence, and model routing as any other session.
- id: persona
name: '@deepseek-ai/dsh-persona'
config:
text: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
# the criterion for host-plane ownership — injection resolves before any session
# exists, so there is no agent to key by. Behind a preset realm those variables
# never reached the model's shell at all. `tool-bash` consumes the host registry
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
# sandbox policy owns it.
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
- id: tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000

View File

@@ -0,0 +1,3 @@
name: 极简模式
description: 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。
order: 3

View File

@@ -0,0 +1,229 @@
# The `standard` agent preset: the full coding agent, mounted once per process.
#
# This file is an AGENT-PLANE composition. The roster mounts it ONCE under a
# standing scope; every session naming it joins by scope parentage, so the
# tools and prompt sections registered here cover each joined agent while a
# session's own state stays keyed per Session/Agent inside the plugins. The
# host composition (`base.cordis.yml` + `web.cordis.yml`) keeps everything a
# preset must not own: the registries themselves, the sandbox and approval
# stack, persistence, and the model route.
#
# A service row here MUST sit inside a group carrying an `isolate` realm.
# Without one it publishes into the root realm, where it is process-global —
# another preset publishing the same name collides, and a host reader would
# resolve one preset's instance for every session; `dsh-agent-presets` rejects
# that at mount. `true` means an entry-local realm: this standing mount's own
# private instance, apart from every other preset's. (A shared label does NOT
# pool instances — `provide()` throws on the second registration under the
# same realm symbol; labels join REALMS, and are not what this file needs.)
# ── identity ────────────────────────────────────────────────────────────────
# The preset's own persona, shadowing the deployment default for this agent.
# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace.
- id: persona
name: '@deepseek-ai/dsh-persona'
config:
text: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
- id: workspace-context
name: '@deepseek-ai/dsh-workspace-context'
config:
maxBytes: 65536
# ── shell ───────────────────────────────────────────────────────────────────
# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
# the criterion for host-plane ownership — injection resolves before any session
# exists, so there is no agent to key by. Behind a preset realm those variables
# never reached the model's shell at all. `tool-bash` consumes the host registry
# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the
# sandbox policy owns it.
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
# ── filesystem ──────────────────────────────────────────────────────────────
# All three register into the host `tools` registry and provide nothing, so
# they need no realm. The `fs` service and its policy stay in the host.
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
config:
sampleOverCapGlobResults: false
- id: tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000
# ── background tasks ────────────────────────────────────────────────────────
- id: tasks
name: cordis:group
group: true
isolate:
tasks: true
config:
- id: tasks-local
name: '@deepseek-ai/dsh-tasks-local'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# ── skills ──────────────────────────────────────────────────────────────────
# The skill REGISTRY lives in the host composition and is layered per scope:
# these rows register into THIS preset's layer of it, so they need no realm.
# `skill-local` contributes local-root discovery for agents on this preset, and
# `tool-skill` gives them the catalog and loader; the merged catalog also
# carries whatever the deployment registered globally (repository plugins).
- id: skill-local
name: '@deepseek-ai/dsh-skill-local'
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'
# ── goals ───────────────────────────────────────────────────────────────────
# Only the model-facing tool. The goal SERVICE, its session driver, and the
# `/goal` command stay on the host plane: the Gateway serves the goal domain as
# Remote endpoints whose receiver comes from a generated descriptor, so it
# resolves `goals` on the host and an entry-local realm here would hide it. The
# registry is keyed by session anyway, so one host instance serves every
# session. What a preset chooses is whether its agent can call the goal tool.
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# ── plan mode ───────────────────────────────────────────────────────────────
# Plan state is per-agent by nature, so an entry-local realm is not a
# workaround here — it is the correct lifetime.
- id: planning
name: cordis:group
group: true
isolate:
planMode: true
config:
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
# ── compaction ──────────────────────────────────────────────────────────────
# `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must
# share this realm rather than sit outside it.
- id: compaction
name: cordis:group
group: true
isolate:
tokenMeter: true
compact: true
toolResultPrune: true
config:
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
- id: tool-result-prune
name: '@deepseek-ai/dsh-compact-tool-result-prune'
config:
thresholdChars: 8192
headChars: 4096
tailChars: 1024
# ── delegation and workflows ────────────────────────────────────────────────
# The `subagents` registry and its spawn/fork backends live in the HOST
# composition: the registry is a process singleton whose cross-session queries
# the api-proxy serves to the browser, and a provider name may only be
# registered once. This preset contributes the delegation TOOLS, which resolve
# that host registry.
#
# `workflows` is different — nothing outside an agent reads it — so every row
# that reaches it shares one entry-local realm here, and a consumer left
# outside would resolve a host registry this preset does not populate.
#
# `tool-subagent-report` is host-plane for the same reason as the registry,
# not because a preset may not want it: it registers a CONTINUABLE SETUP on
# that singleton rather than a tool this agent calls, and the setup list is
# not scope-aware — one copy per mounted preset means every child gets
# `report` registered once per live session, which throws on the second.
- id: delegation
name: cordis:group
group: true
isolate:
workflows: true
config:
- 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
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
config:
provider: spawn
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
config:
subagentProvider: spawn
maxRounds: 64
# ── remaining model-facing rows ─────────────────────────────────────────────
- id: tool-ask-user
name: '@deepseek-ai/dsh-tool-ask-user'
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
config:
allowParallelInProgress: true
# The `web` service and its search provider stay in the host composition; only
# the model-facing tool is per-session.
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
fetch: false
searchTimeoutMs: 60000

View File

@@ -0,0 +1,3 @@
name: 标准模式
description: 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。
order: 1

View File

@@ -75,8 +75,11 @@
- id: tool-str-replace-editor
disabled: true
# The matching browser controls must not offer host tools that this profile
# omits. ui-question's host half owns the ask_user_question registration.
# The matching browser controls must not offer surfaces whose tool this
# overlay omits: the panels would render for a capability the model does not
# have. Turning the row off no longer removes a tool — `ui-question`'s host
# half is empty and `tool-ask-user` is composed per preset — so this is a UI
# decision now, not a capability one.
- id: ui-plan
disabled: true

View File

@@ -17,19 +17,51 @@
"@cordisjs/plugin-include": "workspace:*",
"@cordisjs/plugin-loader": "workspace:*",
"@cordisjs/plugin-timer": "workspace:*",
"@deepseek-ai/dsh-agent-tool-mode": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-base": "workspace:^",
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-headless": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-persona": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-tmux-context": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-pwsh": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "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-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-web-app": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"commander": "^15.0.0",
"cordis": "^4.0.0-rc.7",
"js-yaml": "^4.2.0",
@@ -44,6 +76,7 @@
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@types/js-yaml": "^4.0.9",

View File

@@ -15,6 +15,7 @@ import {
type ConfigDumpLayer,
} from '@deepseek-ai/dsh-app-boot'
import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
import { resolveWindowsShellLayer } from './windows-shell.ts'
const NAME = 'dsh'
@@ -33,6 +34,12 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re
label: layer.packageName,
patches: layer.patches,
}))
// The win32 shell platform layer rides between bundles and user layers,
// exactly where the boot applies it.
const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME)
if (windowsShellLayer !== undefined) {
layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches })
}
if (!defaultOnly) {
if (existsSync(loaded.patchPath)) {
layers.push({ label: loaded.patchPath, patches: loaded.patches })

View File

@@ -5,54 +5,73 @@ export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
/** Process-exit controller shared by normal completion and Unix signal handlers. */
export interface ProcessShutdown {
/** Start or join graceful disposal before exiting with `code`. */
/** Start or join graceful disposal before allowing natural completion with `code`. */
shutdown(code: number): Promise<void>
/** Start graceful disposal, or force exit when a shutdown is already running. */
/** Start graceful disposal followed by exit, or force exit when shutdown is already running. */
interrupt(code: number): void
}
/**
* Create one process-exit controller around an application disposer.
* @param dispose - Whole-application teardown that resolves at quiescence.
* @param exit - Process exit boundary, replaceable by tests.
* @param forceExit - Forced process exit boundary, replaceable by tests.
* @param complete - Natural process completion boundary, replaceable by tests.
* @param timeoutMs - Grace before forced exit, replaceable by tests.
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
*/
export function createProcessShutdown(
dispose: () => Promise<void>,
exit: (code: number) => void = (code) => { process.exit(code) },
forceExit: (code: number) => void = (code) => { process.exit(code) },
complete: (code: number) => void = (code) => { process.exitCode = code },
timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
): ProcessShutdown {
let pending: Promise<void> | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
let exited = false
let completed = false
let forceExited = false
const exitOnce = (code: number): void => {
if (exited) return
exited = true
const clearExitTimeout = (): void => {
/* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
if (timeout !== undefined) clearTimeout(timeout)
exit(code)
}
const shutdown = (code: number): Promise<void> => {
const forceExitOnce = (code: number): void => {
if (forceExited) return
forceExited = true
clearExitTimeout()
forceExit(code)
}
const completeOnce = (code: number): void => {
if (completed || forceExited) return
completed = true
clearExitTimeout()
complete(code)
}
const start = (code: number, forceAfterDispose: boolean): Promise<void> => {
if (pending !== undefined) return pending
timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
timeout = setTimeout(() => { forceExitOnce(code) }, timeoutMs)
pending = Promise.resolve().then(dispose).then(
() => { exitOnce(code) },
() => { exitOnce(code) },
() => {
if (forceAfterDispose) forceExitOnce(code)
else completeOnce(code)
},
() => { forceExitOnce(code) },
)
return pending
}
return {
shutdown,
shutdown(code) {
return start(code, false)
},
interrupt(code) {
if (pending !== undefined) {
exitOnce(code)
forceExitOnce(code)
return
}
void shutdown(code)
void start(code, true)
},
}
}

View File

@@ -10,8 +10,9 @@
import { writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import { FiberState, type Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { dshHomePath } from '@deepseek-ai/dsh-paths'
import {
boot,
composeEntries,
@@ -25,9 +26,16 @@ import {
type Profile,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
/** Harness-home directory holding locally authored agent presets. */
const USER_PRESET_DIR = '.agent-presets'
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
import { resolveWindowsShellLayer } from './windows-shell.ts'
const NAME = 'dsh'
@@ -104,6 +112,8 @@ interface ComposedProfile {
profile: Profile
/** Bundle layers concatenated — the part below the user layers on a live reload. */
bundlePatches: PatchOptions[]
/** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */
windowsShellPatches: PatchOptions[]
/** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
homePatches: PatchOptions[]
/** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */
@@ -118,12 +128,19 @@ interface ComposedProfile {
/** The full patch stack of one composed profile, in application order. */
function allPatches(composed: ComposedProfile): PatchOptions[] {
return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags]
return [
...composed.bundlePatches,
...composed.windowsShellPatches,
...composed.profile.patches,
...composed.homePatches,
...composed.overlayAndFlags,
]
}
/**
* Load `name` and compose its effective patch stack: bundle layers in
* `dsh.profile.bundles` order, the profile's user layer, the home-level user layer
* `dsh.profile.bundles` order, the win32 shell platform layer (when the host
* is Windows), the profile's user layer, the home-level user layer
* (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to
* every profile, so it outranks the per-profile layer), `--patch` overlays,
* then flag patches derived from the composed rows, then the telemetry
@@ -142,14 +159,33 @@ function composeProfile(
const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
const bundlePatches = profile.layers.flatMap(layer => layer.patches)
const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? []
const rows = new Map<string, { name?: string; config?: unknown }>()
for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) {
if (typeof row.id === 'string') rows.set(row.id, row)
}
const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)]
// The agent-preset roots are an assembly fact of every dsh launcher, not a
// patch author's choice: the shipped set sits beside this app's config and
// the user's own under the Harness home. Resolved per boot ($DSH_HOME may
// differ per run) and only patched when the composed tree actually mounts
// the roster — a one-shot `dsh run` composes agents from the same roster
// `dsh web` offers.
if (rows.has('agent-presets')) {
overlayAndFlags.push({
id: 'agent-presets',
config: {
...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
roots: [
{ path: SHIPPED_PRESET_ROOT, trust: 'system' },
{ path: dshHomePath(USER_PRESET_DIR), trust: 'user' },
],
},
})
}
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch)
return { profile, bundlePatches, homePatches, overlayAndFlags, rows }
return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows }
}
/** Options for {@link runProfile}. */
@@ -168,6 +204,11 @@ export interface RunProfileOptions {
environment: EnvironmentSnapshot
}
/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void {
if (!signal.aborted) throw error
}
/**
* Boot one profile invocation end to end and leave process lifetime to the
* mounted plugins (or to the one-shot runner when `task` is present).
@@ -196,11 +237,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
const app: { current?: Context } = {}
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
const signalShutdown = new AbortController()
const interrupt = (code: number): void => {
signalShutdown.abort()
shutdown.interrupt(code)
}
// Signals own teardown throughout the startup window, not only after boot()
// settles: an inserted front door can publish readiness before sibling rows
// finish mounting.
process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) })
process.on('SIGINT', () => { shutdown.interrupt(130) })
process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) })
process.on('SIGINT', () => { interrupt(130) })
installFailLoud(NAME, process, async () => {
await app.current?.fiber.dispose()
})
@@ -218,6 +264,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
// removing the override could never revert the row to the bundle default.
const composeLive = (): PatchOptions[] => structuredClone([
...composed.bundlePatches,
...composed.windowsShellPatches,
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
...composed.overlayAndFlags,
@@ -243,33 +290,41 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
await options.prepare?.(hostCtx, composed.rows)
})
app.current = ctx
// A surface can dispose the whole tree while startup was still in flight
// (early SIGTERM); the Loader service goes with it and there is nothing to
// keep live.
if (watchProfilePatch && ctx.get('loader') !== undefined) {
// Config-only HMR for the live profile patch layer: the web bundle
// disables the shared module-reload `hmr` row (its reload lifecycle is
// untested), so when the composition leaves no HMR service, mount a
// watch-only instance with no module roots — cordis.patch.yml edits stay
// live on every long-lived surface. A silent skip would break the
// documented hot-reload contract. HMR injects the timer service, which a
// bare custom profile may not mount either.
if (ctx.get('hmr') === undefined) {
if (ctx.get('timer') === undefined) {
await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
// A surface can dispose the whole tree while startup or this post-boot
// watcher setup is still in flight. Loader presence and fiber state own
// liveness; the local signal fact distinguishes that expected exit race
// from a real HMR error.
if (watchProfilePatch
&& !signalShutdown.signal.aborted
&& ctx.fiber.state === FiberState.ACTIVE
&& ctx.get('loader') !== undefined) {
try {
// Config-only HMR for the live profile patch layer: the web bundle
// disables the shared module-reload `hmr` row (its reload lifecycle is
// untested), so when the composition leaves no HMR service, mount a
// watch-only instance with no module roots — cordis.patch.yml edits stay
// live on every long-lived surface. A silent skip would break the
// documented hot-reload contract. HMR injects the timer service, which a
// bare custom profile may not mount either.
if (ctx.get('hmr') === undefined) {
if (ctx.get('timer') === undefined) {
await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
}
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
}
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
await watchUserPatches(ctx, {
binName: NAME,
filename: composed.profile.patchPath,
compose: composeLive,
})
await watchUserPatches(ctx, {
binName: NAME,
filename: homePatchPath(),
compose: composeLive,
})
} catch (error) {
suppressSignalShutdownError(signalShutdown.signal, error)
}
await watchUserPatches(ctx, {
binName: NAME,
filename: composed.profile.patchPath,
compose: composeLive,
})
await watchUserPatches(ctx, {
binName: NAME,
filename: homePatchPath(),
compose: composeLive,
})
}
return { ctx, shutdown }
}

View File

@@ -97,6 +97,9 @@ function deriveWebFlagPatches(
// inserts the client-hmr row), never pass-throughs of composed values.
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
put('web-runtime', 'lanAddresses', lanAddresses)
// The agent-preset roots are patched by the shared profile boot: they are
// an assembly fact of every dsh launcher, and `dsh run` composes agents
// from the same roster this alias offers.
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
const composed = rows.get(id)
if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`)

View File

@@ -0,0 +1,52 @@
/**
* The Windows shell platform layer: on win32 hosts the shipped profile
* compositions swap the POSIX-only bash stack for the sandbox-confined
* PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` +
* `@deepseek-ai/dsh-tool-pwsh`). The layer is the base bundle's
* `windows.cordis.patch.yml`, injected by the launcher between the bundle
* layers and the user layers so a user patch can still override it — the
* only override channel is composition config, like every other roster
* decision. POSIX hosts never receive the layer.
* @module @deepseek-ai/dsh/windows-shell
*/
import { join } from 'node:path'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot'
/** The base bundle whose package carries the Windows shell patch. */
export const BASE_BUNDLE = '@deepseek-ai/dsh-base'
/** The Windows shell patch filename inside the base bundle package. */
export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml'
/** One Windows shell platform layer: its patch file and parsed patches. */
export interface WindowsShellLayer {
/** The patch file path, used as the config-dump provenance label. */
label: string
/** The parsed patch entries, applied after the bundle layers. */
patches: PatchOptions[]
}
/**
* Resolve the Windows shell platform layer for a profile composition.
* @param platform - the host platform (`process.platform` at call sites).
* @param layers - the profile's bundle layers, in application order.
* @param binName - the diagnostic prefix on thrown errors (`dsh`).
* @returns the pwsh layer on win32, else `undefined`. A custom profile that
* mounts no base bundle is skipped (it owns its shell stack); a base
* bundle whose Windows shell patch is missing fails loud in
* {@link loadOverlayPatches} — the shipped package always carries it, so
* a miss is a broken installation.
*/
export function resolveWindowsShellLayer(
platform: NodeJS.Platform,
layers: readonly ProfileLayer[],
binName: string,
): WindowsShellLayer | undefined {
if (platform !== 'win32') return undefined
const base = layers.find(layer => layer.packageName === BASE_BUNDLE)
if (base === undefined) return undefined
const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME)
return { label, patches: loadOverlayPatches(binName, label) }
}

View File

@@ -49,6 +49,7 @@ interface ProfileLifecycleFixture {
ready: string
settled: string
disposed: string
interrupt: string
}
/**
@@ -61,16 +62,23 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
const ready = join(home, 'ready')
const settled = join(home, 'settled')
const disposed = join(home, 'disposed')
const interrupt = join(home, 'interrupt')
const bundleDir = join(home, 'lifecycle-bundle')
mkdirSync(bundleDir, { recursive: true })
writeFileSync(join(bundleDir, 'plugin.mjs'), [
"import { writeFileSync } from 'node:fs'",
"import { existsSync, writeFileSync } from 'node:fs'",
"import { join } from 'node:path'",
"export const name = 'profile-lifecycle-fixture'",
'export function apply(ctx, config = {}) {',
' let active = true',
' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
' const heartbeat = setInterval(() => {}, 1000)',
' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
' let interrupted = false',
' const heartbeat = setInterval(() => {',
' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
' interrupted = true',
" process.emit('SIGTERM')",
' }, 20)',
' // Echo the mounted generation so the hot-reload e2e can assert both an',
' // applied override and its removal reverting to this bundle default.',
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
@@ -118,7 +126,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
}
return { home, ready, settled, disposed }
return { home, ready, settled, disposed, interrupt }
}
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
@@ -131,10 +139,22 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
RAW_READY_FILE: fixture.ready,
RAW_SETTLED_FILE: fixture.settled,
RAW_DISPOSED_FILE: fixture.disposed,
RAW_INTERRUPT_FILE: fixture.interrupt,
},
})
}
function requestProfileShutdown(
child: ReturnType<typeof startProfileLifecycle>,
fixture: ProfileLifecycleFixture,
): void {
if (process.platform === 'win32') {
writeFileSync(fixture.interrupt, 'interrupt')
return
}
child.kill('SIGTERM')
}
function createEnvironmentProbeProfile(home: string, project: string): void {
const pluginFile = join(project, 'environment-probe.mjs')
writeFileSync(pluginFile, [
@@ -152,7 +172,8 @@ function createEnvironmentProbeProfile(home: string, project: string): void {
" if (chunk.type === 'text-delta') text += chunk.text",
' }',
' process.stdout.write(`${text}\\n`)',
" process.kill(process.pid, 'SIGTERM')",
" if (process.platform === 'win32') process.emit('SIGTERM')",
" else process.kill(process.pid, 'SIGTERM')",
' })',
'}',
'',
@@ -321,9 +342,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
const child = startProfileLifecycle(fixture)
try {
await waitForFile(fixture.ready)
child.kill('SIGTERM')
requestProfileShutdown(child, fixture)
const result = await child
expect(result.exitCode).toBe(0)
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
expect(result.signal).toBeUndefined()
expect(existsSync(fixture.disposed)).toBe(true)
} finally {
@@ -373,9 +394,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
].join('\n'))
await waitForFile(fixture.ready)
expect(readFileSync(configFile, 'utf8')).toBe('home')
child.kill('SIGTERM')
requestProfileShutdown(child, fixture)
const result = await child
expect(result.exitCode).toBe(0)
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
expect(result.signal).toBeUndefined()
expect(existsSync(fixture.disposed)).toBe(true)
} finally {

View File

@@ -20,35 +20,50 @@ afterEach(() => {
})
describe('process shutdown', () => {
it('exits once after graceful disposal resolves or rejects', async () => {
it('completes naturally after disposal resolves and forces exit when it rejects', async () => {
const resolvedExit = vi.fn()
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
const resolvedComplete = vi.fn()
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit, resolvedComplete)
await resolved.shutdown(0)
expect(resolvedExit).toHaveBeenCalledOnce()
expect(resolvedExit).toHaveBeenCalledWith(0)
expect(resolvedComplete).toHaveBeenCalledOnce()
expect(resolvedComplete).toHaveBeenCalledWith(0)
expect(resolvedExit).not.toHaveBeenCalled()
const rejectedExit = vi.fn()
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
const rejectedComplete = vi.fn()
const rejected = createProcessShutdown(
() => Promise.reject(new Error('dispose failed')),
rejectedExit,
rejectedComplete,
)
await rejected.shutdown(1)
expect(rejectedExit).toHaveBeenCalledOnce()
expect(rejectedExit).toHaveBeenCalledWith(1)
expect(rejectedComplete).not.toHaveBeenCalled()
})
it('uses process.exit as the default process boundary', async () => {
it('uses process.exitCode for default normal completion', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
const originalExitCode = process.exitCode
process.exitCode = undefined
const shutdown = createProcessShutdown(() => Promise.resolve())
await shutdown.shutdown(7)
try {
await shutdown.shutdown(7)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(7)
expect(process.exitCode).toBe(7)
expect(exit).not.toHaveBeenCalled()
} finally {
process.exitCode = originalExitCode
}
})
it('forces exit when graceful disposal reaches its bound', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
@@ -60,13 +75,14 @@ describe('process shutdown', () => {
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
expect(complete).not.toHaveBeenCalled()
})
it('honors a caller-supplied grace period', async () => {
vi.useFakeTimers()
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
const shutdown = createProcessShutdown(() => disposal.promise, exit, vi.fn(), 25)
const pending = shutdown.shutdown(0)
await vi.advanceTimersByTimeAsync(24)
@@ -81,7 +97,8 @@ describe('process shutdown', () => {
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
const pending = shutdown.shutdown(0)
shutdown.interrupt(130)
@@ -91,13 +108,29 @@ describe('process shutdown', () => {
disposal.resolve()
await pending
expect(exit).toHaveBeenCalledOnce()
expect(complete).not.toHaveBeenCalled()
})
it('forces exit after disposal started by a signal', async () => {
const disposal = deferred()
const exit = vi.fn()
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
shutdown.interrupt(143)
disposal.resolve()
await shutdown.shutdown(0)
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(143)
expect(complete).not.toHaveBeenCalled()
})
it('drains on the first signal and forces on the second signal', async () => {
const disposal = deferred()
const dispose = vi.fn(() => disposal.promise)
const exit = vi.fn()
const shutdown = createProcessShutdown(dispose, exit)
const shutdown = createProcessShutdown(dispose, exit, vi.fn())
shutdown.interrupt(143)
await Promise.resolve()
@@ -116,7 +149,8 @@ describe('process shutdown', () => {
it('coalesces normal shutdown calls without treating them as escalation', async () => {
const disposal = deferred()
const exit = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit)
const complete = vi.fn()
const shutdown = createProcessShutdown(() => disposal.promise, exit, complete)
const first = shutdown.shutdown(0)
const second = shutdown.shutdown(1)
@@ -125,7 +159,21 @@ describe('process shutdown', () => {
disposal.resolve()
await first
expect(complete).toHaveBeenCalledOnce()
expect(complete).toHaveBeenCalledWith(0)
expect(exit).not.toHaveBeenCalled()
})
it('lets a signal force exit while natural completion drains remaining handles', async () => {
const exit = vi.fn()
const complete = vi.fn()
const shutdown = createProcessShutdown(() => Promise.resolve(), exit, complete)
await shutdown.shutdown(0)
shutdown.interrupt(130)
expect(complete).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledOnce()
expect(exit).toHaveBeenCalledWith(0)
expect(exit).toHaveBeenCalledWith(130)
})
})

View File

@@ -0,0 +1,547 @@
import { randomUUID } from 'node:crypto'
import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { Context } from 'cordis'
import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { beforeAll, describe, expect, it } from 'vitest'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
import { CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-tools'
const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url))
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */
const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
/** The installation anchor whose dependency surface the preset module fallback mirrors. */
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
/**
* Boot the shipped Web composition, minus the rows that would bind a port,
* touch the network, or write outside the test. Everything that decides an
* agent's capabilities is the real thing, including both shipped presets.
*/
async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise<Context> {
const storageRoot = join(dirname(settingsFile), 'storages')
const patches: PatchOptions[] = [
...loadOverlayPatches('dsh-test', BASE_PATCH),
...loadOverlayPatches('dsh-test', WEB_PATCH),
// The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it
// reads the developer's own document — and since the default preset is a
// setting, a stored `agent-presets.default` would decide this file's
// outcome. Point it at a temp file for the same reason the roster below
// names only the shipped root.
{ id: 'settings', config: { path: settingsFile, watch: false } },
// storage-json's root is anchored to the real $DSH_HOME. Unpinned, this
// file writes the developer's own `~/.dsh/storages/` — and then reads it
// back on the next run, so a stored document from any other build decides
// this test's boot. Same reason the settings row above is pinned.
{ id: 'storage-json', config: { root: storageRoot } },
// Host rows with side effects outside this process: a bound port, a served
// asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
// stay ENABLED on purpose — the api-proxy is the host row that injects
// `subagents`, `workspace`, and the rest of the agent plane, so disabling
// it would hide exactly the breakage this file exists to catch: a service
// moved into the presets that a host row still waits for. The boot audit
// is that assertion.
{ id: 'webserver', disabled: true },
// The web bundle's runtime row injects `httpServer`, so it cannot
// activate without the bound port disabled above. It owns dist serving
// and the URL prompt line — surface glue, not anything that decides an
// agent's capabilities, which is all this file asserts.
{ id: 'web-runtime', disabled: true },
{ id: 'telemetry-otel', disabled: true },
// A deployment-level skill on the host registry's GLOBAL layer — the same
// registration shape a repository plugin's skill root uses. The layered
// skills test below proves it reaches preset-composed agents.
{ id: 'skill-badge', disabled: false },
{ id: 'modules', disabled: true },
{ id: 'connection', disabled: true },
// The shipped `-auto` chooser resolves its interaction from a running
// host and so waits for the webserver disabled above; the browse variant
// supplies `directoryPicker` without one.
{ id: 'directory-picker', disabled: true },
{ insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
// The roster AppCLIEntry would patch in; only the shipped root, so a
// developer's own `~/.dsh/.preset` cannot change this test's outcome.
// `default` here is the COMPOSITION default — the base layer the settings
// document overrides.
{
id: 'agent-presets',
config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] },
},
...extra,
]
// The surface is patch layers over an empty preset root, so the root sits
// outside this workspace and bare plugin names cannot resolve by Node's
// upward walk. The flat fallback the preset boot maintains is what makes
// them resolvable — the same mechanism, not a test-only shim.
const home = dirname(settingsFile)
healProfilesModuleFallback(INSTALL_ANCHOR, home)
const profileDir = join(home, 'profiles', 'spec')
await mkdir(profileDir, { recursive: true })
const rootConfig = join(profileDir, 'cordis.yml')
await writeFile(rootConfig, '[]\n')
return await boot('dsh-test', rootConfig, patches)
}
const toolNames = (ctx: Context, agent?: Agent): string[] =>
ctx.tools.schemas(agent).map(schema => schema.name).sort()
let ctx: Context
beforeAll(async () => {
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
await writeFile(settingsFile, '{}\n')
ctx = await bootWeb(settingsFile)
}, 120_000)
describe('the shipped Web composition', () => {
it('leaves the global tool layer empty', () => {
// Every model-facing tool belongs to a preset, `ask_user_question`
// included: a tool in the global layer reaches EVERY agent regardless of
// which preset composed it, so a two-tool benchmark surface would really
// present three. A regression here means an agent-plane row came back to
// the host composition.
expect(toolNames(ctx)).toEqual([])
})
it('supplies both shipped presets, and only those, from the system root', async () => {
const listed = await ctx.agentPresets.list()
expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard'])
expect(listed.every(preset => preset.trust === 'system')).toBe(true)
expect(ctx.agentPresets.defaultId).toBe('standard')
})
it('composes the full agent from `standard`', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-standard'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
})
try {
// The EXACT catalog, not a spot-check: an omission is this design's
// quietest failure mode, because a row that registers into the wrong
// layer mounts cleanly and simply contributes nothing. `glob`/`grep` are
// excluded for the reason the TUI composition e2e excludes them — they
// depend on ripgrep being present on the machine.
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill',
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
'workflow', 'write',
])
} finally {
await handle.dispose()
}
})
it('composes exactly two tools from `minimal`', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-minimal'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
try {
// Exactly what the preset lists — nothing arrives from the host.
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
} finally {
await handle.dispose()
}
})
it('keeps two differently composed sessions independent', async () => {
const full = await ctx.agents.create({
sessionId: SessionId('preset-both-full'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
})
const minimal = await ctx.agents.create({
sessionId: SessionId('preset-both-minimal'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
try {
expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'str_replace_editor'])
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
await minimal.dispose()
// Tearing the minimal session down leaves the full one whole.
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
expect(toolNames(ctx)).toEqual([])
} finally {
await full.dispose()
}
})
it('composes the cordis agent with its own toolset', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-cordis'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined),
})
try {
const tools = toolNames(ctx, handle.agent)
// The self-referential toolset is what distinguishes this preset.
expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
// And it keeps the standard agent's own tools rather than replacing them.
expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
// The preset's own authoring skill registers into ITS layer of the host
// registry: the cordis agent's view carries it, the global view does not.
const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)
expect(scoped).toContain('editing-cordis-compositions')
expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions')
} finally {
await handle.dispose()
}
})
it('presents `code` as Code Mode without disturbing a native session beside it', async () => {
const coded = await ctx.agents.create({
sessionId: SessionId('preset-code'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'code').then(() => undefined),
})
const native = await ctx.agents.create({
sessionId: SessionId('preset-code-native'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
})
try {
// One tool reaches the MODEL: the transport. The registry's catalog for
// this agent is unchanged — a code mode collapses the presentation, not
// the capabilities — so the assembly is what carries the claim.
const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
expect(toolNames(ctx, coded.agent)).toContain('str_replace_editor')
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('str_replace_editor')
expect(sdk).toContain('web_search')
// The presentation is this agent's alone: the deployment default is
// native, and the session composed from `standard` still sees it.
const nativeAssembly = await ctx.systemPrompt.assemble({ scope: native.agent })
expect(nativeAssembly.tools.map(tool => tool.name)).toContain('bash')
expect(nativeAssembly.tools.map(tool => tool.name)).not.toContain('run_code')
expect(nativeAssembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
} finally {
await native.dispose()
await coded.dispose()
}
})
it('keeps the self-referential toolset out of every other preset', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-no-cordis'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
})
try {
// Editing the live runtime is opt-in per session, not ambient.
expect(toolNames(ctx, handle.agent)).not.toContain('cordis_mount')
} finally {
await handle.dispose()
}
})
it('ships the composition-authoring skill inside the preset directory', async () => {
// The preset's skill root is derived from its own `baseUrl`, so the skill
// travels with the directory wherever the preset is installed.
const skill = join(
CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md',
)
expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true)
})
it('merges the global skill layer into a preset agent\'s catalog, keeping local discovery preset-side', async () => {
const proj = await mkdtemp(join(tmpdir(), 'dsh-preset-skill-proj-'))
await mkdir(join(proj, '.dsh', 'skills', 'project-proof'), { recursive: true })
await writeFile(join(proj, '.dsh', 'skills', 'project-proof', 'SKILL.md'), [
'---',
'name: project-proof',
'description: Proves the preset layer discovers project skills beside global ones.',
'---',
'',
'Project proof body.',
'',
].join('\n'))
const handle = await ctx.agents.create({
// Unique per run: the composition persists into the ambient DSH home,
// and a fixed id would collide with a log an earlier run left there.
sessionId: SessionId(`preset-skills-standard-${randomUUID()}`),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
})
try {
// The host (global) view carries the deployment-level provider alone:
// local discovery moved behind the presets with `skill-local`.
expect((await ctx.skills.list({ cwd: proj })).map(skill => skill.name)).toEqual(['dsh-badge'])
// The standard agent's view merges the global layer with its preset's
// own local discovery over the session cwd.
const scoped = (await ctx.skills.list({ cwd: proj, scope: handle.agent })).map(skill => skill.name)
expect(scoped).toContain('dsh-badge')
expect(scoped).toContain('project-proof')
// The preset's own loader tool resolves the global-layer skill.
const loaded = await ctx.tools.execute({
callId: CallId('preset-skills-load'),
name: 'skill',
arguments: { name: 'dsh-badge' },
signal: new AbortController().signal,
agent: handle.agent,
})
expect(loaded.isError).toBe(false)
expect(JSON.stringify(loaded.content)).toContain('powered by dsh')
} finally {
await handle.dispose()
}
})
it('shows a minimal agent the global layer but no loader tool', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId(`preset-skills-minimal-${randomUUID()}`),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
try {
// Layer visibility is the registry's; whether an agent can USE skills
// stays the preset's choice — minimal mounts no `tool-skill`, so its
// tool table has no loader even though the global layer is readable.
expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge')
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
} finally {
await handle.dispose()
}
})
it('never rewrites the preset file it composed from', async () => {
// The Loader persists a tree whose plugin self-disposed, and tearing an
// agent down disposes its whole subtree. Inherited, that rewrote the
// shipped composition — truncating it to `[]` the first time a session
// ended — so `PresetTree` refuses to write at all.
const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml')
const before = await readFile(path, 'utf8')
const handle = await ctx.agents.create({
sessionId: SessionId('preset-readonly'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
})
await handle.dispose()
// Slack, not a race the number has to win. The write is driven by the
// Loader's fiber-unload listener, which fires as the subtree's fibers
// settle rather than when `dispose()` resolves, and the Loader exposes no
// flush to await. A regression writes synchronously inside that listener,
// so any wait past settlement fails; a longer one only slows the test.
await new Promise(resolve => setTimeout(resolve, 50))
expect(await readFile(path, 'utf8')).toBe(before)
})
it('gives each session its own persona', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-persona'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
try {
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text)
.toContain('You are a coding agent powered by')
} finally {
await handle.dispose()
}
})
})
describe('a switch survives the session', () => {
it('records the choice so the log states what the agent runs', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-switch-logged'),
meta: { agentPreset: 'standard' },
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
})
try {
// The api-proxy's select does exactly this pair while the session is blank.
await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')
handle.agent.session.append('agent-preset/selected', { agentPreset: 'minimal' })
// The header keeps the creation fact; the log carries what it runs.
expect(handle.agent.session.header.agentPreset).toBe('standard')
expect(resolveSessionPreset(handle.agent.session)).toBe('minimal')
} finally {
await handle.dispose()
}
})
it('rebuilds a switched session from the log, not the creation header', () => {
// The exact shape a resume reads back from disk: the header says standard,
// the log records the switch the user made while the session was blank.
const rebuilt = resolveSessionPreset({
header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' },
events: [
{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } },
{ type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } },
] as never,
})
// Reading the header alone would compose the creation-time preset over a
// history another one produced — the replay the blank-only lock prevents.
expect(rebuilt).toBe('minimal')
})
})
describe('a forked session', () => {
it('inherits the composition its seeded history was produced under', async () => {
const parent = await ctx.agents.create({
sessionId: SessionId('preset-fork-parent'),
meta: { agentPreset: 'minimal' },
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
const inherited = resolveSessionPreset(parent.agent.session)
const child = await ctx.agents.create({
sessionId: SessionId('preset-fork-child'),
meta: {
parentSession: SessionId('preset-fork-parent'),
seedLength: 0,
...inherited === undefined ? {} : { agentPreset: inherited },
},
setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined),
})
try {
// Composing nothing would leave the child empty: this layer moved every
// model-facing row out of the host plane, so there is nothing to inherit
// for free any more.
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
expect(toolNames(ctx, child.agent).length).toBeGreaterThan(0)
} finally {
await child.dispose()
await parent.dispose()
}
})
})
describe('authoring a preset on the shipped composition', () => {
let authorCtx: Context
let userRoot: string
beforeAll(async () => {
userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'profiles')
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml')
await writeFile(settingsFile, '{}\n')
authorCtx = await bootWeb(settingsFile, [{
id: 'agent-presets',
config: {
default: 'standard',
roots: [
{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
// The root does not exist yet: a deployment whose user has authored
// nothing is the normal first-run state.
{ path: userRoot, trust: 'user' },
],
},
}])
})
it('refuses to copy over or delete a shipped preset', async () => {
await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/)
await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/)
})
it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => {
// The id becomes a directory name under the user root, so containment is
// checked on the id rather than on the joined path afterwards.
await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow()
})
it('copies a shipped preset a session then really composes from', async () => {
await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式')
// Round-trips through the roster as a `user` row carrying the given name
// and the source's description, over the source's own composition text.
const preset = await authorCtx.agentPresets.resolve('my-agent')
const source = await authorCtx.agentPresets.resolve('minimal')
expect(preset.trust).toBe('user')
expect(preset.name).toBe('我的模式')
expect(preset.description).toBe(source.description)
expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal'))
// Owner-only, in an owner-only directory: a composition is executable
// configuration on a machine that may have other users.
expect((await stat(preset.path)).mode & 0o777).toBe(0o600)
const handle = await authorCtx.agents.create({
sessionId: SessionId('preset-authored'),
setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined),
})
try {
// The same tools the shipped `minimal` composes, from a directory copied
// through the service into a root outside the installed harness.
expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
} finally {
await handle.dispose()
}
})
it('deletes what it copied', async () => {
await authorCtx.agentPresets.copy('minimal', 'doomed')
await authorCtx.agentPresets.remove('doomed')
expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed')
})
})
/**
* Which preset an unnamed session gets is a user setting layered over the
* composition's own default. The package suite proves the layering against a
* hand-built context; this proves it through the shipped `cordis.yml` — that
* the roster and the settings provider are actually wired to each other, and
* that the id the setting names is the one a session composes from.
*/
describe('the default preset as a user setting', () => {
it('composes an unnamed session from the stored default, not the composed one', async () => {
expect(ctx.agentPresets.defaultId).toBe('standard')
await ctx.settings.update(settingsNamespace(SETTINGS_NAMESPACE), { default: 'minimal' })
try {
expect(ctx.agentPresets.defaultId).toBe('minimal')
const handle = await ctx.agents.create({
sessionId: SessionId('preset-user-default'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
try {
// `mount()` with no id resolves the effective default. Two tools, not
// `standard`'s catalog: the setting decided the composition.
expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
} finally {
await handle.dispose()
}
} finally {
// The context is shared with the rest of the file. `replace({})` drops
// the user section wholesale so the field re-inherits the composition
// base; `update` merges, and would leave the override standing.
await ctx.settings.replace(settingsNamespace(SETTINGS_NAMESPACE), {})
}
expect(ctx.agentPresets.defaultId).toBe('standard')
})
})
describe('a session keeps the preset it was created with', () => {
it('refuses to adopt a live session under a different preset', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-locked'),
meta: { agentPreset: 'minimal' },
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
try {
// The api-proxy guard reads exactly this: the header records what the
// session runs, so naming anything else is a caller error rather than a
// switch. Its history was produced under `minimal`'s two tools.
expect(handle.agent.session.header.agentPreset).toBe('minimal')
} finally {
await handle.dispose()
}
})
})

View File

@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot'
import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot'
import {
BASE_BUNDLE,
resolveWindowsShellLayer,
WINDOWS_SHELL_PATCH_FILENAME,
} from '../src/windows-shell.ts'
const WINDOWS_PATCH = `- id: bash-sandbox
disabled: true
- insert:
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
`
/** One fake bundle layer rooted in a temp directory. */
function fakeLayer(packageName: string, dir: string): ProfileLayer {
return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] }
}
/** A base bundle layer whose package carries the Windows shell patch. */
function baseLayerWithPatch(dir: string): ProfileLayer {
writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH)
return fakeLayer(BASE_BUNDLE, dir)
}
describe('resolveWindowsShellLayer', () => {
let base: string
afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) })
const tempBase = (): string => {
base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-'))
return base
}
it('never applies on POSIX hosts', () => {
expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined()
})
it('defaults Windows hosts to the pwsh platform layer', () => {
const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh')
expect(layer).toBeDefined()
expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true)
expect(layer?.patches).toEqual([
{ id: 'bash-sandbox', disabled: true },
{ insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] },
])
})
it('skips custom profiles without a base bundle', () => {
const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase())
expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined()
})
it('fails loud when the base bundle ships no Windows shell patch', () => {
const base = tempBase()
mkdirSync(base, { recursive: true })
// The overlay loader owns the fail-loud contract: the caller named this
// file, so its absence is a misconfiguration, not "no overlay".
expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh'))
.toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/)
})
})
describe('the shipped Windows composition (real bundle layers)', () => {
let home: string
afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) })
// The app installation anchor, mirroring profile-boot.ts: the bundle layers
// resolve from the REAL dsh-base/dsh-web-app packages through it, so this
// suite composes the shipped patch files, not test fixtures.
const anchor = fileURLToPath(new URL('../package.json', import.meta.url))
it('composes the win32 confined roster through the real patch layers', () => {
home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
const profile = loadProfile('dsh', 'web', anchor, home)
const warnings: string[] = []
const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh')
expect(win32).toBeDefined()
const rows = composeEntries(
[...profile.layers.map(layer => layer.patches), win32!.patches],
message => warnings.push(message),
)
const byId = new Map(rows.map(row => [row.id, row]))
// Only the POSIX bash stack leaves the roster: the permission surface
// (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled
// exactly as on POSIX — the confined pwsh executor is what changes.
for (const id of ['bash-sandbox', 'tool-bash']) {
expect(byId.get(id)?.disabled, `row ${id}`).toBe(true)
}
for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) {
expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true)
}
for (const id of ['pwsh-sandbox', 'tool-pwsh']) {
expect(byId.has(id), `inserted row ${id}`).toBe(true)
}
// The launcher's cold-start module fallback BFS-links the apps/cli
// dependency closure into the profile's node_modules (the pwsh-local
// precedent), so every inserted bare plugin must resolve from there.
const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record<string, string> }
for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) {
expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined()
}
// The patch touches only base-owned rows plus inserts, so the full web
// profile composes without any no-match warning.
expect(warnings).toEqual([])
})
it('leaves POSIX untouched and base-only profiles compose without warnings', () => {
home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-'))
initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'])
const profile = loadProfile('dsh', 'web', anchor, home)
// POSIX: no platform layer, the bash stack stays enabled.
const posixRows = composeEntries(profile.layers.map(layer => layer.patches))
const posixById = new Map(posixRows.map(row => [row.id, row]))
expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true)
expect(posixById.has('pwsh-local')).toBe(false)
expect(posixById.has('pwsh-sandbox')).toBe(false)
// A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the
// patch touches only base-owned rows (bash-sandbox/tool-bash) plus its
// inserts, so the composition produces no no-match warning.
initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base'])
const baseOnly = loadProfile('dsh', 'base-only', anchor, home)
const baseWarnings: string[] = []
const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh')
expect(win32).toBeDefined()
composeEntries(
[...baseOnly.layers.map(layer => layer.patches), win32!.patches],
message => baseWarnings.push(message),
)
expect(baseWarnings).toEqual([])
})
})

View File

@@ -23,6 +23,7 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@cordisjs/plugin-group": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",

View File

@@ -0,0 +1,272 @@
// Web e2e scenario: the agent-preset settings section as copy-only authoring.
// The browser never edits composition text — a shipped preset opens in a
// read-only viewer, the copy dialog collects an id and an optional display
// name, and the host copies the whole directory. The section's other job is
// getting the user TO the files: this lane pins `nativeOpen: false` (see the
// overlay), so the location affordance answers the preset directory as text —
// the deterministic branch a golden can hold on every platform.
//
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { Locator } from 'playwright'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url))
const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md')
const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md')
const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md')
/** The shipped roster, beside the composition that names it. */
const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url))
const MODE = webSnapshotMode()
describe('web e2e: agent-preset authoring is a host-side copy', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let userRoot: string
/** The settings dialog, opened on the Agent-presets section. */
function settingsDialog(): Locator {
return page.getByRole('dialog', { name: '设置' })
}
/** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */
function withPresetRoot(snapshot: string): string {
return snapshot.split(userRoot).join('{{presetRoot}}')
}
beforeAll(async () => {
userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
scaffold = await launchWebScaffold({
extraOverlayPath: OVERLAY,
agentPresets: {
roots: [
{ path: SHIPPED_PRESETS, trust: 'system' },
{ path: userRoot, trust: 'user' },
],
default: 'standard',
},
})
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('offers the roster with copy as the only way to create', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section'))
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = settingsDialog()
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
// The intro carries the guidance a create button used to imply, and the
// shipped rows offer view/copy but never delete or a location — their
// install is overwritten by upgrades and is not the user's to manage.
expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建')
expect(snapshot).not.toContain('新建预设')
expect(snapshot).toContain('查看: 标准模式')
expect(snapshot).not.toContain('删除: 标准模式')
expect(snapshot).not.toContain('打开目录')
}, 60_000)
it('views a shipped composition read-only instead of editing it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '查看: 标准模式' }).click()
const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' })
await viewer.waitFor({ timeout: 10_000 })
// The real shipped composition, not a golden: the viewer shows whatever
// the deployment ships, and this lane only asserts it is shown read-only.
const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8')
expect(await viewer.locator('pre').textContent()).toBe(shipped)
expect(await viewer.getByRole('textbox').count()).toBe(0)
// The header X and the footer button share the 关闭 name; the footer one
// is last in the dialog.
await viewer.getByRole('button', { name: '关闭' }).last().click()
await viewer.waitFor({ state: 'detached', timeout: 10_000 })
}, 60_000)
it('copies 极简模式 whole under a new id and lands in its files', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
await copyDialog.waitFor({ timeout: 10_000 })
const dialogSnapshot = await captureStableAria(
page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE)
// Two fields and nothing else: the id is the directory name the host
// needs up front; description and composition live in the files.
expect(dialogSnapshot).toContain('标识符')
expect(dialogSnapshot).not.toContain('描述')
await copyDialog.getByPlaceholder('my-agent').fill('my-agent')
await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式')
await copyDialog.getByRole('button', { name: '创建' }).click()
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
// The new row lands in the custom group, and — with no desktop opener —
// its directory is revealed as text right away: landing in the files is
// the completion of a copy, not a follow-up.
await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 })
await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 })
// The copy dialog is detached, so the settings dialog is the only one
// left (it names itself via aria-labelledby, which a CSS attribute
// selector cannot address).
const snapshot = withPresetRoot(
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('{{presetRoot}}/my-agent')
// The host copied the whole directory and rewrote only the display
// metadata: the composition is byte-identical to the shipped source, the
// description rides along for the user to edit in place, and neither the
// source's name nor its roster order survives into the copy.
const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8')
expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
expect(metadata).toContain('name: 我的模式')
expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。')
expect(metadata).not.toContain('order:')
}, 60_000)
it('deletes the copy after confirmation and reclaims the roster', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '删除: 我的模式' }).click()
const confirm = page.getByRole('dialog', { name: '删除该预设?' })
await confirm.waitFor({ timeout: 10_000 })
await confirm.getByRole('button', { name: '删除', exact: true }).click()
await confirm.waitFor({ state: 'detached', timeout: 10_000 })
await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
// Custom group gone with its only member; the shipped set stands.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
}, 60_000)
it('marks damaged presets broken and clears a ghost through delete', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged'))
// The two hand-edit damage shapes: a composition that no longer parses,
// and a directory whose composition file was deleted outright.
await mkdir(join(userRoot, 'broken-yaml'), { recursive: true })
await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n name: [unclosed\n')
await mkdir(join(userRoot, 'ghost'), { recursive: true })
await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n')
// The section reads the roster when it mounts; hop away and back.
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '通用设置' }).click()
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 })
const snapshot = withPresetRoot(
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
// Both damage shapes surface as marked, unselectable, uncopyable cards
// that still carry their metadata and the discovery-reported reason.
expect(snapshot).toContain('已损坏: broken-yaml')
expect(snapshot).toContain('已损坏: 幽灵预设')
expect(snapshot).toContain('not valid YAML')
expect(snapshot).toContain('agent.cordis.yml is missing')
expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true)
expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
// A broken card offers no "set default" affordance at all — the aria name
// IS the broken marking, so the picking name must not exist.
expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0)
// The ghost's way out is the card's own delete — and the id it blocked
// is claimable again immediately afterwards.
await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click()
const confirm = page.getByRole('dialog', { name: '删除该预设?' })
await confirm.waitFor({ timeout: 10_000 })
await confirm.getByRole('button', { name: '删除', exact: true }).click()
await confirm.waitFor({ state: 'detached', timeout: 10_000 })
await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0)
expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
await copyDialog.waitFor({ timeout: 10_000 })
await copyDialog.getByPlaceholder('my-agent').fill('ghost')
await copyDialog.getByRole('button', { name: '创建' }).click()
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 })
// Leave the roster as the earlier tests shaped it.
await dialog.getByRole('button', { name: '删除: ghost' }).click()
const cleanup = page.getByRole('dialog', { name: '删除该预设?' })
await cleanup.waitFor({ timeout: 10_000 })
await cleanup.getByRole('button', { name: '删除', exact: true }).click()
await cleanup.waitFor({ state: 'detached', timeout: 10_000 })
await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true })
}, 60_000)
it('starts a creator-mode session from the section', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator'))
// Without a workspace the flow only stages (there is no session to land
// in until one is connected); connect first so the gesture carries all
// the way to a composed host session.
await settingsDialog().getByRole('button', { name: '关闭' }).last().click()
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = settingsDialog()
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click()
// Leaving settings is part of the gesture: the flow lands on the
// new-session screen with the self-referential preset staged, and the
// blank session the flow produces composes from it on the host.
await dialog.waitFor({ state: 'detached', timeout: 10_000 })
await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 })
await expect.poll(async () => {
const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'creator-draft-stage', method: 'session.list', payload: {},
}),
})
const body = await response.json() as {
result: { value?: { sessions: unknown[] } }
}
return JSON.stringify(body.result.value?.sessions ?? body.result)
}, { timeout: 15_000 }).toContain('"agentPreset":"cordis"')
}, 60_000)
it('drove every surface without a page error or a stream warning', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})

View File

@@ -0,0 +1,12 @@
# The authoring lane drives the location affordance. A real desktop open
# would pop a file manager on the machine running the tests and the
# capability itself is platform-detected (macOS yes, headless Linux CI no),
# so the gateway is pinned headless: `hasDocument` is false everywhere and
# `openDocument` answers the directory as text — the same branch on every
# host, and the one whose rendering a golden can hold. A patch replaces the
# row's complete config, so the shipped routing defaults ride along.
- id: api-gateway
config:
provider: deepseek-official
model: deepseek-v4-flash
nativeOpen: false

View File

@@ -0,0 +1,153 @@
// Web e2e scenario: agent-preset selection. The roster's `roots` is an
// assembly fact the CLI entry resolves and patches in, so every other lane
// boots with an empty roster and no preset surface at all; this is the one
// lane that mounts the SHIPPED presets and puts them in front of a browser.
//
// Two surfaces, one host rule: a session's composition is fixed when the
// session starts. Before that, the new-session chip stages the choice beside
// the workspace picker — the only screen where it still works. After it, the
// session header names what the session runs and offers no control at all,
// because the host answers `agent-preset-locked` to anything else.
//
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection', import.meta.url))
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md')
/** The shipped roster, beside the composition that names it. */
const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'agent-preset-selection-web-e2e'
/**
* A settled one-turn session with no model content: this lane asserts chrome
* around a conversation, not a conversation, and a recorded turn would tie
* the golden to a provider's wording for no gain.
* @returns a tokenized session log ending on a closed turn.
*/
function seedLog(): string {
const time = 1784974100000
const at = (index: number, event: Record<string, unknown>): string =>
JSON.stringify({ ...event, seq: index, time: time + index })
return [
JSON.stringify({ type: 'session', version: 0, id: '{{sessionId}}', createdAt: time, cwd: '{{cwd}}/workspace' }),
at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }),
at(1, {
type: 'user/message',
data: { content: [{ type: 'text', text: 'Seeded turn.' }], source: { kind: 'user', rpcId: 'seed' } },
surfaceOp: 'append',
}),
at(2, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [1], source: { kind: 'fallback' } } }),
at(3, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
].join('\n')
}
describe('web e2e: agent-preset selection', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({
agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' },
})
// A resumed session runs what it was created with; seeding one that
// records `minimal` is what makes the header label a claim about the
// session rather than an echo of the current default.
await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('offers the chip on the new-session screen, beside the workspace picker', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero'))
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
// The chip opens on the deployment default, by the name that preset
// publishes rather than its directory name.
expect(snapshot).toContain('标准模式')
})
it('names every preset and what it is for', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-menu'))
await page.getByRole('button', { name: '标准模式' }).click()
const menu = page.getByRole('menu')
await menu.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
// Every shipped preset, each with the sentence saying what it composes —
// the id alone never said what a preset does.
expect(snapshot).toContain('极简模式')
expect(snapshot).toContain('创造模式')
await page.keyboard.press('Escape')
})
it('applies the staged pick to the blank session, and the host honors it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-stage'))
await page.getByRole('button', { name: '标准模式' }).click()
await page.getByRole('menuitem', { name: /极简模式/ }).click()
// The chip stages; the blank session the workspace connect produced is
// what the stage lands on. The host's own answer is what comes back.
await expect.poll(async () => {
const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {},
}),
})
const body = await response.json() as {
result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } }
}
return JSON.stringify(body.result.value?.sessions ?? body.result)
}, { timeout: 15_000 }).toContain('minimal')
})
it('labels a resumed session with the preset it was created under', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header'))
// The seeded session's cwd is the scaffold root rather than the connected
// workspace, so it lists under Ungrouped; the group collapses by default.
await page.getByRole('treeitem', { name: /^Ungrouped/ }).click()
await page.locator('[role="treeitem"]').last().click()
await page.getByText('Seeded turn.').waitFor({ timeout: 15_000 })
const snapshot = await captureStableAria(page, '[class*="titleRow"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('极简模式')
// Static chrome, not a control: the header can only report a composition
// the host would refuse to change.
expect(snapshot).not.toContain('button "极简模式"')
})
it('drove every surface without a page error or a stream warning', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})

Some files were not shown because too many files have changed in this diff Show More