Merge remote-tracking branch 'origin/master' into codex/fix-math-rendering

# Conflicts:
#	packages/client/ui-primitives/README.i18n.yaml
This commit is contained in:
fz
2026-08-05 20:50:50 +08:00
549 changed files with 3651 additions and 3263 deletions
@@ -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/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: d19b256b834110d3cbb540cc0e039e61c693e98c
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 79f944b74976f82d5233a8d55916eff49aa7bf86
2026-07-25-web-client-session-scope-and-provide-channel.md: 353cf35c9d6f5fa93a97fb0be60303ad6cef4d14
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: b6d1a20073e1a43414d43b830ccc8ac2b1573bb2
@@ -64,17 +64,17 @@ A session "materialized but with no first prompt" is governed by the summary-der
- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk.
- The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors).
- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals:
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility.
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
- Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank;
- Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank.
- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the one with `session.id === sessions.current`, its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's current blank shows; the user-visible surface therefore holds at most one blank row globally.
- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.
- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.
### connectWorkspace: the sole entry point of New Session
`workspaces.connectWorkspace(workspaceId): Promise<SessionId>` (owned by WorkspacesService — it holds both the workspace canonical path and the sessions reference):
- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path` (direct equality on the host realpath canonical form); a hit returns that id directly, creating nothing.
- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing.
- The create arm: on a miss, `session.create({workspaceId})` returns the new id.
- An unknown workspaceId fails loud (never silently creating somewhere else).
- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store and `sessions.binding(id)` resolves synchronously — `SessionsService.create` projects the list synchronously after RPC success before resolving, so a draft mover can write text into the new scope's machine before open, without waiting for a notifier flush.
@@ -64,17 +64,17 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判
- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 契约保证 never-appended 会话根本不进 `persistence.list()`JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。
- wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。
- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、保持 connectWorkspace 复用资格。
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`在仍为该工作区成员时保持 connectWorkspace 复用资格。
- 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank
- 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示 `session.id === sessions.current` 的一条,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。
- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
### connectWorkspaceNew Session 的唯一入口
`workspaces.connectWorkspace(workspaceId): Promise<SessionId>`(归属 WorkspacesService——它同时持有 workspace 规范 path 与 sessions 引用):
- 复用臂:list mirror 中找 `blank && cwd == workspace.path`host realpath 规范 canon 直等比较),命中直接返回该 id,不新建。
- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中直接返回该 id,不新建。
- 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。
- 未知 workspaceId fail loud(不静默创建到别处)。
- 解析保证(两臂同契约):promise resolve 时返回的 id 已在 list store 且 `sessions.binding(id)` 同步可解析——`SessionsService.create` 在 RPC 成功后同步投影列表再 resolve,使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。
@@ -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/architecture/2026-07-30-web-config-plane.md
2026-07-30-web-config-plane.md: 5970cfcea2e577998a235d08769ba497f5e8c18b
2026-07-30-web-config-plane.zh.md: dbfe1d8c8b635a607ef1c0798d972528a4712dfa
2026-07-30-web-config-plane.md: 5225460be1d66b85a05ff2fd5ae2826b0e6c41d7
2026-07-30-web-config-plane.zh.md: 53a21ddf31640d963c413e1793276de694547311
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-07-30-web-config-plane.zh.md)
> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change.
> Scope: the wire face and web UI deferred from the [request-level LLM configuration note](2026-07-29-request-level-llm-config-credentials.md) — the `settings.*`/`credentials.*`/`llm.*` RPC domains with pushed invalidations, layered+redacted `describe()`, the local settings-document handoff, the llm configurable-provider directory and topology event, the standalone `dsh-client-schema-form` model layer, and the Models settings page with its hand-written provider editor. The `deepseek` → `deepseek-official` provider-route rename rides along as the enabling breaking change.
## Problem
@@ -12,10 +12,12 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
## Decision
**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/update/replace`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept config mutation from another origin.
**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin.
**`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value.
**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-local` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The loopback-only `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on Linux, and `Invoke-Item` on Windows). Generic workspace paths retain the existing default-application handoff. The browser neither derives `$DSH_HOME` nor receives a filesystem target; remote pages make no privileged settings read for this action.
**The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias.
**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging once both land. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry this file's token spellings, not that branch's: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface.
@@ -30,7 +32,8 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
- **Storing the typed key as a literal `apiKey` setting** — the v1 "one API key input" requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe.
- **A `models` bridge plugin owning provider configuration** — same rejection as PR1: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection.
- **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed.
- **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `host.openPath` in the browser** — rejected because `settings-local.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener.
## Consequences
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential.
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential.
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-30-web-config-plane.md) | 中文
> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。
> 范围:[请求级 LLM 配置 note](2026-07-29-request-level-llm-config-credentials.md) 中延后的 wire 面与 web UI——带推送式失效的 `settings.*`/`credentials.*`/`llm.*` RPC 领域、分层且脱敏的 `describe()`、本地设置文档交接、llm 可配置提供方目录与拓扑事件、独立的 `dsh-client-schema-form` 模型层,以及带手写提供方编辑器的 Models 设置页。`deepseek` → `deepseek-official` 提供方路由重命名作为解锁前提的破坏性变更一并搭车合入。
## 问题
@@ -12,10 +12,12 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯
## 决策
**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/update/replace``credentials.describe/set/unset``llm.providers``llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}``host/credentials-changed {ref}``host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。写入 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置修改
**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/openDocument/update/replace/mutate``credentials.describe/set/unset``llm.providers``llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让契约、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}``host/credentials-changed {ref}``host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。settings 读取、原生操作与写入 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问
**`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。
**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-local` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。仅限回环访问的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;Linux 上使用 `xdg-open`Windows 上使用 `Invoke-Item`)。通用 Workspace 路径仍保留现有的默认应用交接。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;远程页面不会为这项操作发起特权 settings 读取。
**llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。
**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`deepseek 有 `reasoningEffort`pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id``name``contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border``--surface``--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是在双方都落地后各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K``M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名承载的是本文件的 token 写法,而非那个分支的:`--dsw-alias-border-subtle``--dsw-alias-text-tertiary``--dsw-alias-text-primary` 均未声明,于是它们解析为各自回退槽位中的亮色模式字面值——正是本节此前迁离的那个缺陷。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。
@@ -30,7 +32,8 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯
- **把键入的密钥存成字面 `apiKey` 设置**——v1「单个 API 密钥输入框」的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。
- **由 `models` 桥接插件持有提供方配置**——与 PR1 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。
- **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧各自只多一个形状的成本,就让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。
- **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `host.openPath` 回传 `documentPath`**——否决,因为 `settings-local.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。
## 后果
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图;seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。
@@ -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-04-composer-tab-gutter-reservation.md
2026-08-04-composer-tab-gutter-reservation.md: 3b28c35c1f11676e41cabde76d1b0d16c688f034
2026-08-04-composer-tab-gutter-reservation.zh.md: 26e8b6bff73a01e6518f3918d201330c1d029876
@@ -0,0 +1,50 @@
# Agent Note: The conversation column reserves one scrollbar gutter for every view
Status: implemented
English | [中文](2026-08-04-composer-tab-gutter-reservation.zh.md)
## Problem
The composer seat is one node in one place in the tree, and it was laid out against a different edge depending on which view tab was shown.
In Chat it is a sticky CHILD of the column's scroller (`[data-conversation-scroll]`), so it rides that scroller's content box — the box a space-consuming scrollbar shortens by the bar's width. A view that declares `data-conversation-composer-overlay`, which Trajectory does, moves the column's scrolling into the view itself: the branch keyed on that attribute left the scroller `overflow: hidden` and positioned the seat absolutely, against the padding box, which no scrollbar reduces.
So for as long as the transcript overflowed — the ordinary state of any session with history — the two tabs disagreed by exactly the bar's width. The input card is centred, so switching tabs moved it 4px sideways on an 8px bar, and its right-hand clearance changed by the full 8. The same displacement appeared inside Chat alone at the moment a growing transcript started to scroll, and again between the hero phase and the first scrolling turn.
## Decision
`.scrollBody` declares `scrollbar-gutter: stable` unconditionally, and the overlay branch declares the same box a scroll container on both axes — `overflow-x: hidden; overflow-y: auto` — instead of `overflow: hidden`.
The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it.
The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari.
The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column.
The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason.
## Alternatives considered
**Inset the overlay seat by the bar's width.** The narrow reading of the bug — the two states differ by 8px, so subtract 8px from one. Rejected because the number is the engine's, not ours: the WebKit path draws the sheet's 8px bar, the Firefox path draws whatever `scrollbar-width: thin` resolves to, and a hardcoded inset would line the two states up in Chromium while drifting everywhere else. The gutter asks the engine to reserve its own bar's width, whatever that is.
**Keep `overflow: hidden` and add `scrollbar-gutter: stable` alone.** The one-line version. It fixes the visible symptom on the engine the browser lane runs, and leaves it in place on Safari, with no test failing anywhere — the failure mode the second half of the change exists to prevent.
**Move the composer seat out of the scroller in Chat too, making the overlay geometry the only geometry.** This deletes the difference at its root rather than reconciling it, and gives up a deliberate property: the sticky seat sits inside the scroll flow, so a wheel over the composer moves the transcript ([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)), and the fade mask above it is painted by the seat's own background. Both are owned behavior with their own coverage; rebuilding them to remove 8px of asymmetry is the larger change, not the smaller one.
**Pad the column by the bar's width instead of reserving a gutter.** Padding applies whether or not a bar is present, so it costs the width unconditionally in every state, and it pins a value in the stylesheet that the engine picks at layout time. Rejected for the same reason the sidebar list rejected it.
## Consequences
- Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column.
- The fix covers three transitions with one declaration, because all three are the same difference: Chat ↔ Trajectory, short ↔ scrolling transcript within Chat, and hero ↔ first scrolling turn.
- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one.
- The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's.
## Testing
`apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish.
The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, both tabs agree before this change as much as after it, and every comparison in the file holds vacuously. Measured, the pre-fix cascade leaves both bands at 0 under the argument, and at 8 and 0 with it dropped.
The pre-fix cascade is then applied in the page — `scrollbar-gutter: auto` on the scroller, `overflow: hidden` on the overlay branch — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence.
@@ -0,0 +1,50 @@
# Agent Note: 会话列为每个视图预留同一条滚动条槽
Status: implemented
[English](2026-08-04-composer-tab-gutter-reservation.md) | 中文
## 问题
composer 座位在组件树中只有一个节点、一个位置,但它究竟对齐到哪条边,取决于当前展示的是哪个视图标签页。
在 Chat 中它是会话列滚动容器(`[data-conversation-scroll]`)的 sticky **子元素**,因而依附于该容器的 content box——而占布局宽度的滚动条会把这个盒子收窄一条滚动条的宽度。声明了 `data-conversation-composer-overlay` 的视图(Trajectory 即是其一)会把会话列的滚动搬进视图自身:以该属性为条件的那条分支把滚动容器留作 `overflow: hidden`,并把座位改为绝对定位——对齐的是 padding box,而滚动条从不收窄这个盒子。
于是只要对话记录超出一屏——任何带历史的会话的常态——两个标签页就恰好相差一条滚动条的宽度。输入卡片是居中的,因此在 8px 的滚动条下切换标签页会让它横向移动 4px,而右侧留白整整变化 8px。同一位移也出现在 Chat 内部:对话增长到开始滚动的那一刻,以及从 hero 态进入第一个可滚动轮次时。
## 决策
`.scrollBody` 无条件声明 `scrollbar-gutter: stable`,overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden`
这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而"溢出与否"恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动容器记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。
横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。
这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。
## 曾考虑的替代方案
**把 overlay 座位按滚动条宽度内缩。** 这是对该缺陷最窄的一种解读——两种状态差 8px,那就从一侧减去 8px。之所以否决,是因为这个数字属于引擎而不属于我们:WebKit 路径绘制样式表里的 8px 滚动条,Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度,硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。滚动条槽是请引擎按它自己那条滚动条的宽度去预留,无论那是多少。
**保留 `overflow: hidden`,只加 `scrollbar-gutter: stable`。** 单行版本。它能在浏览器车道所用的引擎上修掉可见症状,却把症状原封不动留在 Safari 上,而且任何测试都不会失败——这正是改动的后一半所要防的失效模式。
**让 Chat 的 composer 座位也移出滚动容器,使 overlay 的几何成为唯一的几何。** 这是从根上删掉差异,而不是调和它,代价是放弃一项刻意的性质:sticky 座位位于滚动流之内,因此在 composer 上滚轮会带动对话记录([sticky composer](2026-07-29-sticky-composer-conversation-scroll.md)),其上方的渐隐遮罩也由座位自身的背景绘制。两者都是有主、有覆盖的既有行为;为了消除 8px 的不对称而重建它们,是更大的改动而非更小的。
**给会话列加上一条滚动条宽度的内边距,而不是预留滚动条槽。** 内边距无论是否存在滚动条都会生效,因此在每种状态下都无条件付出这份宽度,而且它把一个由引擎在布局期决定的值钉死在样式表里。否决理由与侧边栏列表当初否决它时相同。
## 后果
- Chat 的内容列永久变窄 8px——hero 态与对话记录尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。
- 一条声明覆盖三种切换,因为这三者本就是同一个差异:Chat ↔ Trajectory、Chat 内部的短对话 ↔ 可滚动对话,以及 hero ↔ 第一个可滚动轮次。
- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。
- 提交的 golden 记录了预留的带宽,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。
## 测试
`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。
该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两侧带宽都是 0;去掉它则是 8 与 0。
随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把"卡片确实没动"与"标签页切换根本没到达布局"区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture 承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。
@@ -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-05-workspace-blank-session-reuse-membership.md
2026-08-05-workspace-blank-session-reuse-membership.md: 910a10e9ada1a835df7a38a04fb04c504b0921df
2026-08-05-workspace-blank-session-reuse-membership.zh.md: 7e7aa899f73955b3d34a1eff3d9fedde097a17f0
@@ -0,0 +1,29 @@
# Agent Note: Workspace New Session reuse hijacked cwd-matching unaccounted blank sessions
Status: implemented
English | [中文](2026-08-05-workspace-blank-session-reuse-membership.zh.md)
## Problem
Clicking the `+` on a Workspace group in the sidebar sometimes opened a session that the sidebar showed under Ungrouped instead of under the clicked Workspace — "entered a new session but the Workspace was not selected". The failure was specific to Workspaces registered at the directory the CLI runs from (in practice the harness checkout itself, i.e. `defaults.cwd = process.cwd()`), and appeared once a CLI-born blank session existed there.
Root cause: `connectWorkspace`'s blank-session reuse scanned the session list mirror on `cwd` equality alone. The host's own membership rule requires **both** an id in the Workspace account (`sessionIds`) **and** a session header whose canonical cwd equals the Workspace path ([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md)); a cwd match without the account slot is exactly the Ungrouped case. The reuse scan ignored the account slot, so any **live blank** session whose cwd matched qualified — including `main-session-*` sessions the CLI/TUI/headless entry points birth at the host cwd (`session.create({})` falls back to `defaults.cwd` and never attaches to a Workspace). When such a session was live and blank (no `turn/start` yet), the next `+` click on a Workspace registered at that path reused it and navigation opened a session no grouping surface can show under that Workspace. Workspaces at other paths were unaffected because no unaccounted blank sessions accumulate there; the host-cwd Workspace accumulated one per CLI run.
## Decision
The reuse scan now requires workspace membership: `blank` AND `summary.cwd === workspace.path` AND `workspace.sessionIds.includes(summary.id)` AND not archived. A cwd-only match falls through to `session.create({ workspaceId })`, which attaches the fresh session so the Workspace owns it — the same arm the flow already used for "no blank session exists".
## Alternatives considered
**Adopt the stray instead of minting.** `session.create({ workspaceId })` could attach a cwd-matching unaccounted blank session. Rejected: silently attaching CLI-born sessions to a Workspace crosses the account boundary by surprise, and the client cannot distinguish "stray" from "the Workspace's own blank" without the membership view — which is the fix itself.
**Attach on reuse via a new wire operation.** Requires a `workspace.attachSession` RPC in the navigation hot path and would still render the session under Ungrouped for a frame; no product need justifies the surface.
## Consequences
Stray blank sessions remain visible in Ungrouped (the user can still open them) but are never hijacked by a Workspace's New Session flow. Membership is a new condition on the reuse scan, and it has one observable stale-mirror edge: in the window where the session mirror is fresh but the Workspace account frame lags, the Workspace's own member blank can fail the membership check and a duplicate blank is minted where the old code reused — a second `New Session` row under that Workspace rather than the old failure shape (a session that no grouping surface shows). Both windows are transient and the per-Workspace coalescing still prevents duplicate creates racing one another. No host, wire, or durable-format change.
## Testing
`packages/client/runtime/tests/workspaces-service.spec.ts` covers the four outcomes: a member blank session is reused (no create RPC); a stray blank with matching cwd is **not** reused and a fresh accounted session is created (regression case); an archived blank is not reused; a rejected first prompt keeps a member blank eligible. The full client suite (`pnpm run test:gui`) stays green.
@@ -0,0 +1,29 @@
# Agent Note:工作区新建会话复用了 cwd 匹配但未入账的空白会话
状态:已实现
[English](2026-08-05-workspace-blank-session-reuse-membership.md) | 中文
## 问题
在侧边栏某个工作区分组的 `+` 上创建会话时,有时会进入一个新会话,但侧边栏把它显示在「未分组」而不是点击的那个工作区下——「进入了新会话,但工作区没有被选中」。故障只出现在注册在 CLI 运行目录(即 `defaults.cwd = process.cwd()`,实际场景里就是 harness 检出目录本身)上的工作区,并且一旦该目录下存在 CLI 创建的空白会话就会出现。
根因:`connectWorkspace` 的空白会话复用扫描只按 `cwd` 相等匹配会话列表镜像。host 自己的成员规则要求**同时**满足:会话 id 在工作区账户(`sessionIds`)中,**且**会话 header 的规范化 cwd 等于工作区路径([Workspace UI product flow](../feature/2026-07-25-workspace-ui-product-flow.md));只有 cwd 匹配而没有账户槽位的恰恰就是「未分组」的情形。复用扫描忽略了账户槽位,因此任何 cwd 匹配的**在线空白**会话都会被选中——包括 CLI/TUI/headless 入口在 host cwd 创建的 `main-session-*` 会话(`session.create({})` 回退到 `defaults.cwd`,从不挂到任何工作区)。当这样的会话在线且空白(尚无 `turn/start`)时,下一次在该路径注册的工作区上点击 `+` 就会复用它,导航打开的是一个任何分组表面都无法显示在该工作区下的会话。其他路径的工作区不受影响,因为那里不会积累未入账的空白会话;而 host-cwd 工作区每次 CLI 运行都会积累一个。
## 决定
复用扫描现在要求工作区成员关系:`blank``summary.cwd === workspace.path``workspace.sessionIds.includes(summary.id)` 且未归档。仅 cwd 匹配的情况落到 `session.create({ workspaceId })`,创建并挂接新会话,使工作区拥有它——这与流程中「不存在空白会话」时的既有分支完全相同。
## 曾考虑的替代方案
**收养游离会话而不是新建。**`session.create({ workspaceId })` 挂接一个 cwd 匹配但未入账的空白会话。否决:静默地把 CLI 创建的会话挂到工作区上,越过了账户边界,令人意外;而且客户端没有成员视图就无法区分「游离会话」与「工作区自己的空白会话」——而成员视图本身就是本次修复。
**复用时就地挂接,新增一条 wire 操作。** 需要在导航热路径上新增 `workspace.attachSession` RPC,并且会话仍会有一帧显示在「未分组」;没有产品需求值得新增这个表面。
## 后果
游离空白会话仍显示在「未分组」(用户仍可手动打开),但不再被某个工作区的新建会话流程劫持。成员校验是复用扫描的新增条件,有一个可观察的镜像滞后边界:在会话镜像已新而工作区账户帧滞后的窗口里,工作区自己的成员空白会话可能因成员校验失败而错过复用,多创建一个空白——表现为该工作区下出现第二个「新会话」行,与旧故障形态(打开一个任何分组表面都无法显示的会话)不同。两个窗口都是瞬态的,按工作区的合并逻辑仍然防止并发创建互相竞争。无 host、wire 或持久化格式变更。
## 测试
`packages/client/runtime/tests/workspaces-service.spec.ts` 覆盖四种结果:成员空白会话被复用(无 create RPC);cwd 匹配但非成员的游离空白会话**不被**复用、改为创建全新入账会话(回归用例);已归档空白会话不被复用;首次 prompt 被拒后成员空白会话仍可复用。完整客户端套件(`pnpm run test:gui`)保持绿色。
@@ -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-04-doc-tiers-and-budgets.md
2026-07-04-doc-tiers-and-budgets.md: a52c40a9a147fd39fdec4c61079822f1b1115227
2026-07-04-doc-tiers-and-budgets.zh.md: c26efe0e06d73ff0bd5608c7f44d54ebbe373bed
2026-07-04-doc-tiers-and-budgets.md: e7b3421d09a1ae5ab9a9373e8040832c1b0d4b97
2026-07-04-doc-tiers-and-budgets.zh.md: 18bf98777c87512e6161a4529db66030ced47bfd
@@ -1,4 +1,4 @@
# Agent Note: Documentation tiers, budgets, and the ceiling gate
# Agent Note: Documentation structure, tiers, and budgets
Status: implemented
@@ -6,13 +6,14 @@ English | [中文](2026-07-04-doc-tiers-and-budgets.zh.md)
## Problem
Standing docs accumulated repeated rules, retold incidents, duplicated package maps, and stale Agent Note summaries despite existing writing guidance. Because review alone did not prevent that growth, the repository needed a mechanical budget alongside its documentation taxonomy.
Standing docs accumulated repeated rules, retold incidents, duplicated package maps, and stale Agent Note summaries despite existing writing guidance. That guidance also did not define how a document's place in the hierarchy limits its scope or how ordered teaching differs from lookup-oriented material. Because review alone did not prevent that growth, the repository needed a mechanical budget alongside its documentation taxonomy.
## Decision
- **A tier taxonomy with one home per fact.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: it assigns every Markdown tier a single job (standing orders, system map, type catalog, decision records, incident stories, how-tos, per-package contracts, generated catalogs, workflows), forbids restating a fact outside its home tier (link instead), and carries the slop checklist used when writing or reviewing any doc.
- **Structure follows the documentation tree.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: a document owns detail about its subject, summarizes only the purpose, responsibility, and high-level behavior of direct children, and links to deeper owners. [Agent Notes](../../README.md) remain outside this structural contract. Every human-facing document is a tutorial with an ordered outcome or a reference with an explicit lookup scope; a [postmortem](../../../../docs/postmortem/README.md) is an incident-scoped reference whose chronology records evidence. Tutorials introduce concepts in prerequisite order for the reader's starting knowledge.
- **A tier taxonomy with one home per fact.** The standard assigns every Markdown tier one job, forbids restating a fact outside its home tier, and carries the slop checklist used when writing or reviewing any doc.
- **A narrow, hard budget gate.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and package READMEs are unbudgeted: length is legitimate there when every row is a fact, and review plus the slop checklist govern them.
- **Ceilings are an enforcement frontier that ratchets.** A ceiling sits at least 5% above the doc's current size — working headroom, so routine wording edits pass while real growth still trips the gate — and ratchets down, keeping that margin, as the doc is brought to its target budget (root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600; `packages/README.md` ≤ 600). When the gate goes red the fix is to relocate or condense per the taxonomy; raising a ceiling is permitted only with explicit justification in the PR description, the manifest diff being the reviewable act.
- **Ceilings are an enforcement frontier that ratchets.** A doc at or below its target keeps at least 5% headroom as its ceiling ratchets down; a doc above target keeps a frozen ceiling that prevents growth until it reaches the target (root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600 except `packages/AGENTS.md` ≤ 650 and `docs/AGENTS.md` ≤ 1,250; `packages/README.md` ≤ 600). When the gate goes red, relocate or condense; raise a ceiling only with explicit PR justification.
- **A thin workflow skill, contracts in docs.** [.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) carries the placement/audit/red-gate workflow and defers to the standard as its source of truth, the same split as [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) over the i18n contract.
## Alternatives considered
@@ -23,6 +24,7 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m
## Consequences
- Adding to a budgeted doc now requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI.
- The bring-under-target rewrites land as stacked follow-ups that ratchet the manifest down as they merge; until each lands, its doc's frozen ceiling only prevents further growth.
- Adding to a budgeted doc requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI.
- Structural review starts with ownership and document form before sentence-level editing, so lower-level detail moves to its owner instead of being polished in the wrong place.
- Budgeted docs that remain above target cannot grow; reaching the target restores the 5% working headroom.
- Word count is a crude proxy accepted deliberately: it cannot judge quality, but it forces the relocation decision at exactly the moment content is being added, which is when the author has the context to place it correctly.
@@ -1,4 +1,4 @@
# Agent Note: 文档分层、预算与上限门禁
# Agent Note: 文档结构、层级与预算
Status: implemented
@@ -6,14 +6,15 @@ Status: implemented
## 问题
尽管已有写作指导,常设文档仍不断累积重复规则、反复讲述的事、重复的包映射,以及陈旧的 Agent Note 摘要。仅靠评审无法阻止这种增长,因此仓库需要在文档分类体系之外再配一套可自动执行的预算。
尽管已有写作指导,常设文档仍不断累积重复规则、反复讲述的事、重复的包package映射,以及陈旧的 Agent Note(agent 决策记录)摘要。该指导也未明确文档在层级中的位置如何限定其内容范围,以及按顺序引导读者学习的内容与面向查阅的材料有何不同。仅靠评审无法阻止这种增长,因此仓库需要在文档分类之外再配一套机械预算。
## 决策
- **每项事实只归属一处的层级分类体系。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:它为每种 Markdown 层级分配单一职责(常设指令、系统图、类型目录、决策记录、事故叙事、操作指南、各包契约、生成的目录、工作流),禁止在事实归属层级之外重复陈述(应改为链接),并包含编写或评审任何文档时使用的赘余检查清单
- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都不得超过其词数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使文件重命名无法悄然摆脱预算约束。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md``packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md``docs/defensive-patterns.md`)。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们
- **上限是会逐步收紧的执行红线。** 上限设定为文档当前词数的至少 105%(留出工作余量,使日常措辞调整能通过,而真正的膨胀仍会触发门禁),并随着文档被精简到目标预算而同步下调、保持该余量(根 `AGENTS.md` ≤ 1,500 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600`packages/README.md` ≤ 600)。门禁变红时,修复方式是按分类体系迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才允许提高上限,manifest(元数据清单)的 diff 本身就是可供评审的变更
- **精简的工作流 skill(技能),契约归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载文档放置、审计和门禁失败处理工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 契约之间的分工相同
- **结构遵循文档树。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:文档负责承载其主题的详细内容,仅概述直接子项的目的、职责和高层行为,并链接到更深层内容的归属文档。[Agent Note](../../README.md) 仍不受这一结构契约约束。每份面向人的文档要么是按顺序引导读者达成结果的教程(tutorial),要么是查阅范围明确的参考文档(reference);[事故复盘(postmortem](../../../../docs/postmortem/README.md) 是范围限定于单个事件的参考文档,其时间线记录证据。教程结合读者的起始知识,按前置依赖顺序介绍概念
- **每项事实只归属一处的层级分类。**文档标准为每种 Markdown 层级分配单一职责,禁止在事实归属层级之外重复陈述,并包含编写或评审任何文档时使用的赘余检查清单
- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其字数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md``packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md``docs/defensive-patterns.md`)。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们
- **上限是只进不退的执行红线。** 达到或低于目标的文档在上限逐步下调时保留至少 5% 的余量;高于目标的文档则维持冻结的上限,在达到目标之前不得增长(根 `AGENTS.md` ≤ 1,600 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600,但 `packages/AGENTS.md` ≤ 650、`docs/AGENTS.md` ≤ 1,250`packages/README.md` ≤ 600)。门禁变红时,迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才提高上限
- **精简的工作流 skill(技能),契约归文档。**[.agents/skills/dsh-doc-standards](../../../skills/dsh-doc-standards/SKILL.md) 承载放置/审计/红灯门禁工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 契约之间的分工相同。
## 曾考虑的替代方案
@@ -23,6 +24,7 @@ Status: implemented
## 后果
- 向受预算约束的文档添加内容现在需要腾挪空间:将新增内容迁移到其分类体系归属地并留下链接,或压缩现有行文来腾出空间。只增不减会导致 CI 失败。
- 精简到目标预算的重写以堆叠的后续 PR 落地,每次合并时同步下调 manifest 中的上限;在各自落地之前,文档冻结的上限仅阻止进一步膨胀
- 词数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它在内容被添加的那一刻强制触发迁移决策,而那正是作者拥有足够上下文来正确放置内容的时刻
- 向受预算约束的文档添加内容需要置换:将新增内容迁移到其分类体系归属地并留下指针,或压缩现有行文来腾出空间。只增不减会导致 CI 失败。
- 结构评审先检查归属关系和文档形式,再进行句子层面的编辑,使较低层级的细节迁移到其归属文档,而不是在错误的位置加以润色
- 仍高于目标的受预算约束文档不得增长;达到目标后,将恢复 5% 的工作余量
- 字数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它在内容被添加的那一刻强制触发迁移决策,而那正是作者拥有足够上下文来正确放置内容的时刻。
+14 -6
View File
@@ -1,6 +1,6 @@
---
name: dsh-doc-standards
description: 'Use when writing, moving, reviewing, or auditing documentation in the deepseek-harness repo — choosing where content belongs, trimming doc slop, responding to a verify-doc-budgets gate failure, or requests like "improve the docs", "audit the docs for slop", "where should this be documented", "this doc is too long".'
description: 'Use when writing, moving, reviewing, or auditing documentation in the deepseek-harness repo — choosing hierarchy and detail, separating tutorials from references, checking tutorial progression, trimming doc slop, responding to a verify-doc-budgets failure, or requests like "improve the docs", "audit the docs", "where should this be documented", or "this doc is too long".'
---
# Applying the DeepSeek Harness Documentation Standard
@@ -9,24 +9,32 @@ The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow c
## Sources of truth (read, don't re-summarize)
- [docs/AGENTS.md](../../../docs/AGENTS.md) — the taxonomy ("one home per fact"), budgets, slop checklist.
- [docs/AGENTS.md](../../../docs/AGENTS.md) — hierarchy, tutorial/reference forms, taxonomy, budgets, and slop checklist.
- [.agents/notes/README.md](../../notes/README.md) — when a decision earns an Agent Note, how to file it, and what goes inside one (the header block, per-lifecycle skeleton, and Alternatives-considered mandate, gated by `verify-agent-note-format`); [docs/postmortem/README.md](../../../docs/postmortem/README.md) — when an incident earns a postmortem.
- [docs/i18n/README.md](../../../docs/i18n/README.md) — the bilingual pairing contract; editing either side of a pair obligates the counterpart in the same change.
- Root [AGENTS.md](../../../AGENTS.md) — the standing orders whose budget discipline this skill protects.
- [Archived Agent Notes](../../notes/archived/AGENTS.md) — frozen historical snapshots excluded from editorial maintenance and evolving documentation gates.
## Placing content
## Review structure before prose
Run the placement test in the standard's taxonomy table, then check the constraints that make a placement expensive or wrong:
Apply the standard's authoring order to every human-facing document in scope. Do not apply this structural pass to Agent Notes. Classify a postmortem as a reference scoped to one incident; preserve the chronological evidence required by its contract without treating chronology as a teaching sequence.
1. Locate the document in the repository and navigation trees. State its own subject and identify its direct children.
2. Set the detail boundary. Keep full detail about the document's subject, summarize direct children by purpose, responsibility, and high-level behavior, and move deeper explanations to their owning descendants with links. Treat test infrastructure as descendant-owned unless it is the document's subject.
3. Classify the document from its intended use, not its path or title. A tutorial must lead through ordered work to an observable outcome; a reference must support lookup within an explicit scope without requiring sequential reading.
4. For a tutorial, privately classify the starting reader and concepts as beginner, intermediate, or advanced. Trace each concept to its prerequisites, reorder premature material, and move optional advanced detail to a later tutorial or reference.
5. Split substantial mixed forms. Keep a small secondary form only behind a clear structural boundary.
Then check constraints that make placement expensive or wrong:
- Paired docs (`pnpm run verify-translation-pairing --list`) cost a zh counterpart update and a `--write` re-record on every edit — prefer an unpaired home for content that will churn.
- Generated catalogs are never hand-edited; if the fact belongs there, change the generator's source.
- Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown links, `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments, but nothing catches heading-anchor fragments — grep `#the-heading` across the repo yourself (one anchor is hardcoded in `scripts/gen-cordis-catalog.ts`).
- A move is atomic: remove from the old home, add to the new home, and fix every inbound link in the same change.
## Auditing the corpus
## Audit the corpus
The audit is a hunt for the standard's slop checklist, 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 repeat the audit for prose introduced by the new base rather than relying on the earlier result.
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.
+2 -2
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 README.md
README.md: 7777c35a2785856e553cb963920288ff8021dc27
README.zh.md: 85ee977063e4d822d71e1e780ab4a461260aaf53
README.md: b8e46044fb8857730b32d9fbbb9ed4de964d6017
README.zh.md: e289d523bf61a577f1dd2335b3b4567736d9100d
+3 -10
View File
@@ -8,11 +8,9 @@ It uses an architecture where **everything is a plugin**.
## Internal testing notice
感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
DeepSeek Harness is under internal testing. Features and interfaces may change.
“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。
The internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.
## Install
@@ -26,7 +24,7 @@ scripts/install.sh
The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.
The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-<timestamp>`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.
The default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.
## Use DeepSeek Harness
@@ -84,11 +82,6 @@ Follow <a href="https://x.com/Deepseekharness">DeepSeek Harness on Twitter</a> f
## Development
```sh
pnpm install
pnpm run test:coverage
```
Start with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.
For agents, follow [AGENTS.md](AGENTS.md).
+3 -10
View File
@@ -8,11 +8,9 @@ DeepSeek Harness`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源
## 内测声明
感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙
DeepSeek Harness处于内部测试阶段,功能和接口可能发生变化
“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计
为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。
为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议
## 安装
@@ -26,7 +24,7 @@ scripts/install.sh
安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。
安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项 [`scripts/install.sh`](scripts/install.sh)。
默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项 [`scripts/install.sh`](scripts/install.sh) 负责
## 使用 DeepSeek Harness
@@ -88,11 +86,6 @@ pnpm run demo:acp
## 开发
```sh
pnpm install
pnpm run test:coverage
```
请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。
面向 agent:遵循 [AGENTS.md](AGENTS.md)。
+1 -1
View File
@@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this
## Runtime npm dependencies
External packages that a workspace package resolves at runtime. `scripts/install.sh` installs this repository itself, so the tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI/TUI, the Web UI, and the Python SDK runtime load by default.
External packages that a workspace package resolves at runtime. `scripts/install.sh` installs this repository itself, so the tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default.
| Package | License |
| --- | --- |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 6fdca68eed11dffe46bf2fbde9a7899359690dca
README.zh.md: d8d7122729df1dd8aaed8207ddfb0a0470778b01
README.md: ce7af5a299e45d6f107686aff043246914dce8ed
README.zh.md: e97fec9d6bb726cb1e419a1ca2fa1871d4d203ca
+15 -66
View File
@@ -2,75 +2,24 @@
English | [中文](README.zh.md)
The `dsh` command has three entry modes: a required raw config overlay, a one-shot headless prompt, and the Web UI. [`src/args.ts`](src/args.ts) owns the Commander grammar, and [`src/bin.ts`](src/bin.ts) dynamically imports only the selected runner. Unknown commands and leaked options fail with a nonzero exit code.
The `dsh` command is the product launcher for raw Cordis configurations, the Web UI, and one-shot headless tasks. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero.
## Entry modes
| Command | Purpose |
|---|---|
| `dsh --config ./app.cordis.yml` | Run an explicit patch-list configuration over the shipped base. |
| `dsh web` | Start the browser UI with the shipped Web composition and optional personal configuration. |
| `dsh -p "task"` | Run one fresh persisted session, print the final answer, and exit. |
The invoking directory is the default workspace root. Web and headless share the shipped provider, persistence, policy, tool, repository Plugin, and telemetry composition; raw config selects its own deployment-specific front door.
## Raw config
Raw `dsh` requires an explicit patch-list config:
Raw `dsh` requires `--config`. The named patch list is applied directly over [`config/base.cordis.yml`](config/base.cordis.yml); it is not a complete replacement tree and does not add a surface overlay or personal `$DSH_HOME/config.yaml`. Use `--dump-default-config` and `--dump-config` to inspect the resulting tree without booting it.
```sh
dsh --config ./app.cordis.yml
```
The [CLI behavior reference](reference/README.md) owns exact overlay precedence, flags, shutdown behavior, deployment defaults, and the source launcher.
The named file is applied directly over [`config/base.cordis.yml`](config/base.cordis.yml) through the Include plugin's patch algorithm. It is not a complete replacement tree, and neither the personal `$DSH_HOME/config.yaml` nor another surface overlay is added. The base deliberately contains no startup agent or interaction front door; the required overlay selects those deployment details. Relative config paths resolve from the invoking directory. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
## Development
A patch targets a base row by `id` and replaces that row's complete `config` value rather than deep-merging keys. Patch lists may also insert new rows whose plugin modules the shipped Loader can resolve:
```yaml
- id: agent-loop
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
```
Inspect the effective tree without booting it:
```sh
dsh --dump-default-config
dsh --config ./app.cordis.yml --dump-config
```
`--dump-default-config` prints only the shipped base. `--dump-config` requires `--config` and prints base plus overlay with provenance comments. Composition uses `applyEntryPatches` and `entryListSchema` from `@cordisjs/plugin-include`; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
## Web and headless
`dsh web` boots `base.cordis.yml` plus [`config/web.cordis.yml`](config/web.cordis.yml), followed by `$DSH_HOME/config.yaml` when present. `dsh web --config <path>` replaces that personal layer with the explicit patch list. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become Web host patches; their owning plugin schemas validate them at boot. `--dev` mounts the client-plugin HMR receiver and expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
```sh
dsh web
dsh web --config ./web-profile.cordis.yml
dsh web --dump-default-config
dsh web --dump-config
```
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the Web/headless process; another value fails at boot. [`config/core-web.cordis.yml`](config/core-web.cordis.yml) is an optional Web overlay that reduces the native model surface to persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition.
## Shared deployment behavior
The base mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless an overlay inserts a provider and enables it.
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.
The empty `repository-plugins` row lets Web/headless personal config and raw overlays mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for overlays, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
## Source launcher
Link the source-running launcher onto PATH:
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`.
Production Web and headless runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract.
+15 -66
View File
@@ -2,75 +2,24 @@
[English](README.md) | 中文
`dsh` 命令有三种入口模式:必需的原始配置 overlay、一次性 headless 提示词,以及 Web UI。[`src/args.ts`](src/args.ts) 拥有 Commander 命令语法,[`src/bin.ts`](src/bin.ts) 只会动态导入选中模式的运行器。未知命令和误传入其他模式的选项都会以非零代码退出。
`dsh` 命令是原始 Cordis 配置、Web UI 和一次性无头任务的产品启动器。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。
## 入口模式
| 命令 | 用途 |
|---|---|
| `dsh --config ./app.cordis.yml` | 在随附基础配置之上运行显式 patch 列表配置。 |
| `dsh web` | 使用随附 Web 组合和可选个人配置启动浏览器 UI。 |
| `dsh -p "task"` | 运行一个新的持久化会话,打印最终答案并退出。 |
调用目录是默认 workspace 根目录。Web 与无头模式共享随附的提供方、持久化、策略、工具、repository Plugin 和遥测组合;原始配置自行选择部署专用前端入口。
## 原始配置
原始 `dsh` 要求显式传入一份 patch 列表配置:
原始 `dsh` 必须提供 `--config`。指定的 patch 列表直接应用到 [`config/base.cordis.yml`](config/base.cordis.yml) 之上;它不是完整替代树,也不会添加 surface overlay 或个人 `$DSH_HOME/config.yaml`。使用 `--dump-default-config``--dump-config` 可在不启动的情况下检查生成的配置树。
```sh
dsh --config ./app.cordis.yml
```
[CLI(命令行界面)行为参考](reference/README.md)负责确切的 overlay 优先级、flag、关闭行为、部署默认值和源码启动器。
指定文件会通过 Include 插件的 patch 算法,直接应用在 [`config/base.cordis.yml`](config/base.cordis.yml) 之上。它不是完整替换树,系统也不会添加个人 `$DSH_HOME/config.yaml` 或其他 surface overlay。base 有意不包含启动 agent(智能体)或交互入口;必需的 overlay 负责选择这些部署细节。相对配置路径以调用目录为基准解析。配置解析、schema 校验、模块解析或插件启动失败都会被报告,并以非零代码退出。SIGINT 和 SIGTERM 会在退出前 dispose(资源释放)已挂载的根上下文。
## 开发
patch 通过 `id` 定位 base 配置项,并替换该配置项的完整 `config` 值,而不是深度合并各个键。它也可以插入新配置项:
```yaml
- id: agent-loop
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
```
可以在不启动应用的情况下检查有效配置树:
```sh
dsh --dump-default-config
dsh --config ./app.cordis.yml --dump-config
```
`--dump-default-config` 只打印随附 base。`--dump-config` 要求提供 `--config`,并打印带来源注释的 base 与 overlay。组合过程使用 `@cordisjs/plugin-include``applyEntryPatches``entryListSchema``!!js` 表达式保持未求值状态,未匹配的 patch 目标会报告到 stderr。
## Web 与 headless
`dsh web` 会启动 `base.cordis.yml` 加 [`config/web.cordis.yml`](config/web.cordis.yml),并在 `$DSH_HOME/config.yaml` 存在时继续应用该文件。`dsh web --config <path>` 会以显式 patch 列表替换个人层。`--host``--port``--workspace-root` 和可重复的 `--trusted-host` 值会转为 Web 宿主 patch;各自所属插件的 schema 会在启动时校验它们。`--dev` 会挂载客户端插件 HMR(热模块替换)接收器,要实现无需刷新的客户端 bundle 更新,还需单独运行 `pnpm run dev:web` watcher。
```sh
dsh web
dsh web --config ./web-profile.cordis.yml
dsh web --dump-default-config
dsh web --dump-config
```
生产 Web 运行器需要已构建的包(package)与前端产物(`pnpm run build`)。它默认通过 `http://127.0.0.1:3080` 提供服务。绑定所有网络接口时,系统也会信任本机探测到的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任边界所接受的具名权威。
`dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。
Web 与 headless 的进程关闭流程最多给插件树 5 秒执行 dispose。第一次 `SIGINT`/`SIGTERM` 会启动这次优雅排空;第二次信号会立即强制退出。如果 headless 的正常完成流程已经卡在 dispose 中,第一次 `Ctrl+C` 就会触发强制退出:进程立即结束,该信号不再被吞掉。
两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md``CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑;headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。
新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。
`DSH_TOOLS_MODE` 为 Web/headless 进程选择 `native``code``both`;其他值会在启动时失败。[`config/core-web.cordis.yml`](config/core-web.cordis.yml) 是可选的 Web overlay,它在保留随附宿主、浏览器、workspace、持久化与权限组合的同时,将面向原生模型的工具缩减为持久 `bash``str_replace_editor`
## 共享部署行为
base 会挂载原生 DeepSeek 适配器、设置与凭据提供方、稳定的 `web_search`、仓库插件支持与会话遥测。提供方凭据位于 `$DSH_HOME/.env` 或环境中,且仍可轮换,因为启动器绝不会把凭据文件提升进 `process.env`。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;除非 overlay 插入提供方并启用 `web_fetch`,否则后者处于禁用状态。
会话事件默认以 OTLP/HTTP 日志的形式流式发送。`DSH_TELEMETRY_OTLP_URL` 用于选择其他 collector。`DSH_TELEMETRY_DISABLED` 的任何非空值都会在启动前禁用遥测配置项。随附 base 没有遥测脱敏规则,因此导出记录可能包含消息文本、工具参数与结果,以及 workspace 路径;该部署决策由[遥测 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) 统一定义。
空的 `repository-plugins` 配置项允许 Web/headless 个人配置与原始 overlay 挂载已准备的不可变仓库插件 generation。详见[仓库插件契约](../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI(命令行界面)还将 `@deepseek-ai/dsh-mcp-client` 作为 overlay 依赖发布,但默认不启用任何 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。
## 源码启动器
将以源码运行的启动器链接到 PATH:
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
它会通过自身实际路径解析该检出,并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts``TSX_TSCONFIG_PATH` 固定指向检出根目录,因此 workspace 包解析不受调用目录影响。`pnpm run dsh` 使用同一入口并转发参数。构建后的形式是执行 `pnpm run build` 后的 `apps/cli/lib/bin.js`
生产环境的 Web 和无头运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。
+6
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 apps/cli/reference/README.md
README.md: b37ec9ed61ea4e9899a51316065d4188f30997ad
README.zh.md: ca29808a6c8e670f0d0b82c59b1a2c1fa0e13565
+76
View File
@@ -0,0 +1,76 @@
# `dsh` CLI behavior reference
English | [中文](README.zh.md)
This reference defines the raw-config, Web, and headless command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner.
## Raw config
Raw `dsh` requires an explicit patch-list config:
```sh
dsh --config ./app.cordis.yml
```
The named file is applied directly over [`config/base.cordis.yml`](../config/base.cordis.yml) through the Include plugin's patch algorithm. It is not a complete replacement tree, and neither the personal `$DSH_HOME/config.yaml` nor another surface overlay is added. The base deliberately contains no startup agent or interaction front door; the required overlay selects those deployment details. Relative config paths resolve from the invoking directory. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
A patch targets a base row by `id` and replaces that row's complete `config` value rather than deep-merging keys. Patch lists may also insert new rows whose plugin modules the shipped Loader can resolve:
```yaml
- id: agent-loop
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
```
Inspect the effective tree without booting it:
```sh
dsh --dump-default-config
dsh --config ./app.cordis.yml --dump-config
```
`--dump-default-config` prints only the shipped base. `--dump-config` requires `--config` and prints base plus overlay with provenance comments. Composition uses `applyEntryPatches` and `entryListSchema` from `@cordisjs/plugin-include`; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
## Web and headless
`dsh web` boots `base.cordis.yml` plus [`config/web.cordis.yml`](../config/web.cordis.yml), followed by `$DSH_HOME/config.yaml` when present. `dsh web --config <path>` replaces that personal layer with the explicit patch list. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become Web host patches; their owning plugin schemas validate them at boot. `--dev` mounts the client-plugin HMR receiver and expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
```sh
dsh web
dsh web --config ./web-profile.cordis.yml
dsh web --dump-default-config
dsh web --dump-config
```
The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the Web/headless process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional Web overlay that reduces the native model surface to persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition.
## Shared deployment behavior
The base mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless an overlay inserts a provider and enables it.
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.
The empty `repository-plugins` row lets Web/headless personal config and raw overlays mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for overlays, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
## Source launcher
Link the source-running launcher onto PATH:
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`.
+76
View File
@@ -0,0 +1,76 @@
# `dsh` CLI(命令行界面)行为参考
[English](README.md) | 中文
本参考定义原始配置、Web 和无头命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。
## 原始配置
原始 `dsh` 必须提供显式 patch 列表配置:
```sh
dsh --config ./app.cordis.yml
```
指定文件通过 Include 插件的 patch 算法直接应用到 [`config/base.cordis.yml`](../config/base.cordis.yml) 之上。它不是完整替代树,也不会添加个人 `$DSH_HOME/config.yaml` 或其他 surface overlay。基础配置刻意不包含启动 agent(智能体)或交互前端入口;必填 overlay 负责选择这些部署细节。相对配置路径从调用目录解析。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
patch 通过 `id` 定位基础配置行,并替换该行完整的 `config` 值,而不是深度合并各键。patch 列表也可插入新行,只要随附 Loader 能解析其插件模块:
```yaml
- id: agent-loop
config:
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
```
可在不启动的情况下检查生效的配置树:
```sh
dsh --dump-default-config
dsh --config ./app.cordis.yml --dump-config
```
`--dump-default-config` 只打印随附基础配置。`--dump-config` 必须与 `--config` 同时使用,并打印基础配置和带来源注释的 overlay。组合使用 `@cordisjs/plugin-include``applyEntryPatches``entryListSchema``!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。
## Web 与无头模式
`dsh web` 启动 `base.cordis.yml` 加 [`config/web.cordis.yml`](../config/web.cordis.yml),并在 `$DSH_HOME/config.yaml` 存在时继续加载它。`dsh web --config <path>` 用显式 patch 列表替代该个人层。`--host``--port``--workspace-root` 和可重复的 `--trusted-host` 值会成为 Web 宿主 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 挂载客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。
```sh
dsh web
dsh web --config ./web-profile.cordis.yml
dsh web --dump-default-config
dsh web --dump-config
```
生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
`dsh -p "task"` 使用同一基础配置和 Web 组合,并加载启动时的个人配置;它在 OS 分配的端口上启动 Web 宿主,运行一个新的持久化会话,打印最终答案并退出。它不接受 `--config` 或原始配置 dump flag。
Web 和无头进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果无头模式正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
两种模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md``CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。Web 监视有效的个人配置编辑;无头模式只在启动时读取该文件。[app-boot 个人配置契约](../../../packages/ui/app-boot/README.md#personal-config)负责配置层优先级、凭据存储、实时更新失败行为和 `$DSH_HOME` 解析。
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
`DSH_TOOLS_MODE` 为 Web/无头进程选择 `native``code``both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选 Web overlay:它在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,把原生模型 surface 缩减为持久 `bash``str_replace_editor`
## 共享部署行为
基础配置挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 overlay 插入提供方并启用 `web_fetch` 后,该工具才可用。
会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。
`repository-plugins` 行让 Web/无头个人配置和原始 overlay 能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 契约](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为 overlay 的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。
## 源码启动器
把源码运行启动器链接到 PATH
```sh
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
它通过 real path 解析 checkout,并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts``TSX_TSCONFIG_PATH` 固定到 checkout 根目录,因此 workspace 包解析不依赖调用目录。`pnpm run dsh` 使用同一入口并转发参数。运行 `pnpm run build` 后,构建形式为 `apps/cli/lib/bin.js`
+420
View File
@@ -0,0 +1,420 @@
// Web e2e scenario: the input card holds one horizontal position across the
// Chat and Trajectory tabs.
//
// The composer seat is the same node in both tabs, but it measures itself
// against a different edge in each (see
// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css).
// In Chat it is a sticky CHILD of the column's scroller, so it rides that
// scroller's content box — the box a space-consuming scrollbar shortens. A view
// that opts into a composer overlay (`data-conversation-composer-overlay`, which
// Trajectory declares and which moves the column's own scrolling into the view)
// gets an absolutely positioned seat instead, laid out against the padding box,
// which the scrollbar never reduces.
//
// So the two tabs disagreed by exactly the bar's width for as long as the
// transcript overflowed: the card jumped sideways on every tab switch, and
// inside Chat alone at the moment a growing transcript started to scroll. The
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
// and states the overlay branch as a scroll container on the same axes, so both
// edges are the same edge.
//
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
// can assert the declarations exist but not that the two states land in the same
// place. What is asserted here is the user-visible fact — the card does not move
// — measured as the distance between the two tabs' card rectangles.
//
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
// which is load-bearing rather than incidental. Under that argument a scroll
// container's bar consumes no layout width at all, so the two tabs agree before
// this change as much as after it and every comparison below holds vacuously —
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
// and 0 with the argument dropped. Dropping it is also the faithful
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
// and a bar that occupies layout space is what the product actually draws.
//
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
// same two tabs through it, which is what keeps the equal rectangles above from
// being explained by a tab switch that never reached the layout. It is the
// reported symptom as a number: the card moves 4px, half the 8px band, on each
// edge.
//
// Zero model calls: a seeded cold session renders from its log, and switching
// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER.
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 { createChatScrollFixture } from './chat-scroll-fixture.ts'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url))
/**
* Committed golden of where the input card sits in each tab, at a wide viewport
* (card at its width cap) and a narrow one (card shrinking with the column).
*
* Absolute coordinates are deliberately absent: they depend on the sidebar's
* laid-out width and on font metrics, so committing them would produce a fixture
* that has to be re-recorded per platform. What is recorded is the distance
* between the two tabs' rectangles, which is zero when the reservation holds and
* the bar's width when it does not — including under the control, so the golden
* carries the difference the fix removes rather than only its absence.
*/
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */
const FIXTURE = createChatScrollFixture({
markerPrefix: 'TAB_GEOMETRY',
title: 'COMPOSER_TAB_GEOMETRY long session',
turns: 24,
})
const SEED_ID = 'composer-tab-geometry-web-e2e'
/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */
const WIDE_VIEWPORT = { width: 1680, height: 1000 }
const NARROW_VIEWPORT = { width: 800, height: 1000 }
/**
* Resize to one measurement viewport after the responsive sidebar and center
* column finish their track transition.
* @param page - the page under test.
* @param viewport - the viewport dimensions to apply.
* @param sidebarCollapsed - the sidebar state expected at this width.
*/
async function setMeasuredViewport(
page: Page,
viewport: { width: number; height: number },
sidebarCollapsed: boolean,
): Promise<void> {
await page.setViewportSize(viewport)
await page.locator('[data-sidebar-collapsed="true"]').waitFor({
state: sidebarCollapsed ? 'attached' : 'detached',
timeout: 10_000,
})
await page.locator('[data-conversation-scroll]').evaluate(async (host) => {
const deadline = performance.now() + 5_000
let previous = host.getBoundingClientRect().width
let stableFrames = 0
while (performance.now() < deadline) {
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const current = host.getBoundingClientRect().width
stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0
if (stableFrames >= 3) return
previous = current
}
throw new Error('conversation width did not settle after the viewport changed')
})
}
/**
* The pre-fix cascade, injected into the page: the reservation dropped and the
* overlay branch back to a hidden box. `!important` beats the module rules
* without a rebuild, and the id lets the control be lifted again in the same
* session.
*/
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
const CONTROL_CSS = `
[data-conversation-scroll] { scrollbar-gutter: auto !important; }
[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; }
`
/** The column scroller and the input card as the browser lays them out, in one tab. */
interface TabMetrics {
/** Resolved `scrollbar-gutter` on the column's scroller. */
gutter: string
/** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */
overflowX: string
/** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */
overflowY: string
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
band: number
/** True when the column's scroller actually scrolls — only Chat does. */
scrolls: boolean
/** Left edge of the input card in viewport coordinates. */
cardLeft: number
/** Right edge of the input card. */
cardRight: number
/** Width of the input card, capped at the composer card max width. */
cardWidth: number
}
/** One tab's metrics beside the other's, plus the distances between them. */
interface TabComparison {
chat: TabMetrics
trajectory: TabMetrics
/** Distance between the two tabs' card left edges: 0 when the card holds its position. */
leftShift: number
/** Distance between the two tabs' card right edges. */
rightShift: number
/** Difference between the two tabs' card widths. */
widthShift: number
}
/**
* Measure the column scroller and the input card in the tab currently shown.
* @param page - the page under test.
* @returns the scroller's resolved overflow style and the card's rectangle.
*/
function measureTab(page: Page): Promise<TabMetrics> {
return page.evaluate(() => {
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
if (host === null) throw new Error('conversation column scroller not in the DOM')
const card = host.querySelector<HTMLElement>('[data-composer-seat] [data-composer-card]')
if (card === null) throw new Error('no input card inside the composer seat')
const style = getComputedStyle(host)
const hostRect = host.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
return {
gutter: style.scrollbarGutter,
overflowX: style.overflowX,
overflowY: style.overflowY,
band: hostRect.width - host.clientWidth,
scrolls: host.scrollHeight > host.clientHeight,
cardLeft: cardRect.left,
cardRight: cardRect.right,
cardWidth: cardRect.width,
}
})
}
/**
* Show one tab and wait for the view that owns it to be laid out.
* @param page - the page under test.
* @param tab - the tab to show.
*/
async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
await page.getByRole('tab', { name: tab, exact: true }).click()
if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 })
// Both measurements are taken after a paint, so a rectangle read mid-transition
// cannot be reported as a shift the cascade did not cause.
await page.evaluate(() => new Promise<void>((settle) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) })
}))
}
/**
* Measure both tabs and the distances between them, leaving Chat shown.
* @param page - the page under test.
* @returns each tab's metrics and the card's displacement between them.
*/
async function compareTabs(page: Page): Promise<TabComparison> {
await showTab(page, 'Chat')
const chat = await measureTab(page)
await showTab(page, 'Trajectory')
const trajectory = await measureTab(page)
await showTab(page, 'Chat')
return {
chat,
trajectory,
leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft),
rightShift: Math.abs(trajectory.cardRight - chat.cardRight),
widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth),
}
}
/**
* Run the pre-fix cascade in the page for one measurement, then lift it.
* @param page - the page under test.
* @returns the comparison as the column laid out before this change.
*/
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
await page.evaluate(({ id, css }) => {
const style = document.createElement('style')
style.id = id
style.textContent = css
document.head.append(style)
}, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
try {
return await compareTabs(page)
} finally {
await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
}
}
/**
* Open the seeded session from the sidebar search.
*
* Cold summaries carry the temp workspace's basename, so the persisted first
* message is the stable identity to search for, and the query itself drives the
* lazy content-index reconciliation. Hand-rolled polling because `expect.poll`
* is test-scoped and this runs in `beforeAll`.
* @param page - the page under test.
*/
async function openSeededSession(page: Page): Promise<void> {
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
await search.fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000
for (;;) {
if (await results.count() === 1) break
if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results')
await page.waitForTimeout(200)
}
await results.click()
}
/**
* Render the golden body.
* @param wide - comparison at the viewport where the card sits at its width cap.
* @param narrow - comparison at the viewport where the card shrinks with the column.
* @param control - comparison at the wide viewport with the reservation removed.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
const section = (name: string, comparison: TabComparison): string[] => [
`## ${name}`,
'',
`- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`,
`- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`,
`- Chat reserved band: ${String(comparison.chat.band)}px`,
`- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`,
`- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`,
`- Trajectory reserved band: ${String(comparison.trajectory.band)}px`,
`- input card left edge moves between tabs: ${String(comparison.leftShift)}px`,
`- input card right edge moves between tabs: ${String(comparison.rightShift)}px`,
`- input card width changes between tabs: ${String(comparison.widthShift)}px`,
'',
]
return [
'# Input card position across the Chat and Trajectory tabs',
'',
...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
...section('Wide viewport, reservation removed in the page (control)', control),
].join('\n').trimEnd()
}
describe('web e2e: input card position across view tabs', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, FIXTURE.log, SEED_ID)
// Scrollbars must take layout space here or the scenario proves nothing;
// see the file header for the measurement behind dropping this argument.
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeededSession(page)
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last()
.waitFor({ timeout: 30_000 })
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('reserves the same gutter in both tabs while the transcript scrolls', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// Vacuity guard, in two parts. A transcript that does not overflow gives
// Chat no scrollbar, and a hidden or overlaid bar gives it no width; either
// would make the tabs agree without the reservation doing anything.
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
const comparison = await compareTabs(page)
expect(comparison.chat.band).toBeGreaterThan(0)
// The reservation reaches both states, which is the whole change: the same
// band, on a box that scrolls and on one that only holds a view.
expect(comparison.chat.gutter).toBe('stable')
expect(comparison.trajectory.gutter).toBe('stable')
expect(comparison.trajectory.band).toBe(comparison.chat.band)
// Declared as a scroll container on both axes rather than left to compute:
// `overflow: hidden` would drop the reservation in WebKit, and a `visible`
// horizontal axis computes to `auto` beside a scrolling one.
expect(comparison.trajectory.overflowY).toBe('auto')
expect(comparison.trajectory.overflowX).toBe('hidden')
// Only Chat scrolls this box; the Trajectory view owns its own scrollers.
expect(comparison.trajectory.scrolls).toBe(false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('holds the input card in place when the tab changes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const comparison = await compareTabs(page)
// The reported symptom as a number. At this viewport the card sits at its
// width cap, so the pre-fix shift showed up as a centring difference — half
// the band on each edge — rather than as a width change.
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('holds the input card in place at a viewport where it shrinks with the column', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const capped = await measureTab(page)
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const comparison = await compareTabs(page)
// The other geometry, and a different failure: below the cap the card takes
// the column's width, so an unreserved gutter changed its WIDTH by the whole
// band instead of shifting it by half. Asserted against the capped
// measurement rather than against the cap's pixel value, which belongs to
// the stylesheet.
expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth)
expect(comparison.leftShift).toBe(0)
expect(comparison.rightShift).toBe(0)
expect(comparison.widthShift).toBe(0)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('moves the card again once the reservation is removed in the page', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
// The control: without it, equal rectangles could also mean the tab switch
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
// its bar and the Trajectory branch goes back to a hidden box with none, and
// the card moves by half the band on each edge.
const comparison = await compareTabsWithoutReservation(page)
expect(comparison.chat.gutter).toBe('auto')
expect(comparison.chat.band).toBeGreaterThan(0)
expect(comparison.trajectory.band).toBe(0)
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
// Restoring the sheet restores the fix, so the control cannot leak into the
// remaining measurements.
const restored = await compareTabs(page)
expect(restored.leftShift).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed tab geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const wide = await compareTabs(page)
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
const narrow = await compareTabs(page)
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
const control = await compareTabsWithoutReservation(page)
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// The seeded session is generated in-process, so the geometry golden is the
// whole inventory.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
})
})
+4 -4
View File
@@ -56,7 +56,7 @@ import type {} from '@deepseek-ai/dsh-agent'
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
/**
@@ -592,9 +592,9 @@ export async function compareOrRefreshGolden(goldenPath: string, actual: string,
}
/**
* Fixture-inventory guard (the TUI afterAll shape): the scenario directory
* holds exactly the expected files and every committed JSONL is a scrub
* fixed-point without a run-local browser RPC id.
* Fixture-inventory guard: the scenario directory holds exactly the expected
* files and every committed JSONL is a scrub fixed-point without a run-local
* browser RPC id.
* @param dir - the scenario snapshot directory.
* @param expected - the exact expected file inventory.
*/
+24
View File
@@ -61,6 +61,30 @@ describe('web e2e: settings modal and General preferences', () => {
await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
await openDocument.waitFor({ timeout: 10_000 })
let openRequests = 0
await page.route('**/api/settings.openDocument', async (route) => {
const envelope = route.request().postDataJSON() as {
rpcId: string
payload: Record<string, never>
}
expect(envelope.payload).toEqual({})
openRequests += 1
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
type: 'server-response',
rpcId: envelope.rpcId,
result: { ok: true, value: { opened: true } },
}),
})
})
await openDocument.click()
await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1)
await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true)
await page.unroute('**/api/settings.openDocument')
// Golden of the freshly opened dialog (default zh, General active).
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
@@ -0,0 +1,37 @@
# Input card position across the Chat and Trajectory tabs
## Wide viewport (1680px, card at its cap)
- Chat: scrollbar-gutter stable, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
- Trajectory scroller scrolls: false
- Trajectory reserved band: 8px
- input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px
## Narrow viewport (800px, card shrinking with the column)
- Chat: scrollbar-gutter stable, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
- Trajectory scroller scrolls: false
- Trajectory reserved band: 8px
- input card left edge moves between tabs: 0px
- input card right edge moves between tabs: 0px
- input card width changes between tabs: 0px
## Wide viewport, reservation removed in the page (control)
- Chat: scrollbar-gutter auto, overflow auto/auto
- Chat scroller scrolls: true
- Chat reserved band: 8px
- Trajectory: scrollbar-gutter auto, overflow hidden/hidden
- Trajectory scroller scrolls: false
- Trajectory reserved band: 0px
- input card left edge moves between tabs: 4px
- input card right edge moves between tabs: 4px
- input card width changes between tabs: 0px
@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
@@ -7,6 +7,7 @@
- button "模型":
- img
- text: 模型
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
+12 -6
View File
@@ -90,9 +90,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
{ timeout: 10_000 },
).not.toBeUndefined()
// First adoption births a blank Session+Agent whose workspace attach must
// settle before a test may delete the registration; the reuse path (same
// canonical cwd already has a blank session) creates no agent, so callers
// opt in only where a fresh attach is possible.
// settle before a test may delete the registration; re-registration after
// a delete mints a fresh blank Session+Agent too (the old cwd-only reuse
// path is gone), so callers opt in only where a fresh attach is possible.
if (options.waitForAgent === true) {
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
.toBeGreaterThan(agentsBefore)
@@ -251,8 +251,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
expect((await scaffold.ctx.sessionPersistence.inspect(SessionId(SEED_ID))).events.length).toBeGreaterThan(0)
// Re-registering the exact deleted path immediately, without a reload, is
// a supported reversible flow. It creates a fresh Workspace id without
// re-adopting the retained Session.
// a supported reversible flow. It creates a fresh Workspace id and does
// NOT re-adopt the retained (non-blank) Session; the New Session flow
// mints a fresh blank session and attaches it to the new registration
// (the old cwd-only blank reuse is gone, so the account is never empty).
await adoptDirectory(scaffold.workspaceCwd)
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
@@ -261,7 +263,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
expect(reregistered?.id).toBeDefined()
expect(reregistered?.id).not.toBe(workspace.id)
expect(reregistered?.sessionIds).toEqual([])
await expect.poll(
() => reregistered?.sessionIds ?? [],
{ timeout: 10_000 },
).not.toEqual([])
expect(reregistered?.sessionIds).not.toContain(SEED_ID)
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
.toBeGreaterThanOrEqual(1)
expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
+1
View File
@@ -62,6 +62,7 @@
"tests/chat-scroll-contract.e2e.ts",
"tests/chat-long-interactions.e2e.ts",
"tests/chat-continuous-conversation.e2e.ts",
"tests/composer-tab-geometry.e2e.ts",
"tests/complex-history.perf.ts"
],
"references": [
+20 -14
View File
@@ -1,10 +1,20 @@
# AGENTS.md — The documentation standard
This file defines Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for placement and validation, and [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage and editorial judgment; the [doc-tiers Agent Note](../.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale.
This file defines document structure, Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for placement and validation, and [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage and editorial judgment; the [doc-tiers Agent Note](../.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale.
## Document structure
These rules apply to human-facing documentation; [Agent Notes](../.agents/notes/README.md) remain outside their scope. A [postmortem](postmortem/README.md) is an incident-scoped reference; chronology records evidence, not a teaching sequence. A document's subject and tree position fix its scope: describe its own subject at appropriate detail, describe direct children only by purpose, responsibility, and high-level behavior, and link to the owning descendant for lower-level detail. Document type does not widen that scope. A reference may be exhaustive only about its own subject. Testing mechanisms, fixtures, and harnesses belong at the lowest owning level; higher documents link there.
Classify every in-scope document as a tutorial or reference. A tutorial follows an ordered path to an outcome and introduces only what each step needs. A reference defines a lookup scope and describes current behavior without depending on a teaching sequence. Separate substantial tutorial and reference content; use a clear structural boundary when either part is small.
Before writing a tutorial, privately classify the reader's starting knowledge and each concept as beginner, intermediate, or advanced. Establish prerequisites before dependent concepts, increase difficulty gradually, and move unnecessary advanced material to a later tutorial or reference.
Author in this order: locate the document in the tree; set its permitted detail; choose tutorial or reference; for a tutorial, order concepts by prerequisite and difficulty; relocate descendant-owned detail; replace lower-level explanations with links to their owners.
## The tier taxonomy: one home per fact
Each fact has one home: the tier whose job it is. Elsewhere, link to that home; `verify-md-links` keeps links resolving while duplicated prose drifts.
Each fact has one home: the tier whose job it is. Elsewhere, link to that home.
| Tier | Job | Does NOT belong there |
|---|---|---|
@@ -12,17 +22,15 @@ Each fact has one home: the tier whose job it is. Elsewhere, link to that home;
| Subtree `AGENTS.md` (`packages/`, `examples/`, `docs/`, `.agents/notes/`) | Orders specific to that subtree | Repo-wide rules the root file already carries |
| [architecture.md](architecture.md) | The system map: services, the loop, extension seams — read before changing `packages/` | Type shapes (→ core-data-structures), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations |
| [core-data-structures/](core-data-structures/core.md) | The type catalog: literal shapes and semantics of the spine and seam vocabulary | Behavior narration (→ architecture.md) |
| [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and concise verification contract; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority |
| [Agent Notes](../.agents/notes/README.md) | Decision records under their own lifecycle contract | Migration plans, checklists, and spec-speak once implemented; archived notes are frozen history |
| [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — |
| [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the Agent Note each guide links) |
| [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history |
| Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns |
| [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ Agent Notes), gate-by-gate enumerations that drift from `package.json` scripts |
| Package README | Per-package config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc or catalog restatement, other packages' concerns |
| [development.md](development.md) | Contributor onboarding: setup, daily workflow, and CI shape at summary level | Runtime rationale (→ Agent Notes), drifting gate inventories |
| Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [Cordis core API](cordis-catalog/core/context.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind |
| Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) |
Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookbooks; type shapes → core data; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link.
## Writing rules
- **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, Agent Notes, or postmortems.
@@ -31,7 +39,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb
- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).
- **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)).
- **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)).
- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples.
- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, timing, modality, exceptions, consequences, and non-obvious orientation; delete narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link its rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for details.
- Your audience is professional programmers. Prefer concise and straight-forward English over metaphor. Do not overuse words like "gate", "vocabulary", "surface", "seams".
## Wordcount Budgets
@@ -44,20 +52,18 @@ When the gate goes red:
2. **Condense** content that belongs here but can be shorter.
3. **Raise** the ceiling only when the words truly need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug.
Ceilings are guardrails, not reduction targets. Retain at least 5% headroom; lower a ceiling only when the document's durable contract still has room, and raise it when necessary content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 650 and this file ≤ 1,250; `packages/README.md` ≤ 600. Review and the slop checklist govern unbudgeted tiers.
Ceilings are guardrails, not reduction targets. At or below target, retain at least 5% headroom; above target, freeze the ceiling until relocation or condensation brings the document under target. Lower a ceiling only when the contract still has room, and raise it when content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 650 and this file ≤ 1,250; `packages/README.md` ≤ 600. Review governs unbudgeted tiers.
## The slop checklist
Hunt these in any doc; the [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) skill runs this list as an audit:
- The same rule stated in more than one home. Grep a distinctive phrase; keep one home, convert the rest to links.
- Narrated history: "previously", "now", "no longer", "used to", "renamed", "was moved", references to PRs or commits. State the current fact; the why belongs in an Agent Note, the story in a postmortem or git.
- A war story told inline where a one-line rule plus a postmortem/Agent Note link would do.
- Narrated history or war stories: "previously", "now", "no longer", "used to", "renamed", "was moved", PRs, or commits. State the current fact; link an Agent Note or postmortem when needed.
- Implementation-status annotations in prose or diagrams ("implemented!", "future: …"). Status rots; the repo layout and package manifests carry it.
- Hand-restating a generated catalog or JSDoc: event tables, tool arg tables, method signatures. Link instead.
- Hand-maintained inventories of tests, packages, or implementation status when the tree or a generator is authoritative.
- Hand-restated catalogs, JSDoc, or inventories of tests, packages, and status when source or a generator is authoritative.
- Reasoning transcripts: step-by-step implementation narration, proof of obvious branches, test walkthroughs, or rejected local alternatives. Keep the resulting contract or durable rationale; delete the path used to derive it.
- The same rationale repeated beside sibling methods. State it once at the owning seam or shared helper.
- Rationale repeated beside sibling methods instead of once at the owning seam or helper.
- Paragraph walls: one paragraph carrying several rules and parenthetical asides. Split it, or demote the detail to the linked home.
- Emphasis inflation: bold, CAPS, or "critically" everywhere means nothing stands out. Reserve emphasis for the clause that changes behavior.
- Spec-speak in `implemented/` Agent Notes: "should", migration plans, acceptance checklists. An implemented Agent Note describes what is, per the [implemented-note instructions](../.agents/notes/implemented/AGENTS.md).
+2 -2
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 docs/architecture.md
architecture.md: ee50e249f8e588a3f92f57db929c6bbfb1c853dd
architecture.zh.md: c2e596cd10c21be2bcaad11323277d04ef9e74a1
architecture.md: 296aea2056294d08693845e1780a9ac268b21bfc
architecture.zh.md: c5f87c967d95c8934e919c4c01bc4acd4d3a1a70
+6 -18
View File
@@ -68,9 +68,7 @@ Waterfalls are around-middleware: listeners delegate with `next()`; returning wi
## Default Loop Lifecycle
A **session** is append-only. An ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits its predecessor's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model or plugins stop it; a **step** is one model request plus tools. Quotes in the [sequence below](agent-lifecycle.md) mark durable events.
Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent.
A **session** is append-only. An ordinary **turn** claims one queued `send()` item; injection claims none. A turn ends when the model or plugins stop it; a **step** is one model request plus its tool calls. Agent and session publication happen only after private setup and resume state are ready. Quotes in the [sequence below](agent-lifecycle.md) mark durable events.
### Turn Flow
@@ -119,29 +117,19 @@ idle inject:
do not open a turn or run the model
```
Each step assembles ordered stable system sections, cache-safe dynamic contexts, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
Admission-time and active-turn `inject()` stage for the next step; tool-time injection and post-tool `additionalContexts` settle after results. Steering shares the outbox but remains provisional until a request admits it. `steer()` returns a message-owned receipt: after `agent/step` and asynchronous prompt assembly succeed, the loop commits the stable batch, snapshots request history, opens `step/start`, then resolves its receipts as admitted with the turn and step; later arrivals wait. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never opens a step rejects affected receipts, while `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly.
Before driver claim, `updateInbox()` may edit or remove a queued occurrence, or strictly transfer its immutable message into an open next-step window. That transfer ends the queued occurrence and accepts a new steering occurrence; a closed window leaves Queue unchanged. Direct `steer()` remains best-effort for newly submitted input and falls back to a waking follow-up outside the window ([decision](../.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md)).
Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)).
Each step assembles the prompt, tools, runtime context, adapter settings, and model history before recording its reconstruction boundary. Tool calls then run through the shared execution pipeline. `inject()` adds context without opening an idle turn; `steer()` targets a next-step admission window; queued input remains the source of ordinary turns. The generated [agent lifecycle](agent-lifecycle.md) owns exact event order, and the [agent-loop README](../packages/core/agent-loop/README.md) owns queue, steering, retry, and cancellation mechanics.
### Failure Boundaries
Adapter failures close their step before `agent/request-error` receives the exact `Error`, normalized `LlmFailure`, and signal. A handled failure closes its turn and opens a retry turn from durable history without an idle notification; exhaustion leaves terminal `turn/end`. Failed chunks commit neither messages nor tool calls.
Other failures use `agent/error`. Cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels asynchronous model-capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` emits its cause before queue clearing and abort; observers cannot veto; idle calls emit nothing. Durability records user or parent cancellation as `aborted`, teardown as `disposed`; teardown awaits quiescence. The cause affects reporting, not late result-context handling ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
Turn and step events are turn-enclosed. Idle `user/message` and standalone `compact/* { turn: null }` consume no turn; their lock-time markers may interleave with injection. Reload synthesizes interrupted turn ends; `session/end-seed` distinguishes stale compaction orphans from live locks. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap).
Adapter failures close their step before `agent/request-error` can authorize recovery from durable history. Other failures use `agent/error`; cancellation and disposal take precedence over recovery. Failed model attempts commit no assistant message or tool side effect. Turn closure is represented by one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap); the exact retry contract belongs to [LLM streaming](core-data-structures/llm-streaming.md).
### Agent Handles
`ctx.agents` owns agents, returning `AgentHandle { agent, dispose() }`. Plugins use `send()` or `followup()`, receipt-bearing `steer()`, and `inject()` presets; [`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) synchronously reserves idle for durable work without changing queued prompt identity. Await a steering receipt when request admission matters; best-effort UI steering may ignore it. `cancel()` and `whenIdle()` control lifecycle. Caller, factory, and consumer co-own teardown through one awaited disposer.
`ctx.agents` owns agents and returns `AgentHandle { agent, dispose() }`. Plugins submit queued work, steering, or injected context through the [agent interface](../packages/core/agent/README.md#agent-interface-typests); cancellation, idleness, and teardown stay behind the same handle.
### Agent Scope
Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication and may return a synchronous commit that the factory invokes immediately before registry entry, after every setup await. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
Each agent owns scoped `agent.ctx`; shared storage overlays its tools, prompts, and commands on global contributions while scoped listeners filter dispatch. Setup composes before publication and cleanup unwinds contributions. The [agent-scope decision](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) owns the detailed lifecycle.
## State
@@ -198,4 +186,4 @@ New behavior attaches to a documented extension point; a loop change updates thi
| Fork a live session | call `ctx.sessions.fork(source, boundary?, childSessionId?)` |
| Scope a registration to one agent | use its `agent.ctx` (see Agent Scope) |
The [extension cookbook](cookbook/extension-cookbook.md) has plugin skeletons and the feature-to-seam map; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md).
The [extension cookbook](cookbook/extension-cookbook.md) has plugin skeletons; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md).
+6 -18
View File
@@ -68,9 +68,7 @@ waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委
## 默认循环生命周期
**会话**采用仅追加方式。普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型或插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。[下文时序](agent-lifecycle.md)中的引号标记持久事件。
创建时若未提供 id,流程会生成 `<config-id>-session-<uuid>``sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。
**会话**仅追加。普通**轮次**认领一个排队的 `send()` ;注入不会认领。轮次在模型或插件停止时结束;一个**步骤**一次模型请求及其工具调用组成。只有私有设置和恢复状态准备完毕后,系统才会发布 agent 与会话。[下文时序](agent-lifecycle.md)中的引号标记持久事件。
### 轮次流程
@@ -119,29 +117,19 @@ idle inject:
do not open a turn or run the model
```
每个步骤都会组装有序的稳定系统提示词片段、缓存安全的动态上下文、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider``model``cwd`[提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)
接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行期间的注入和工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用 outbox,但在请求接纳前始终处于待准入状态。`steer()` 会返回归属于该消息的回执:`agent/step` 和异步提示词组装成功后,循环提交稳定批次、捕获请求历史并开启 `step/start`,再将其回执解析为已准入并附带轮次与步骤;后续消息继续等待。结束轮次的工具结果、广义取消、dispose(资源释放),以及已领取 idle-steering 消息却从未开启步骤的轮次,都会拒绝受影响的回执;`cancel(..., { keepInbox: true })` 和非终止型路由则保留待处理投递。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。
驱动器认领之前,`updateInbox()` 可以编辑或移除 queued 单次入队项,也可以严格地把其不可变消息转移到开放的 next-step 窗口。该转移会结束 queued 单次入队项,并接受一个新的 steering 单次入队项;窗口关闭时 Queue 保持不变。直接调用 `steer()` 时,对新提交的输入仍采用尽力而为的语义,并在窗口之外回退为会唤醒 agent 的后续轮次([决策](../.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md))。
裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。
每个步骤都会组装提示词、工具、运行时上下文、适配器设置和模型历史,随后记录其重建边界。之后,工具调用通过共享执行流水线运行。`inject()` 添加上下文但不打开空闲轮次;`steer()` 针对下一步骤的准入窗口;排队输入仍是普通轮次的来源。精确事件顺序由生成的 [agent 生命周期](agent-lifecycle.md)定义;队列、steering、重试与取消机制由 [agent-loop README](../packages/core/agent-loop/README.md)定义
### 失败边界
适配器故障会先关闭自身步骤,再由 `agent/request-error` 接收准确的 `Error`、标准化的 `LlmFailure` 和信号。已处理的失败会关闭所在轮次,并从持久历史开启重试轮次,不发出空闲通知;重试耗尽则留下终态 `turn/end`。失败分片既不提交消息,也不提交工具调用
其他故障使用 `agent/error`。取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消异步模型能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 在清空队列和中止前发出原因;观察方不能否决;空闲调用不发事件。持久化层将用户或父级取消记录为 `aborted`,拆卸记录为 `disposed`;拆卸会等待完全停稳。原因只影响报告方式,不影响延迟完成的结果上下文处理([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
轮次和步骤事件均位于轮次边界内。空闲 `user/message` 与独立的 `compact/* { turn: null }` 不占用轮次;其锁定时刻标记可以与注入交错。重新加载会为中断的轮次合成结束事件;`session/end-seed` 区分陈旧的压缩遗留项与活跃锁。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。
适配器故障会先关闭步骤,再由 `agent/request-error` 授权从持久历史恢复。其他故障使用 `agent/error`;取消和资源释放优先于恢复。失败的模型尝试不会提交 assistant 消息或工具副作用。轮次关闭由一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)表示;准确的重试契约由 [LLM 流式输出](core-data-structures/llm-streaming.md)定义
### Agent 句柄
`ctx.agents` 拥有 agent,返回 `AgentHandle { agent, dispose() }`。插件使用 `send()``followup()`、带回执的 `steer()``inject()` 预设;[`reserveTurnAdmission()`](../packages/core/agent/README.md#agent-interface-typests) 为持久工作同步预留空闲状态,同时不改变排队提示词身份。需要确认请求准入时应等待 steering 回执;尽力执行的 UI steering 可以忽略它。`cancel()``whenIdle()` 控制生命周期。调用方、工厂和消费方通过同一个需等待完成的 disposer 共同拥有拆卸过程
`ctx.agents` 管理 agent返回 `AgentHandle { agent, dispose() }`。插件通过 [agent 接口](../packages/core/agent/README.md#agent-interface-typests)提交排队工作、steering 或注入上下文;取消、空闲状态和拆卸也都由同一个句柄封装
### Agent 作用域
每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合,并可返回一个同步提交操作;所有 setup 的 await 均完成后,工厂会在进入注册表前立即调用该操作。类型化解析器从合并后的 `Events``scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop``ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)
每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令叠加到全局贡献之上,作用域监听器过滤分派。设置过程在发布前完成组合,清理过程会撤销贡献。详细生命周期由 [agent 作用域决策](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)定义
## 状态
@@ -198,4 +186,4 @@ idle inject:
| fork 活跃会话 | 调用 `ctx.sessions.fork(source, boundary?, childSessionId?)` |
| 将注册项限定到单个 agent | 使用其 `agent.ctx`(参见 Agent 作用域) |
[扩展实操手册(cookbook](cookbook/extension-cookbook.md)提供插件骨架和功能到服务边界的映射;指南涵盖[](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。
[扩展实操手册(cookbook](cookbook/extension-cookbook.md)提供插件骨架;指南涵盖[](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。
+2 -2
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 docs/cookbook/adding-a-package.md
adding-a-package.md: 5df22b23a69e2a33a5d963970c771088ed7f3b5d
adding-a-package.zh.md: e573657988bd67f5ff9b9191550c10bc654ef116
adding-a-package.md: a45b222f6aed905a18ef9b480c989e6045029afe
adding-a-package.zh.md: af0e4d0779fa99ce43ebccba00c33eab16c4d362
+3 -5
View File
@@ -14,7 +14,6 @@ packages/<group>/<pkg>/
# ../../../vendor/cordis (+ ../../../vendor/schemastery if
# you use Config, + ../../<group>/<dep> for each dsh dep)
src/index.ts # service default export or plugin (name/inject/apply/Config)
tests/<x>.spec.ts
README.md # service API, events, extension points, design notes,
# + gated Model Experience context blocks or short form
# + the gated "Known Limitations and Deferred Work" section
@@ -33,11 +32,11 @@ In-package relative imports use explicit `.ts` specifiers in source (for example
|---|---|
| `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages/<group>/*/src` candidate to the `@deepseek-ai/dsh-*` wildcard |
| `tsconfig.host.json` (host-side package) or `tsconfig.client.json` (client-side package) | add `{ "path": "./packages/<group>/<pkg>" }` to `references` — exactly one aggregate, never both ([layout](../development.md#typescript-project-layout)) |
| `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm/llm-deepseek`) |
| `knip.json` | only if the package has entrypoints that repository discovery does not already cover |
A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dshClient` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract.
Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `.oxlintrc.json`, `scripts/check-workspace-constraints.ts`.
Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `.oxlintrc.json`, `scripts/check-workspace-constraints.ts`.
## 3. Decide the package topology
@@ -85,8 +84,7 @@ A package with no context effect or one consumer-owned path uses the audited `No
pnpm install # registers the workspace
pnpm run doc-sync
pnpm run constraints && pnpm run typecheck && pnpm run lint
pnpm run test:coverage # 100% per-file over src (types.ts exempt)
pnpm run build && pnpm run hygiene
```
Test expectations: every registry/registration needs an HMR-safety test (register from a child fiber, dispose it, assert cleanup). Excessive tests are welcome — see [docs/testing.md](../testing.md).
Follow the [repository testing policy](../testing.md) for the behavior-specific checks and coverage required by the new package.
+3 -5
View File
@@ -14,7 +14,6 @@ packages/<group>/<pkg>/
# ../../../vendor/cordis (+ ../../../vendor/schemastery if
# you use Config, + ../../<group>/<dep> for each dsh dep)
src/index.ts # service default export or plugin (name/inject/apply/Config)
tests/<x>.spec.ts
README.md # service API, events, extension points, design notes,
# + gated Model Experience context blocks or short form
# + the gated "Known Limitations and Deferred Work" section
@@ -33,11 +32,11 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c
|---|---|
| `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages/<group>/*/src` 候选路径 |
| `tsconfig.host.json`host 侧包)或 `tsconfig.client.json`client 侧包) | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }`——恰好一个聚合,绝不两个都加([布局](../development.md#typescript-project-layout) |
| `knip.json` | 仅当包有`*.spec.ts` 入口时需要(如 `*.e2e.ts` → 添加 per-workspace override,参照 `packages/llm/llm-deepseek` |
| `knip.json` | 仅当包有仓库发现机制尚未覆盖的入口时需要 |
`packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dshClient`、导出 `./client`、调用共享 tsdown preset`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。
以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts``tsdown.config.ts``vitest.config.ts``.oxlintrc.json``scripts/check-workspace-constraints.ts`
以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts``tsdown.config.ts``.oxlintrc.json``scripts/check-workspace-constraints.ts`
## 3. 确定包拓扑
@@ -85,8 +84,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex
pnpm install # registers the workspace
pnpm run doc-sync
pnpm run constraints && pnpm run typecheck && pnpm run lint
pnpm run test:coverage # 100% per-file over src (types.ts exempt)
pnpm run build && pnpm run hygiene
```
测试要求:每个注册表/注册操作都需要一个 HMR(热模块替换)安全测试(从子 fiber 注册,dispose(资源释放)它,断言清理完成)。鼓励编写充分的测试——见 [docs/testing.md](../testing.md)
请遵循[仓库测试政策](../testing.md),为新包运行行为所需的专项检查并达到相应覆盖率
+2 -2
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 docs/cookbook/adding-a-tool.md
adding-a-tool.md: 0688e4a46b1eb2ca282a7ee970546a34b3c1cc68
adding-a-tool.zh.md: e12d5e23a93d08f15fb0d1ac40229c5c4ab26e38
adding-a-tool.md: cb418a9118901cc6572fb17125351bdda922434b
adding-a-tool.zh.md: d8b73a51d1dde8647e7a032648f8a6344fcc83eb
+5 -5
View File
@@ -1,8 +1,8 @@
# Cookbook: adding a tool
# Tool authoring reference
English | [中文](adding-a-tool.zh.md)
How to give the model a new capability. The minimal shape below shows the contract; `packages/bash/tool-bash` is the production-grade three-package seam.
Reference for the contracts a model-facing tool must satisfy. For an ordered first tool, follow [Build a tool](../user/develop/basic/tool.md). `packages/bash/tool-bash` is the production-grade three-package example.
## The minimal shape
@@ -35,7 +35,7 @@ export function apply(ctx: Context) {
}
```
Registration is effect-based: disposing the plugin fiber unregisters the tool (write the HMR test). Schemas flow into the system-prompt assembly automatically.
Registration is effect-based: disposing the plugin fiber unregisters the tool. Schemas flow into the system-prompt assembly automatically.
## Rules of the execute() contract
@@ -89,6 +89,6 @@ Hard rules (they bite if broken):
The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Host/client runtimes map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations.
## Tests every tool needs
## Verification
Cover argument rejection, every canonical value and Native rendering shape, output-schema rejection, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and projected `tool/result` session events; prove the canonical value itself is not persisted. For a UI card, assert the exact `presentCall` and `presentResult` views and exercise the owning host/client projection. Add an assembled snapshot for the shipped model or UI behavior the tool changes.
Follow the [repository testing policy](../testing.md) and the owning package's test documentation. A shipped model- or UI-visible change requires the assembled coverage specified there.
+5 -5
View File
@@ -1,8 +1,8 @@
# 实操手册:添加工具
# 工具编写参考
[English](adding-a-tool.md) | 中文
如何为模型赋予一项新能力。下文的最小形态展示这项契约;`packages/bash/tool-bash` 是生产级、由三个包(package)构成的 seam
面向模型的工具必须满足哪些契约,均以本文为准。如需按步骤构建第一个工具,请阅读[构建工具](../user/develop/basic/tool.md)。`packages/bash/tool-bash` 是生产级的三包示例
## 最小形态
@@ -35,7 +35,7 @@ export function apply(ctx: Context) {
}
```
注册基于副作用:dispose(资源释放)插件 fiber 即注销该工具(请编写 HMR(热模块替换)测试)。schema 会自动流入系统提示词的组装过程。
注册基于副作用:dispose(资源释放)插件 fiber 即注销该工具。schema 会自动流入系统提示词的组装过程。
## execute() 契约的规则
@@ -89,6 +89,6 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的
中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。host/client 运行时将每个 `card` 映射到各自的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)`dsh-tool-fs`generic/diff)和 `dsh-tool-bash`terminal)是参考实现。
## 每个工具必须的测试
## 验证
覆盖参数拒绝、每种规范值和 Native 渲染形态、输出 schema 拒绝以及 HMR dispose。对于有副作用的工具,使用脚本化的 `MockAdapter` 驱动真实工具通过 agent loop(智能体循环),并断言其 `tool/call` 和投影后的 `tool/result` 会话事件;同时证明规范值本身未被持久化。对于 UI 卡片,断言 `presentCall``presentResult` 的精确视图,并实际运行所属 host/client 投影。如果工具改变了已交付的模型或 UI 行为,请添加组装应用快照
遵循[仓库测试策略](../testing.md)和所属包的测试文档。已交付且面向模型或 UI 的变更必须提供其中规定的组装覆盖
@@ -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 docs/cookbook/adding-a-vendored-package.md
adding-a-vendored-package.md: a951a96f62d2ea3aa693a24d83bf46a1a12070cd
adding-a-vendored-package.zh.md: 66b71d9ac7901d40074a21213c4962d1db96db8f
adding-a-vendored-package.md: b85d74a3a09b27254883b88cb8e6587e32ed811c
adding-a-vendored-package.zh.md: 2927837a28d1e7b593090d581522f69f84504806
+2 -2
View File
@@ -53,7 +53,7 @@ Covered automatically by globs — no edits needed: root `package.json` workspac
```sh
pnpm install # registers the workspace
pnpm run typecheck
pnpm run build && pnpm run test && pnpm run constraints
pnpm run build && pnpm run constraints
```
The source `paths` map lives once in `tsconfig.base.json` and serves every graph. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor/<dir>/tsconfig.json`, not pulled into an aggregate's strict program ([layout](../development.md#typescript-project-layout)).
Run the behavior checks selected by the [testing policy](../testing.md). The source `paths` map lives once in `tsconfig.base.json` and serves every graph. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor/<dir>/tsconfig.json`, not pulled into an aggregate's strict program ([layout](../development.md#typescript-project-layout)).
@@ -53,7 +53,7 @@ vendored TypeScript 源码中的本地相对导入/导出在复制后使用显
```sh
pnpm install # registers the workspace
pnpm run typecheck
pnpm run build && pnpm run test && pnpm run constraints
pnpm run build && pnpm run constraints
```
源码 `paths` 映射只在 `tsconfig.base.json` 存在一份,服务所有图。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor/<dir>/tsconfig.json` 被引用,而非被拉入某个聚合的严格程序中([布局](../development.md#typescript-project-layout))。
请运行[测试政策](../testing.md)所选择的行为检查。源码 `paths` 映射只在 `tsconfig.base.json` 存在一份,服务所有图。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor/<dir>/tsconfig.json` 被引用,而非被拉入某个聚合的严格程序中([布局](../development.md#typescript-project-layout))。
@@ -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 docs/cookbook/adding-an-llm-adapter.md
adding-an-llm-adapter.md: a7f9dced70041653a0cb815147a07b6386d79e3e
adding-an-llm-adapter.zh.md: 494115bdd5cc2365feb0964f2bef4d6dc070d3f6
adding-an-llm-adapter.md: 4fcc646ed2eea8a6170027d01761887aa28b0045
adding-an-llm-adapter.zh.md: 35a671416f8160a6187a06f3dbd614dfe4faa778
+4 -7
View File
@@ -34,13 +34,10 @@ Registration is effect-based (HMR-safe); one adapter per provider route — dupl
Provider-specific thinking-mode toggles remain in the adapter's Config. Exact model metadata uses one provider-neutral capability seam: implement `resolveModel()` with provider/model identity and optional `context` and `reasoning` fields, declare a configured `defaultEffort` only when one exists, and honor the resolver's optional `AbortSignal`. Reasoning efforts are ordered opaque ids mapped to provider requests by the adapter. Preserve the adapter's authoritative selectable list, including an adapter-defined `off` when supported, without exposing final wire spellings or clamping unsupported values; an id need not equal its wire representation.
## Structure that worked
## Implementation structure
Split the adapter into testable stages (llm-deepseek's layout): wire types (`types.ts`, coverage-exempt) → request serializer → SSE/transport parser → chunk-translation state machine → a thin adapter class wiring them. Each stage gets its own unit suite.
Keep wire types, request serialization, transport parsing, chunk translation, and the adapter class as separate responsibilities; [`llm-deepseek`](../../packages/llm/llm-deepseek/README.md) is the reference layout.
## Testing
## Verification
- **Unit: mock the provider, not the harness.** A scripted `node:http` server speaking the provider's wire format covers happy paths, every error status, malformed payloads, premature closes, and aborts — no network, and it drives the 100% per-file coverage gate. Works for SDK-backed adapters too (point the SDK's baseURL at the mock).
- **Hostile framing tests.** Split stream payloads at arbitrary byte positions (including mid-UTF-8) — real networks do.
- **E2E: `tests/*.e2e.ts`** under `pnpm run test:e2e`, gated with `describe.skipIf(!process.env.MY_KEY)` so CI (no secrets) stays green. Cover representative model/provider/API families and every provider mode you map, a tool-call round trip INCLUDING the follow-up turn with results in history, and loose assertions only (substring/structure, bounded maxTokens — real models are nondeterministic).
- Register the e2e file pattern in `knip.json` (per-workspace `entry` override) or knip flags it unused.
Follow the [repository testing policy](../testing.md), which owns adapter coverage, real-provider checks, and published-entry requirements.
+4 -7
View File
@@ -34,13 +34,10 @@ export function apply(ctx: Context, config: Config) {
提供方特有的思考模式开关仍放在适配器的 Config 中。确切模型元数据使用一处提供方无关的能力 seam:实现 `resolveModel()`,返回提供方/模型身份以及可选的 `context` 和 `reasoning` 字段;仅当存在配置指定的默认值时才声明 `defaultEffort`;遵守解析模型时传入的可选 `AbortSignal`。推理(reasoning)强度是由适配器映射到提供方请求的有序不透明 ID。请保留适配器给出的权威可选列表,包括适配器在支持时定义的 `off`;不得暴露最终协议值的具体拼写,也不得自动调整不支持的值。ID 无需与其协议表示相同。
## 经验证有效的结构
## 实现结构
将适配器拆分为可测试的阶段(llm-deepseek 的布局):协议格式(wire format)类型(`types.ts`,豁免覆盖率)→ 请求序列化器 → SSE/传输解析器 → 分片转换状态机 → 一个将它们串联的薄适配器类。每个阶段配备独立的单元测试套件
让协议类型、请求序列化、传输解析、分片转换和适配器类分别承担独立职责;[`llm-deepseek`](../../packages/llm/llm-deepseek/README.md) 是参考布局
## 测试
## 验证
- **单元测试:mock 提供方,而非 harness。** 用脚本化的 `node:http` 服务器模拟提供方的协议格式,覆盖正常路径、所有错误状态码、畸形载荷、连接提前关闭和中止——无需网络,且能满足 100% 逐文件覆盖率门禁。对基于 SDK 的适配器同样适用(将 SDK 的 baseURL 指向 mock 服务器)
- **恶意分帧测试。** 在任意字节位置(包括 UTF-8 字符中间)切割流载荷——真实网络环境正是如此。
- **e2e`tests/*.e2e.ts`**,通过 `pnpm run test:e2e` 运行,以 `describe.skipIf(!process.env.MY_KEY)` 守卫,确保无密钥的 CI 保持绿色。覆盖具有代表性的模型/提供方/API 系列以及你映射的每种提供方模式、一次务必包含后续轮次(历史中带工具结果)的工具调用往返,以及仅做宽松断言(子串/结构匹配、有界的 maxTokens——真实模型是非确定性的)。
- 在 `knip.json` 中注册 e2e 文件模式(per-workspace `entry` 覆盖),否则 knip 会将其标记为未使用。
遵循[仓库测试策略](../testing.md),该策略负责适配器覆盖、真实提供方检查和已发布入口要求
+2 -2
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 docs/cookbook/extension-cookbook.md
extension-cookbook.md: 07073c39f8a9b998b09b0815257d995b174c8be7
extension-cookbook.zh.md: 10664af9a39f0a1663c316869ba8ef02f67c29fe
extension-cookbook.md: 66cdee676936029e4db03bf0c1a0436d58f29bcd
extension-cookbook.zh.md: 7e29d34b5777a4a4b19c8d3da2fc078df519c6d4
+1 -38
View File
@@ -2,9 +2,7 @@
English | [中文](extension-cookbook.zh.md)
> FIXME: This important guide has not received sufficient human design review; complete that review before the first release.
The three plugin shapes you write against the harness extension surface, as illustrative snippets (elided imports and helper stubs — not copy-paste-complete). For the full step-by-step guides see [adding a package](adding-a-package.md), [adding a tool](adding-a-tool.md), and [adding an LLM adapter](adding-an-llm-adapter.md); for the seams these hook into see [docs/architecture.md](../architecture.md).
Reference shapes for the harness extension surface. The snippets omit imports and helper implementations and are not copy-paste-complete. For concrete authoring paths, see the [package checklist](adding-a-package.md), [first-tool tutorial](../user/develop/basic/tool.md), [tool reference](adding-a-tool.md), and [LLM adapter guide](adding-an-llm-adapter.md); the [architecture](../architecture.md) owns the system and extension-seam map.
## A tool plugin
@@ -92,38 +90,3 @@ export function apply(ctx: Context) {
## Runnable wirings
Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), JSON-RPC leaves use [`@deepseek-ai/dsh-jsonrpc-demo`](../../packages/examples/jsonrpc-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo).
## The feature → mechanism map
Every product feature maps to a listener on a documented extension seam — the microkernel claim made checkable ([microkernel Agent Note](../../.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md)). No row modifies the loop.
`system-prompt/assemble` is an expert cooperative whole-assembly transform: its returned assembly is authoritative, so listener authors own preserving active Code Mode and structured-output protocol contributions. Prefer `ctx.tools.restrict()` for tool filtering that must stay aligned across presentation, lookup, and execution.
| Product feature | Plugin mechanism |
|---|---|
| Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfall seams return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams |
| `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control |
| `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue |
| Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and the structured-output execution's monotonic `concludeTurn()` marker |
| Queued + steering messages | core `Agent.followup()` / `Agent.steer()` |
| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) |
| System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing |
| AGENTS.md (root) | a section provider reading the file |
| AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener |
| Built-in tools | `ctx.tools.register()`; schemas flow into the assembly automatically — the `dsh-tool-*` families (bash, fs, web, subagent, todo) are the shipped examples |
| ToolSearch / progressive disclosure | replace a scoped `ctx.tools.restrict()` registration as the visible set changes; the registry keeps presentation, lookup, and execution aligned |
| Tool deadline / retry / metrics | wrap core dispatch with `tools/execute`; a wrapper may replace `exec.signal`, delegate, and inspect the normalized result in one lexical lifetime |
| Final tool-result metrics / audit / capture | observe immutable authoritative outcomes with `tools/result`; use `tools/post-execute` instead only when the plugin must transform the result or attach context |
| Monotonic terminal turn policy | call `ToolExecution.concludeTurn()` from the successful terminal tool; later tool calls in the same response remain guardable, and the loop stops after the step |
| Subprocess sandbox (landlock / sandbox-exec) | use a `ctx.sandbox` backend through `dsh-bash-sandbox`; use `tools/pre-execute` for capability-level denial |
| Permission system / AskUserQuestion | return `ask` from `tools/pre-execute` and answer through `ctx.approval`; register a separate model-facing ask tool for ordinary user questions |
| Plan mode | Shipped: [`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — logged `plan/mode` state, the `plan:policy` guidance section, `/plan [message]` entry, `/plan off` direct exit, and the user-reviewed `exit_plan_mode` exit; enforcement stays on the independent sandbox/approval axes |
| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`) + `dsh-tool-subagent` exposing one configured provider to the model |
| MCP | one plugin per server: discover tools → `ctx.tools.register()` |
| Skills | section + tool registration; `inject()` skill content on invocation |
| Memory | section provider + tool |
| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy |
| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` |
| Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` |
| Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) |
| Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works |
+1 -38
View File
@@ -2,9 +2,7 @@
[English](extension-cookbook.md) | 中文
> FIXME:这篇重要指南尚未经过充分的人工设计审查;请在首次发布前完成审查
针对 harness 扩展表面编写的三种插件形态,以示意性代码片段呈现(省略了 import 和辅助桩——不可直接复制运行)。完整的分步指南见[添加包(package](adding-a-package.md)、[添加工具](adding-a-tool.md)和[添加 LLM(大语言模型)适配器](adding-an-llm-adapter.md);这些插件所挂接的 seam 见 [docs/architecture.md](../architecture.md)。
harness 扩展表面的参考形态。代码片段省略了 import 和辅助实现,无法直接复制运行。具体编写路径见[包检查清单](adding-a-package.md)、[第一个工具教程](../user/develop/basic/tool.md)、[工具参考](adding-a-tool.md)和 [LLM(大语言模型)适配器指南](adding-an-llm-adapter.md);系统与扩展 seam 映射由[架构文档](../architecture.md)负责
## 工具插件
@@ -92,38 +90,3 @@ export function apply(ctx: Context) {
## 可运行的组装示例
可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo)ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo)JSON-RPC 叶子使用 [`@deepseek-ai/dsh-jsonrpc-demo`](../../packages/examples/jsonrpc-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。
## 功能→机制映射
每个产品功能都映射到一个文档化扩展 seam 上的监听器——微内核声明由此可验证([微内核 Agent Note](../../.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md))。没有任何一行修改循环本身。
`system-prompt/assemble` 是一个专家协作式的整体装配变换:其返回的装配结果具有权威性,因此监听器作者有责任保留活跃的 Code Mode 和结构化输出协议的贡献。对于需要在展示、查找和执行之间保持对齐的工具过滤,优先使用 `ctx.tools.restrict()`
| 产品功能 | 插件机制 |
|---|---|
| 钩子系统(用户级 + 项目级) | `agent/session-start``agent/prompt-submit``agent/request``tools/pre-execute``tools/post-execute``agent/turn-stopping` 上的监听器;waterfall seam 返回类型化决策,`agent/turn-stopping` 则可通过 steering 触发下一步;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 |
| `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 |
| `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 |
| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和结构化输出执行的单调 `concludeTurn()` 标记来强制输出 |
| 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` |
| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) |
| 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 |
| AGENTS.md(根目录) | 一个读取该文件的 section provider |
| AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` |
| 内置工具 | `ctx.tools.register()`schema 自动流入装配——`dsh-tool-*` 系列(bash、fs、web、subagent、todo)是已交付的示例 |
| ToolSearch / 渐进式披露 | 当可见集变化时替换一个作用域化的 `ctx.tools.restrict()` 注册;注册表保持展示、查找和执行三者对齐 |
| 工具截止时间 / 重试 / 指标 | 用 `tools/execute` 包裹核心分发;包装器可替换 `exec.signal`、委托执行,并在同一词法生命周期内检视规范化结果 |
| 最终工具结果指标 / 审计 / 捕获 | 用 `tools/result` 观察不可变的权威结果;仅当插件需要变换结果或附加上下文时才使用 `tools/post-execute` |
| 单调终端轮次策略 | 从成功的终端工具调用 `ToolExecution.concludeTurn()`;同一响应中后续工具调用仍可由守卫阻止,循环在该步骤后停止 |
| 子进程沙箱(landlock / sandbox-exec | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` |
| 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 |
| Plan mode | 已交付:[`@deepseek-ai/dsh-plan-mode`](../../packages/plan/plan-mode/README.md) — 落日志的 `plan/mode` 状态、`plan:policy` 引导段、`/plan [message]` 入口、`/plan off` 直接退出,以及经用户评审的 `exit_plan_mode` 出口;强制约束留在独立的沙箱/审批轴上 |
| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 |
| MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` |
| Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 |
| 记忆 | section provider + 工具 |
| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 |
| UIGUICLI 输出 JSONL | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` |
| 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` |
| 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek``dsh-llm-pi-ai` |
| 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 |
+8
View File
@@ -1735,6 +1735,14 @@ Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages
Abstract settings service. Providers implement raw-document storage (`load`/`persist`) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the `settings/updated` commit event.
```ts cordis-catalog
/**
* Prepare the provider's user-editable document for a native editor. File
* providers may materialize an absent document before returning its path;
* non-file providers return undefined.
* @returns the absolute local document path, or undefined for non-file storage.
*/
prepareDocument(): Promise<string | undefined>
/**
* Register a namespace schema and receive its owner scope. The registration
* is an effect on the calling plugin's fiber: disposing that fiber removes
@@ -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 docs/core-data-structures/llm-streaming.md
llm-streaming.md: 28f775f81049d3aa0198e75fbb6c4544302e772d
llm-streaming.zh.md: db6b28acb844033025b3c5ffef075c9c3153151e
llm-streaming.md: 8f5b917ee19044eeef70aadf68cff752b8fba76a
llm-streaming.zh.md: 10e04f0856a96f86c2145cdca51edb9e28020e67
+2 -2
View File
@@ -64,10 +64,10 @@ Every adapter MUST obey these, and every consumer may rely on them:
- **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`.
- **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text.
- **An empty completion is a retryable error, not a silent success.** Both adapters map a terminal `stop` finish that carried no content blocks to `finish {kind:'error'}` with the canonical `EMPTY_RESPONSE` code, and `dsh-llm-retry` retries it by default; see [empty model responses are retryable](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md).
- **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter).
- **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below), the `User-Agent` baseline.
- **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state.
This contract is pinned down by two deliberately independent implementations: `dsh-llm-deepseek` (direct fetch, SSE framing via `eventsource-parser`) and `dsh-llm-pi-ai` (a generic multi-provider adapter through `@earendil-works/pi-ai`). The library-backed adapter exercises the finish-chunk error path, while transport-boundary tests prove each idle watchdog stops its actual request.
Two independent implementations obey this contract: `dsh-llm-deepseek` uses direct fetch with SSE framing through `eventsource-parser`, while `dsh-llm-pi-ai` provides a generic multi-provider adapter through `@earendil-works/pi-ai`. Both carry cancellation and the idle watchdog to the provider request.
## `ResolvedRetryPolicy`
@@ -64,10 +64,10 @@ interface LlmFailure {
- **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`,默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。
- **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。
- **空 completion 是可重试错误,而不是静默的成功结果。** 两个适配器都把没有携带任何内容块的终止性 `stop` 结束映射为携带规范 `EMPTY_RESPONSE` code 的 `finish {kind:'error'}``dsh-llm-retry` 默认会重试它;详见[空模型响应可重试](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md)。
- **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)
- **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送下文的 `attributionHeaders()`,即 `User-Agent` 基线。
- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。
该契约由两个有意保持独立的实现锁定`dsh-llm-deepseek`直接 fetchSSEServer-Sent Events)分帧经由 `eventsource-parser`)和 `dsh-llm-pi-ai`通过 `@earendil-works/pi-ai` 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。
两个彼此独立的实现遵循该契约:`dsh-llm-deepseek` 使用直接 fetch并通过 `eventsource-parser` 进行 SSEServer-Sent Events)分帧;`dsh-llm-pi-ai`通过 `@earendil-works/pi-ai` 提供通用多提供方适配器。两者都会把取消与空闲 watchdog 传递至提供方请求。
## `ResolvedRetryPolicy`
@@ -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 docs/core-data-structures/persistence.md
persistence.md: 237ce268691fa6f4b0546c6c1c14c21eaa462612
persistence.zh.md: 9cb555f2e157844704896d9b04feb822428540e5
persistence.md: ed19af4e739c153ce1beec7c1e8de06f0c0bca53
persistence.zh.md: 83d8ba3c0eabe57b9df661fb86ec05d3db98d8b6
+1 -1
View File
@@ -4,7 +4,7 @@ English | [中文](persistence.zh.md)
The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md).
The seam is a textbook [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append, crash-repairing load, non-mutating inspect, and lightweight list/snapshot observation over the existing `SessionEvent`**no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md).
The seam is a textbook [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append, crash-repairing load, non-mutating inspect, and lightweight list/snapshot observation over the existing `SessionEvent`**no parallel persisted type** — and two interchangeable backends implementing the same contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md).
## The flush checkpoint
+1 -1
View File
@@ -4,7 +4,7 @@
事件日志的**持久性 seam**。[session.md](session.md) 描述了内存中的 `Session`:仅追加的 `SessionEvent` 日志即为真源。本页描述如何使该日志持久化:抽象的 `SessionPersistence` 服务、它的后端、flush 检查点、崩溃恢复,以及随日志一同存储的元数据头。日志承载的事件词汇在生成的[持久化日志事件目录](../persistence-catalog.md)中逐项列举。
该 seam 是典型的[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence)`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、会执行崩溃修复的 load、不会修改数据的 inspect,以及轻量的 list/snapshot 观察——**没有平行的持久化类型**——以及两个可互换、通过同一套 `runPersistenceContract`后端。见 [session-persistence Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。
该 seam 是典型的[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session-persistence/session-persistence)`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、会执行崩溃修复的 load、不会修改数据的 inspect,以及轻量的 list/snapshot 观察——**没有平行的持久化类型**——以及两个实现同一契约的可互换后端。见 [session-persistence Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。
## flush 检查点
+2 -2
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 docs/core-data-structures/sandbox.md
sandbox.md: b931dca54afd1fdd1dcba5b8e7778feeedb7d15d
sandbox.zh.md: a2334148e0237db2309c77437cf0681586e2fb5a
sandbox.md: 9e5feafe046f18dad49aeaf281793f0b8e03c240
sandbox.zh.md: a1314d1b78eb0d46ea4c8ca5aa330ee83bf89132
+3 -3
View File
@@ -139,10 +139,10 @@ interface ConfinedArgv {
}
```
The operator-facing local-provider key remains `runnerFailureSignatures`: an operator-configured runner must supply at least one non-empty, single-line, case-insensitive substring for its own pre-exec refusal dialect. The provider maps those entries into one rule. Consumers directly spawn `ConfinedArgv.argv`, so a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable rejects through the spawn channel rather than a stderr rule when Node supplies attributable `ENOENT`/`EACCES` evidence; after a process starts, child exits such as 126 or 127 remain ordinary unless the selected runner's documented fatal signature matches.
The [local provider](../../packages/sandbox/sandbox-local/README.md) owns operator configuration and maps its runner dialect into these rules. The [sandboxed bash consumer](../../packages/bash/bash-sandbox/README.md) owns spawn and result attribution.
## Provider and fail-closed errors
`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. Any direct spawn rejection of the returned argv proves the confined launch never started, but only `ENOENT` or `EACCES` with positive Node provenance for provider argv[0] after the caller-owned workdir is independently verified usable carries infrastructure meaning and the original error as detail. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the consumer's ordinary command-start semantics. After a process starts, a matching structured rule identifies a runner refusal. Silent unconfined passthrough is never legal for a confined policy.
`ctx.sandbox.confine(argv, policy)` returns a `ConfinedArgv` or throws `SandboxUnavailableError` with code `SANDBOX_UNAVAILABLE` when no usable backend exists. Consumers may also classify a failure while spawning or observing the returned argv; that attribution belongs to the consumer contract. Silent unconfined passthrough is never legal for a confined policy.
Provider probing arbitrates between multiple candidates and is cached for the provider lifetime. A platform with one candidate may select it directly; execution-time refusal retains the safety property. The local provider reports bwrap and Seatbelt as full and preserves the Landlock launcher's full/partial kernel verdict.
Provider selection, probing, caching, and backend-specific enforcement reports belong to the [local provider](../../packages/sandbox/sandbox-local/README.md).
+3 -3
View File
@@ -139,10 +139,10 @@ interface ConfinedArgv {
}
```
面向运维人员的本地提供方配置键仍为 `runnerFailureSignatures`:运维人员配置的 runner 必须为自身的 pre-exec 拒绝方言提供至少一个非空、单行、不区分大小写的子串。提供方会将这些条目映射到一条规则。消费方直接 spawn `ConfinedArgv.argv`,因此当 Node 提供可归因的 `ENOENT``EACCES` 证据时,缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本会在 spawn 通道遭拒,而不是由 stderr 规则判定;进程启动后,126 或 127 等子进程退出码仍按普通结果处理,除非匹配所选 runner 文档所定义的致命签名
[本地提供方](../../packages/sandbox/sandbox-local/README.md)拥有运维配置,并将其 runner 方言映射到这些规则。[沙箱化 bash 消费方](../../packages/bash/bash-sandbox/README.md)拥有 spawn 与结果归因
## 提供方与 fail-closed 错误
`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。直接 spawn 所返回的 argv 时,任何拒绝都能证明受限启动从未开始;但只有在调用方拥有的 workdir 经独立验证可用,且 `ENOENT` 或 `EACCES` 带有明确指向提供方 argv[0] 的 Node 来源信息时,该拒绝才具有基础设施含义,并以原始错误作为详细信息。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留消费方的普通命令启动语义。进程启动后,匹配到的结构化规则标识 runner 拒绝。对于受限策略,静默的无隔离透传永远不合法。
`ctx.sandbox.confine(argv, policy)` 返回一个 `ConfinedArgv`,或在没有可用后端时抛出 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)。消费方也可以在 spawn 或观察所返回的 argv 时对失败进行分类;该归因属于消费方契约。对于受限策略,静默的无隔离透传永远不合法。
提供方探测在多个候选后端之间仲裁,结果在提供方生命周期内缓存。只有一个候选后端的平台可以直接选定它;执行时拒绝仍保留安全属性。本地提供方将 bwrap 和 Seatbelt 报告为 full,并保留 Landlock 启动器的 full/partial 内核裁定
提供方选择、探测、缓存和后端专有的强制执行报告归[本地提供方](../../packages/sandbox/sandbox-local/README.md)所有
+2 -2
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 docs/core-data-structures/session.md
session.md: 1045915da0b3b6b527d3d859e3566ffc77b501d3
session.zh.md: 53e22ca8e9dc17a758c5fd00e6818250e4889680
session.md: c5491b8d6b44c0a86ce5804320533925a0e6e287
session.zh.md: 3a713c77e972096cf95223701e8aff4cb9d0ff87
+1 -1
View File
@@ -368,7 +368,7 @@ declare class Session {
/**
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
* `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
* `Session` is created without a store-owned header, a minimal header is
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
* `session.header` is always present. Kept out of the event log — it is a
* storage concern, not replayable conversation state.
+1 -1
View File
@@ -370,7 +370,7 @@ declare class Session {
/**
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
* `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
* `Session` is created without a store-owned header, a minimal header is
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
* `session.header` is always present. Kept out of the event log — it is a
* storage concern, not replayable conversation state.
+2 -2
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 docs/defensive-patterns.md
defensive-patterns.md: cc34877fb0d6a2e1740d8fa138f879363c8e69a3
defensive-patterns.zh.md: 277e4904d033e26d379e0495cb2b22370ac159d0
defensive-patterns.md: f9737cd6ba8d2bf1f926c962b3842885778f9af4
defensive-patterns.zh.md: 26a8933401cef39287bab8a48a07527e0c86dc1f
+1 -1
View File
@@ -18,7 +18,7 @@ When an interface documents two valid ways to signal something — an adapter ma
## Dispose must reach quiescence, not just request it
A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. Tests prove disposal waited (pid gone right after `await fiber.dispose()`), not merely that the process eventually dies.
A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent.
## Contain callback exceptions at the boundary
+1 -1
View File
@@ -18,7 +18,7 @@
## Dispose 必须达到完全停稳,而不仅仅是请求停止
如果清理流程只发出终止或中止信号便返回,而不等待工作真正停止,就会留下孤儿进程。清理逻辑应采用异步流程,并等待子进程退出(发出终止信号后等待 `done`);还应在终止进程前关闭监听器和通知注册表,使迟到的完成事件保持静默。测试必须证明 dispose 确实等待了,例如 `await fiber.dispose()` 返回后进程 ID 已不存在,而不能只证明该进程最终会退出。
如果清理流程只发出终止或中止信号便返回,而不等待工作真正停止,就会留下孤儿进程。清理逻辑应采用异步流程,并等待子进程退出(发出终止信号后等待 `done`);还应在终止进程前关闭监听器和通知注册表,使迟到的完成事件保持静默。
## 在边界处隔离回调异常
+2 -2
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 docs/development.md
development.md: 77087a95fdb4cfbeb02111ef2560d6989cde5686
development.zh.md: 18e130e2f3aa1c53f6efa70366cdb629df71ad85
development.md: 30a2bd0a2c97df8d3d75ec50f47b861b3a65590e
development.zh.md: 5582a85429c97c3e31517a495c69392b80885f7d
+21 -52
View File
@@ -2,16 +2,18 @@
English | [中文](development.zh.md)
This onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.
The setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.
## Prerequisites
## Setup tutorial
### Prerequisites
- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).
- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.
- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.
- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.
## First-time setup
### First-time setup
Install dependencies from the repo root:
@@ -19,7 +21,7 @@ Install dependencies from the repo root:
pnpm install
```
The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).
The install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.
If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:
@@ -27,11 +29,7 @@ If hooks are missing because dependencies were restored from cache or `postinsta
node scripts/install-lefthook.mjs
```
The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.
Before enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.
After moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.
If the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.
Run typecheck once after a fresh clone:
@@ -39,9 +37,13 @@ Run typecheck once after a fresh clone:
pnpm run typecheck
```
That first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.
Setup is complete when `pnpm run typecheck` exits successfully.
## TypeScript project layout
## Contributor reference
### TypeScript project layout
The repository typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through two no-emit aggregates.
The repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.
@@ -68,7 +70,7 @@ pnpm run build
`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.
## Environment variables
### Environment variables
The real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:
@@ -79,7 +81,7 @@ DEEPSEEK_BASE_URL=https://... # optional
`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.
## Git hooks
### Git hooks
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
@@ -92,44 +94,15 @@ The hooks intentionally do not run tests, snapshots, documentation checks, build
Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.
## CI gates
### CI gates
The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.
## Daily commands
### Daily commands
Use these from the repo root:
The root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.
```sh
pnpm run test # unit tests
pnpm run test:coverage # unit tests with per-file coverage gates
pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY
pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks
pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates
pnpm run lint # oxlint .
pnpm run lint:fix # formatting-only ESLint, then oxlint . --fix
pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs
pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source
pnpm run verify-cordis-catalog # fail if either cordis catalog is stale
pnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc
pnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions
pnpm run verify-doc-graphs # fail if generated relationship docs are stale
pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown
pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax
pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type
pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling
pnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)
pnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list
pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps
pnpm run verify-module-graph # fail if docs/module-graph.md is stale
pnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files
pnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable
pnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check
```
When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.
## Demos
### Demos
The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:
@@ -149,7 +122,7 @@ The ACP automation server exposes fresh agent sessions over JSON-RPC stdio and a
pnpm run demo:acp
```
## TODO markers
### TODO markers
Use one of three comment tags to flag known issues in the code, ordered by urgency:
@@ -159,7 +132,7 @@ Use one of three comment tags to flag known issues in the code, ordered by urgen
Pick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.
## Documenting types verbatim (`ts type-equiv`)
### Documenting types verbatim (`ts type-equiv`)
The [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:
@@ -168,7 +141,3 @@ The [core data structures](core-data-structures/core.md) docs paste source-equiv
```
`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.
## Architecture context
Read `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.
+21 -52
View File
@@ -2,16 +2,18 @@
[English](development.md) | 中文
本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。
搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本
## 前置条件
## 搭建教程
### 前置条件
- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。
- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`
- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。
- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACPAgent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。
## 首次搭建
### 首次搭建
在仓库根目录安装依赖:
@@ -19,7 +21,7 @@
pnpm install
```
安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true``GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。
安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责
如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:
@@ -27,11 +29,7 @@ pnpm install
node scripts/install-lefthook.mjs
```
包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除
启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree``core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。
检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。
如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。
新克隆后请先运行一次类型检查:
@@ -39,9 +37,13 @@ node scripts/install-lefthook.mjs
pnpm run typecheck
```
首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本
`pnpm run typecheck` 成功退出即表示搭建完成
## TypeScript 项目布局
## 贡献者参考
### TypeScript 项目布局
仓库类型检查会执行全仓 `tsc -b` 图:它会发射每个 package/vendor 的 `lib/types`,并通过两个 no-emit 聚合检查示例、测试和脚本。
仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。
@@ -68,7 +70,7 @@ pnpm run build
`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。
## 环境变量
### 环境变量
真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:
@@ -79,7 +81,7 @@ DEEPSEEK_BASE_URL=https://... # optional
`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。
## Git 钩子
### Git 钩子
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
@@ -92,44 +94,15 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v
贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。
## CI 门禁
### CI 门禁
keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。
## 日常命令
### 日常命令
在仓库根目录使用:
根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`
```sh
pnpm run test # unit tests
pnpm run test:coverage # unit tests with per-file coverage gates
pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY
pnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks
pnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates
pnpm run lint # oxlint .
pnpm run lint:fix # formatting-only ESLint, then oxlint . --fix
pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs
pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source
pnpm run verify-cordis-catalog # fail if either cordis catalog is stale
pnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc
pnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions
pnpm run verify-doc-graphs # fail if generated relationship docs are stale
pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown
pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax
pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type
pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling
pnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)
pnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list
pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps
pnpm run verify-module-graph # fail if docs/module-graph.md is stale
pnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files
pnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable
pnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check
```
修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。
## 演示
### 演示
单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`
@@ -149,7 +122,7 @@ ACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样
pnpm run demo:acp
```
## TODO 标记
### TODO 标记
请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:
@@ -159,7 +132,7 @@ pnpm run demo:acp
请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。
## 逐字记录类型(`ts type-equiv`
### 逐字记录类型(`ts type-equiv`
[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:
@@ -168,7 +141,3 @@ pnpm run demo:acp
```
`pnpm run verify-type-equiv``doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。
## 架构上下文
在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。
+2 -2
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 docs/i18n/README.md
README.md: d1fff61a1d3db7fbe93d3c95b144598c2ca41c8b
README.zh.md: 9f1d61f1b854ead0ef5851157cce2e9a6de83d89
README.md: 2bac578034441bbe786ce54c051cc622bf9275c0
README.zh.md: b7566bf6f88e03eb06227b63011f78252b4dcd07
+1 -1
View File
@@ -43,7 +43,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):
- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.
- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, `docs/module-graph.md`, `docs/agent-lifecycle.md`, `docs/capability-seams.md`, `docs/event-producer-consumer.md`, `docs/graph-atlas.md`, and `docs/tool-execution-pipeline.md` — generated files whose generators emit English only; a hand-written translation would go stale on regeneration.
- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.
- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.
- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.
+1 -1
View File
@@ -43,7 +43,7 @@
**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):
- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件生成器目前只输出英文手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单
- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md`、`docs/module-graph.md`、`docs/agent-lifecycle.md`、`docs/capability-seams.md`、`docs/event-producer-consumer.md`、`docs/graph-atlas.md` 与 `docs/tool-execution-pipeline.md`:生成文件,其生成器只输出英文手写译文在重新生成时变得陈旧
- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。
- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。
- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。
@@ -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 docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md
0004-landlock-partial-notice-misclassified-child-failures.md: b3be42d221623c70796105a593127971660ebc2b
0004-landlock-partial-notice-misclassified-child-failures.zh.md: 7a057d4405fcc6733e9c86cbbbbfff9bf8012641
0004-landlock-partial-notice-misclassified-child-failures.md: db810fdc896f9734d1b581617838d72166f4efc9
0004-landlock-partial-notice-misclassified-child-failures.zh.md: 4a31fb038c44b036e6a183040947295a47221967
@@ -6,7 +6,7 @@ Status: resolved
## Executive summary
On kernels with an older Landlock ABI, the launcher prints a benign partial-enforcement notice before executing every child. The harness treated that shared `landlock-run:` prefix plus any nonzero child exit as launcher failure, so ordinary outcomes such as ripgrep's exit 1 for no matches surfaced as `SANDBOX_UNAVAILABLE`; the then-bash-backed filesystem search also hid that structured error behind `SEARCH_FAILED`. Broad signature rules and missing partial-ABI composition coverage let the defect through. Runner classification now requires status-gated fatal evidence after exact informational exclusions, and an assembled keyless scenario pins the surviving bash path. Filesystem search has since moved to packaged ripgrep through the subprocess seam and no longer crosses sandboxed bash.
On kernels with an older Landlock ABI, the launcher prints a benign partial-enforcement notice before executing every child. The harness treated that shared `landlock-run:` prefix plus any nonzero child exit as launcher failure, so ordinary outcomes such as ripgrep's exit 1 for no matches surfaced as `SANDBOX_UNAVAILABLE`; the then-bash-backed filesystem search also hid that structured error behind `SEARCH_FAILED`. Broad signature rules and missing partial-ABI composition coverage let the defect through. Runner classification now requires status-gated fatal evidence after exact informational exclusions, and an assembled keyless scenario pins the surviving bash path. Filesystem search uses packaged ripgrep through the subprocess seam and does not cross sandboxed bash.
## Summary
@@ -28,7 +28,7 @@ The defect did not weaken confinement or run a command unconfined. Its security
- The sandbox provider reduced that contract to `runnerFailureSignatures: ['landlock-run: ']`; the bash consumer combined the prefix with any nonzero exit and reported stderr's first line.
- Unit tests covered clean success, denial diagnostics, and fatal runner prefixes. Real-runner tests self-skipped without a usable kernel and did not force partial enforcement followed by a nonzero child.
- A minimal POSIX wrapper that prints the notice and `exec`s its payload reproduced the failure with `false` and ripgrep no-match.
- Structured rules plus shared foreground/background classification and assembled replay coverage closed the surviving sandbox attribution gap. Before this fix was reconciled with current `master`, filesystem search moved to packaged ripgrep through `ctx.subprocess`; the obsolete bash-adapter patch and tests were dropped instead of reintroducing the old architecture.
- Structured rules plus shared foreground/background classification and assembled replay coverage closed the surviving sandbox attribution gap. Filesystem search uses packaged ripgrep through `ctx.subprocess`; the fix leaves that path outside sandboxed bash.
## Root cause
@@ -43,9 +43,9 @@ Stderr remains an in-band attribution channel. A confined child can deliberately
- [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) carries optional allowed exit codes, case-insensitive per-line fatal signatures, and case-insensitive exact informational-line exclusions.
- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) maps Landlock to exit 125 plus a non-notice `landlock-run:` line while bwrap, Seatbelt, and custom runners remain signature-only.
- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) directly spawns the provider argv, so a pre-start rejection uses the spawn-error channel instead of localized shell diagnostics. Settled foreground and background execution share one evidence-returning classifier; fatal evidence outranks denial, and foreground errors report the matched fatal line without changing captured stderr.
- Current [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) uses packaged ripgrep through `ctx.subprocess` and no longer consumes the sandboxed bash seam; the base reconciliation keeps that architecture unchanged.
- Deterministic tests use a POSIX fake partial-Landlock launcher to cover notice-only child exits 1, 2, and 125, ordinary child exits 126 and 127, gated fatal diagnostics, permission denial, and foreground/background parity.
- The `examples/acp-agent` keyless snapshot runs direct bash `false` through a test-only partial-Landlock provider, keeping the product regression pinned independently of filesystem-search implementation choices.
- [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) uses packaged ripgrep through `ctx.subprocess` and remains outside the sandboxed bash seam.
- The native-boundary regression cases live in [`partial-landlock.spec.ts`](../../packages/bash/bash-sandbox/tests/partial-landlock.spec.ts), including informational notices, fatal evidence, and foreground/background classification.
- The assembled product path is pinned by the [`partial-landlock` snapshot composition](../../examples/acp-agent/partial-landlock.cordis.snapshot.yml), independently of filesystem-search implementation choices.
## Lessons
@@ -6,7 +6,7 @@ Status: resolved
## 摘要
在 Landlock ABI 较旧的内核上,launcher 会在执行每个子进程前打印一条无害的部分强制执行通知。harness 把共享的 `landlock-run:` 前缀与任意非零子进程退出组合起来,判定为 launcher 失败,因此 ripgrep 在没有匹配项时以 1 退出等普通结果会呈现为 `SANDBOX_UNAVAILABLE`;当时仍由 bash 支撑的文件系统搜索还会用 `SEARCH_FAILED` 遮蔽这个结构化错误。过于宽泛的签名规则,以及缺少较旧 ABI 下部分强制执行的组合测试覆盖,让该缺陷得以流入。runner 分类现在会先精确排除信息性行,再要求由退出状态门控的致命证据,并由一个组装后的无密钥场景固定仍然存在的 bash 路径。文件系统搜索后来已改为通过 subprocess seam 运行打包的 ripgrep,不经过沙箱化 bash。
在 Landlock ABI 较旧的内核上,launcher 会在执行每个子进程前打印一条无害的部分强制执行通知。harness 把共享的 `landlock-run:` 前缀与任意非零子进程退出组合起来,判定为 launcher 失败,因此 ripgrep 在没有匹配项时以 1 退出等普通结果会呈现为 `SANDBOX_UNAVAILABLE`;当时仍由 bash 支撑的文件系统搜索还会用 `SEARCH_FAILED` 遮蔽这个结构化错误。过于宽泛的签名规则,以及缺少较旧 ABI 下部分强制执行的组合测试覆盖,让该缺陷得以流入。runner 分类现在会先精确排除信息性行,再要求由退出状态门控的致命证据,并由一个组装后的无密钥场景固定仍然存在的 bash 路径。文件系统搜索通过 subprocess seam 运行打包的 ripgrep,不经过沙箱化 bash。
## 概述
@@ -28,7 +28,7 @@ harness 用一个不区分大小写的 `landlock-run: ` 子串表示这两种情
- 沙箱提供方把该契约简化为 `runnerFailureSignatures: ['landlock-run: ']`;bash 消费方将此前缀与任意非零退出组合,并报告 stderr 的第一行。
- 单元测试覆盖了无诊断的成功、拒绝诊断和致命 runner 前缀。真实 runner 测试在没有可用内核时会自行跳过,也没有强制构造「部分强制执行通知后跟非零子进程退出」的情况。
- 一个最小 POSIX 包装脚本会打印该通知并 `exec` 其负载;它通过 `false` 与 ripgrep 无匹配场景复现了故障。
- 结构化规则、前台与后台共享的分类逻辑和组装后的回放覆盖共同弥补了仍然存在的沙箱归因缺口。本修复与当前 `master` 对齐前,文件系统搜索已改为通过 `ctx.subprocess` 运行打包的 ripgrep合并时删除了过时的 bash 适配器补丁与测试,而没有把旧架构重新引入
- 结构化规则、前台与后台共享的分类逻辑和组装后的回放覆盖共同弥补了仍然存在的沙箱归因缺口。文件系统搜索通过 `ctx.subprocess` 运行打包的 ripgrep本修复让该路径继续位于沙箱化 bash 之外
## 根因
@@ -43,9 +43,9 @@ stderr 仍是带内归因通道。受限子进程可以故意复现 runner 的
- [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) 携带可选的允许退出码、不区分大小写的逐行致命签名,以及按不区分大小写的整行精确匹配排除的信息性行。
- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) 把 Landlock 映射为退出码 125 加一行非通知的 `landlock-run:` 诊断,而 bwrap、Seatbelt 和自定义 runner 仍仅依据签名。
- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) 直接 spawn 提供方 argv,因此启动前遭拒时使用 spawn 错误通道,而非本地化的 shell 诊断。已结算的前台与后台执行共用一个返回证据的分类器;致命证据优先于拒绝,前台错误会报告匹配到的致命行,同时保持捕获的 stderr 不变。
- 当前 [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) 通过 `ctx.subprocess` 运行打包的 ripgrep不再消费沙箱化 bash seam;与新基线对齐时保持该架构不变
- 确定性测试使用一个模拟 Landlock 部分强制执行行为的 POSIX launcher,覆盖仅带通知的子进程退出码 1、2、125,普通子进程退出码 126、127,带门控的致命诊断、权限拒绝,以及前台/后台一致性
- `examples/acp-agent` 的无密钥快照会通过仅用于测试的部分 Landlock 提供方直接运行 bash `false`,从而独立于文件系统搜索的实现选择固定产品层回归
- [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) 通过 `ctx.subprocess` 运行打包的 ripgrep并继续位于沙箱化 bash seam 之外
- 原生边界回归用例位于 [`partial-landlock.spec.ts`](../../packages/bash/bash-sandbox/tests/partial-landlock.spec.ts),包括信息性通知、致命证据和前台/后台分类
- 组装后的产品路径由 [`partial-landlock` 快照组合](../../examples/acp-agent/partial-landlock.cordis.snapshot.yml)固定,独立于文件系统搜索的实现选择。
## 教训
+2 -2
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 docs/testing.md
testing.md: 728c65f1911cbaa098f653af9436a92336fbe068
testing.zh.md: 72b7c3bcfa69f4c65fc128dbcbb19c080fb0fbc4
testing.md: 514ca4e1df7505b02350470d5de4a5ee3647634b
testing.zh.md: 6d24d8e74d726d53fa3500f57e34238483c5481c
+1 -1
View File
@@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning
- **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`).
- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar.
- **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)).
- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. TUI journeys replay primary/child JSONL through the real loop and tools, then project ANSI into semantic terminal-state outputs; package snapshots retain transient states and a real PTY covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless pins `stream-json` through its real one-shot process. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS.
Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header. In-flight branches carrying older fixture edits merge current `master` and run the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) through `pnpm run migrate:packed-session-fixtures`; the [removal proposal](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) retires that command and these links after all affected branches converge.
+1 -1
View File
@@ -9,7 +9,7 @@
- **单元测试**`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及永久性契约回归(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。
- **覆盖率门禁**`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。
- **真实 API e2e**`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY``PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。
- **快照**`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`TUI 旅程通过真实循环与工具回放主会话与子会话 JSONL,再将 ANSI 投影为语义化终端状态输出;包级快照保留瞬态状态,真实 PTY 覆盖进程边界([TUI 快照 Agent Note](../.agents/notes/archived/testing/2026-07-18-tui-terminal-state-snapshots.md))。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。
- **快照**`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输契约与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 通过真实单次运行进程固定 `stream-json`。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。
- **Web 浏览器快照**`pnpm run test:web`;必需的 Linux PRPull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。
签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture。仍携带旧版 fixture 改动的在途分支应合并当前 `master`,并通过 `pnpm run migrate:packed-session-fixtures` 运行[临时迁移器](../scripts/migrate-packed-session-fixtures.ts);待所有受影响分支收敛后,[移除提案](../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)会移除该命令及这些链接。
@@ -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 docs/typert-catalog-integration-design.md
typert-catalog-integration-design.md: c7d601730655f61f3b875ad5ad6997d3c888bfea
typert-catalog-integration-design.zh.md: abaddfe1f740d4bd7cff5b2db8fd91e34626aab8
-133
View File
@@ -1,133 +0,0 @@
# Typert Catalog Integration Design
English | [中文](typert-catalog-integration-design.zh.md)
## Current State and Problem
Typert already provides separate host/client `FaceModel` instances, a `TypeGraph` with explicit cross-face references, and analysis support for services, events, `@typert object`, generics, inheritance, and External types. The TypeScript compiler API should only translate source code into this standard model; downstream consumers should not traverse the TypeScript AST again.
The repository currently has two catalog pipelines that analyze TypeScript source directly: the static API catalog consumed by `tool-cordis`, and the generation and freshness gate for `docs/cordis-catalog/events.md` and `docs/cordis-catalog/services.md`. They analyze the same services, events, and related types, but maintain separate collection and rendering logic, so they cannot prove that the Typert model is sufficient to represent the existing domain semantics.
The first phase makes both pipelines consume the Typert model while keeping the three committed artifacts character-for-character identical to their pre-migration versions:
- `docs/cordis-catalog/events.md`
- `docs/cordis-catalog/services.md`
- `packages/cordis/tool-cordis/src/api-catalog.ts`
This phase does not require product plugins to publish Typert subpaths, example applications to load Typert, or changes to the runtime dependencies of `tool-cordis`.
## Options
### Drive `tool-cordis` from the Runtime Registry
Each plugin publishes and loads Typert artifacts, then `tool-cordis` reads the current runtime model from `ctx.typert`. This path reflects the set of plugins actually loaded, but it requires every product package represented in the catalog to add package exports, generated artifacts, registry contributions, and application assembly. That integration surface is much larger than the analysis capability being validated now.
### Publish Typert Artifacts Repository-Wide, Then Aggregate Them Statically
All product packages generate host/client JS and DTS during the normal build/typecheck process, then the catalog generator aggregates those artifacts. This path establishes the complete publication protocol up front, but it also changes many package manifests and the build topology at once, coupling catalog migration to repository-wide Typert publication.
### Analyze at Build Time, Then Project the Catalog
`WorkspaceAnalyzer` builds a `WorkspaceModel` and `TypeGraph` from the host TypeScript project. The repository-specific `CordisCatalogProjector` consumes only that model and generates the three texts. `tool-cordis` continues to import the committed static `api-catalog.ts`, so the runtime does not need the Typert service.
This phase uses build-time projection. It directly verifies that the standard Typert model can replace the existing AST collector while leaving runtime publication and automatic loading to separate follow-up decisions.
## Phase-One Architecture
```text
tsconfig.host.json
WorkspaceAnalyzer ── TypeScript compiler API 的唯一边界
WorkspaceModel + TypeGraph
CordisCatalogProjector ── 不依赖 TypeScript AST
├── docs/cordis-catalog/events.md
├── docs/cordis-catalog/services.md
└── packages/cordis/tool-cordis/src/api-catalog.ts
```
The objects have the following responsibilities:
- `WorkspaceAnalyzer` analyzes packages, exports, services, events, type declarations, and reference relationships, and produces a compiler-independent model.
- `WorkspaceModel` and `TypeGraph` are the standard data structures shared by all generation and scanning analyses. They preserve developer-authored generics, inheritance, and type trees without retaining the TypeScript AST.
- The root entry point of `@deepseek-ai/dsh-typert-generator` exports `CordisCatalogProjector`, which performs model-driven selection, sorting, summary extraction, source location handling, JSDoc completeness checks, type-link closure, and rendering in three text formats. Its implementation remains in a dedicated Cordis catalog file, but it does not create another package subpath or embed a list of repository type names.
- `scripts/gen-cordis-catalog.ts` provides `LINK_MAP`, `FOUNDATION_TYPE_NAMES`, `TYPE_LINK_EXEMPTIONS`, and the inherited Cordis list, injects them explicitly into the projector through `CordisCatalogPolicy`, and owns the write/check CLI behavior. The vendor Cordis core pages continue to be generated by a separate pinned-source projector.
- `tool-cordis` imports only the static `api-catalog.ts` and does not depend on `typert-registry` or `typert-loader`.
`CordisCatalogProjector` is a repository-specific downstream consumer and is not part of Typert's general-purpose model. When adding another category, first extend the standard model, then add the corresponding projector. The Typert analyzer must not absorb Cordis documentation formats or `tool-cordis` presentation logic.
## Model Additions
In addition to type structure, the catalog's character-for-character projection needs the declaration forms written by developers and exact source locations. The standard model therefore retains event/service locations, body-free text for events and members, parameter initializers, and the export status and canonical text of type declarations. `SourceDeclarationModel` also indexes top-level exported declarations for ambiguity checks and static type closure, without promoting them to domain graph roots.
```ts
interface SourceLocation {
readonly file: string
readonly line: number
readonly column: number
}
interface EventModel {
readonly location: SourceLocation
readonly text: string
}
```
Repository-wide analysis supports building bounded `ts.Program` instances in package batches, then merging them through source-location-stable graph ids into a face model equivalent to monolithic analysis. This capability changes only the memory boundary of the compiler program; it does not change package, declaration, or type graph semantics.
All information required by the projector must come from `WorkspaceModel` or `TypeGraph`. If a fact required for character-for-character compatibility cannot be expressed by the model, extend the standard model; do not reintroduce `ts.Node`, `ts.Symbol`, or `ts.TypeChecker` in the projector or script.
## Character-for-Character Migration Oracle
Before migration, retain the three texts produced by the old generator against the same source state. After migration, run the new analyzer and projector and require the three outputs to be byte-for-byte identical. Newlines, spaces, ordering, JSDoc, source pointers, and generated headers are all part of the comparison.
`pnpm run verify-cordis-catalog` retains its `--check` mode, which reads the three committed artifacts and compares them directly with the newly computed results. A missing file or any differing character makes the artifact stale, and the error points to the single `pnpm run gen-cordis-catalog` repair command.
Tests pin both of the following layers:
- Typert fixture snapshots pin the `WorkspaceModel`, `TypeGraph`, JS, DTS, and Zod outputs, proving the behavior of the standard model and general-purpose emitters.
- Cordis catalog tests or snapshots pin the projector's three complete texts, proving that the repository-specific product projection does not bypass the standard model and providing directly reviewable textual evidence.
The three committed artifacts are the migration oracle between the old and new implementations and the continuing freshness oracle after migration. The old `gen-cordis-api` AST collector is removed. The scripts and commands with that name remain only as compatibility entry points for the unified projector because the generated file header itself contains the command; retaining the entry point preserves the character-for-character oracle without creating a second source of truth.
## Exact Change List
### Typert Generator
- Add the locations, authored declaration text, parameter initializers, export status, and top-level source declaration index needed for character-for-character projection, with coverage in analyzer and model snapshots.
- Support bounded package-batch analysis and prove that direct and batched models are equivalent.
- Confirm that the catalog's required service declarations, public instance members, JSDoc, generics, inheritance, and referenced types are all available from the model.
- Keep the TypeScript compiler API encapsulated within the analyzer; the public model and projector inputs do not expose compiler objects.
### Cordis Catalog Projector
- Select the complete set of Cordis services and events from the host `WorkspaceModel`.
- Preserve the old generator's JSDoc rules: events must have `@mode` and payload `@param` tags; service methods must have a matching `@param` for every parameter; non-void returns must have `@returns`.
- Compute the type links used by signatures and the transitive public type closure required by `tool-cordis` from the type graph.
- Receive caller-maintained type classifications and the inherited surface through an explicit `CordisCatalogPolicy`; do not maintain the repository documentation taxonomy inside the generator package.
- Preserve the existing output rules for source pointers, signatures, summaries, ordering, declaration truncation, and the inherited context catalog.
- Project once and render the events Markdown, services Markdown, and TypeScript API catalog, preventing drift between documentation and tool data.
### Commands and Consumers
- `scripts/gen-cordis-catalog.ts` maintains repository policy data, assembles the analyzer and projector, and writes/checks all three artifacts together. Parsing, validation, and rendering logic lives in the generator's dedicated Cordis source file and is exported uniformly from the package root entry point.
- Narrow `scripts/gen-cordis-api.ts` to a logic-free compatibility entry point for the unified CLI; the root `gen-cordis-api` and `verify-cordis-api` aliases point to that entry point.
- Restore the static catalog default in `tool-cordis` and remove its dependencies on `ctx.typert`, `typert-registry`, and runtime package-model completeness.
- `gen-doc-graphs` obtains the projector's model-level result once and reuses its services and events; it must not continue to import the AST collector or analyze the repository again.
### Narrow the Scope of Phase-One Changes
- Remove the newly added `./typert` and `./client/typert` exports and `lib/typert.*` files from product plugin package.json files.
- Remove `typert-registry` and `typert-loader` assembly from examples.
- Normal build/typecheck does not run repository-wide `gen-typert` or require product-package Typert artifacts to exist before it runs on a clean tree.
- Retain `packages/typert/generator`, `packages/typert/registry`, and `packages/typert/loader`, along with their independent fixture, emitter, and runtime registration tests.
## Future Extensions
The runtime registry remains the receiving and query layer for generated JS/Zod, and the loader remains the automatic loading mechanism; neither supplies data to the first-phase static catalog. When product packages need runtime reflection, they can opt in by publishing `package/typert` and `package/client/typert`, which the loader then registers with `ctx.typert`.
Future integration does not change the phase-one layering: only the analyzer handles TypeScript, the standard model serves both static generation and scan analysis, and the emitter produces runtime artifacts from that same model. Whether to extend publication to more packages, enable the loader by default, or extend the runtime registry's query capabilities are separate review decisions and remain decoupled from the Cordis catalog migration.
@@ -1,133 +0,0 @@
# Typert catalog 接入设计
[English](typert-catalog-integration-design.md) | 中文
## 现状与问题
Typert 已经具备独立的 host/client `FaceModel`、可显式跨 face 引用的 `TypeGraph`,以及 service、event、`@typert object`、泛型、继承和 External 类型的分析能力。TypeScript compiler API 只应负责把源码转换成这套标准模型;后续消费者不应再次遍历 TypeScript AST。
仓库目前有两条直接分析 TypeScript 源码的 catalog 链路:`tool-cordis` 使用的静态 API catalog,以及 `docs/cordis-catalog/events.md``docs/cordis-catalog/services.md` 的生成与 freshness gate。它们分析的是同一批 service、event 和相关类型,却分别维护收集与渲染逻辑,不能证明 Typert 模型足以承载现有业务语义。
第一阶段的目标是让这两条链路共同消费 Typert 模型,并保持三份已提交产物与迁移前字符级一致:
- `docs/cordis-catalog/events.md`
- `docs/cordis-catalog/services.md`
- `packages/cordis/tool-cordis/src/api-catalog.ts`
本阶段不要求业务插件发布 Typert 子路径,不要求示例应用加载 Typert,也不改变 `tool-cordis` 的运行时依赖关系。
## 可选路径
### 运行时 registry 驱动 `tool-cordis`
每个插件发布并加载 Typert 产物,`tool-cordis` 再从 `ctx.typert` 读取当前运行时模型。这条路径可以反映实际加载的插件集合,但会要求所有参与 catalog 的业务包增加 package exports、生成产物、registry contribution 和应用装配,接入面远大于当前要验证的分析能力。
### 全仓发布 Typert 产物后静态汇总
所有业务包在普通 build/typecheck 中生成 host/client JS 与 DTS,再由 catalog 生成器汇总这些产物。这条路径能够提前建立完整的发布协议,但会同时修改大量 package manifest 和构建拓扑,使 catalog 迁移与 Typert 的全仓发布绑定。
### 构建期分析后投影 catalog
`WorkspaceAnalyzer` 从 host TypeScript project 构建 `WorkspaceModel``TypeGraph`,仓库专用的 `CordisCatalogProjector` 只消费该模型并生成三份文本。`tool-cordis` 继续导入已提交的静态 `api-catalog.ts`,运行时不需要 Typert service。
本阶段采用构建期投影。它直接验证 Typert 标准模型能否替代现有 AST collector,同时把运行时 publication 和自动加载留在独立的后续决策中。
## 第一阶段架构
```text
tsconfig.host.json
WorkspaceAnalyzer ── TypeScript compiler API 的唯一边界
WorkspaceModel + TypeGraph
CordisCatalogProjector ── 不依赖 TypeScript AST
├── docs/cordis-catalog/events.md
├── docs/cordis-catalog/services.md
└── packages/cordis/tool-cordis/src/api-catalog.ts
```
各对象的职责如下:
- `WorkspaceAnalyzer` 负责 package、export、service、event、类型声明和引用关系的分析,并产生 compiler-independent model。
- `WorkspaceModel``TypeGraph` 是所有生成和扫描分析共用的标准数据结构,保留开发者写出的泛型、继承和类型树,不保存 TypeScript AST。
- `@deepseek-ai/dsh-typert-generator` 根入口导出的 `CordisCatalogProjector` 负责模型驱动的选择、排序、摘要、源位置、JSDoc 完整性、类型链接闭包和三种文本格式;实现仍单独放在 Cordis catalog 专用文件中,但不形成额外的 package subpath,也不内置仓库类型名单。
- `scripts/gen-cordis-catalog.ts` 提供 `LINK_MAP``FOUNDATION_TYPE_NAMES``TYPE_LINK_EXEMPTIONS` 和 inherited Cordis 清单,通过 `CordisCatalogPolicy` 显式注入 projector,并负责 write/check 的命令行行为;vendor Cordis core 页面仍由独立的 pinned-source projector 生成。
- `tool-cordis` 只导入静态 `api-catalog.ts`,不依赖 `typert-registry``typert-loader`
`CordisCatalogProjector` 是仓库业务消费者,不进入 Typert 通用模型。新增其他类别时,先扩展标准模型,再增加对应 projectorTypert analyzer 不吸收 Cordis 文档格式或 `tool-cordis` 展示逻辑。
## 模型补充
Catalog 的字符级投影除了类型结构,还需要开发者写下的声明形式和精确源码位置。标准模型因此保留 event/service location、event/member 的 body-free text、parameter initializer,以及 type declaration 的 export 状态和 canonical text`SourceDeclarationModel` 另外索引顶层导出声明,供歧义检查和静态类型闭包使用,但不把它们提升为业务 graph root。
```ts
interface SourceLocation {
readonly file: string
readonly line: number
readonly column: number
}
interface EventModel {
readonly location: SourceLocation
readonly text: string
}
```
全仓分析支持按 package 分批构建有界 `ts.Program`,再依靠源码位置稳定的 graph id 合并为与一次性分析等价的 face model。该能力只改变 compiler program 的内存边界,不改变 package、declaration 或 type graph 语义。
projector 所需信息必须来自 `WorkspaceModel``TypeGraph`。如果字符级兼容需要的事实无法从模型表达,应补充标准模型;不得在 projector 或脚本中重新引入 `ts.Node``ts.Symbol``ts.TypeChecker`
## 字符级迁移 oracle
迁移前,在同一份源码状态下保留旧生成器产生的三份文本。迁移后运行新的 analyzer 与 projector,要求三份输出逐字节相等;换行、空格、排序、JSDoc、source pointer 和生成头都属于比较内容。
`pnpm run verify-cordis-catalog``--check` 模式继续读取三份 committed artifact,并与本次计算结果直接比较。任一文件缺失或任一字符不同都视为 stale,错误信息指向统一的 `pnpm run gen-cordis-catalog` 修复命令。
测试同时固定以下两层:
- Typert fixture snapshots 固定 `WorkspaceModel``TypeGraph`、JS、DTS 与 Zod 输出,证明标准模型和通用 emitter 的行为。
- Cordis catalog 测试或 snapshot 固定 projector 的三份完整文本,证明仓库业务投影没有绕过标准模型,并给出可直接评审的文本证据。
三份 committed artifact 是旧实现与新实现的迁移 oracle,也是迁移完成后的持续 freshness oracle。旧 `gen-cordis-api` AST collector 被删除;同名脚本和命令只作为统一 projector 的兼容入口保留,因为生成文件头本身包含该命令,保留入口可以维持字符级 oracle 而不产生第二套真源。
## 精确改造清单
### Typert generator
- 补齐字符级投影所需的 location、authored declaration text、parameter initializer、export 状态和顶层 source declaration index,并在 analyzer 与 model snapshots 中覆盖。
- 支持有界 package batch 分析,并证明 direct 与 batched model 等价。
- 确认 catalog 所需的 service 声明、public instance member、JSDoc、泛型、继承和引用类型均可从 model 读取。
- 保持 TypeScript compiler API 封装在 analyzer 内;公共 model 和 projector 输入不暴露 compiler 对象。
### Cordis catalog projector
- 从 host `WorkspaceModel` 选择完整的 Cordis service/event 集合。
- 保留旧生成器的 JSDoc 规则:event 必须有 `@mode` 和 payload `@param`service method 必须有参数对应的 `@param`,非 void 返回必须有 `@returns`
- 从 type graph 计算签名涉及的类型链接和 `tool-cordis` 所需的传递 public type closure。
- 通过显式 `CordisCatalogPolicy` 接收调用方维护的类型分类和 inherited surface,不在 generator 包内维护仓库文档 taxonomy。
- 保留 source pointer、签名、摘要、排序、声明截断和 inherited context catalog 的既有输出规则。
- 一次投影并渲染 events Markdown、services Markdown 与 TypeScript API catalog,避免文档和工具数据漂移。
### 命令与消费方
- `scripts/gen-cordis-catalog.ts` 维护仓库 policy 数据、组装 analyzer/projector,并同时 write/check 三份产物;解析、校验和渲染逻辑位于 generator 的 Cordis 专用源文件,并统一从 package 根入口导出。
-`scripts/gen-cordis-api.ts` 收窄为统一 CLI 的无逻辑兼容入口;根目录的 `gen-cordis-api``verify-cordis-api` aliases 指向该入口。
- `tool-cordis` 恢复静态 catalog 默认值,移除对 `ctx.typert``typert-registry` 和运行时 package model 完整性的依赖。
- `gen-doc-graphs` 一次取得 projector 的 model-level 结果并复用 services/events,不能继续导入 AST collector 或重复分析全仓。
### 收窄本阶段改动面
- 撤销业务插件 package.json 中新增的 `./typert``./client/typert` exports 和 `lib/typert.*` files。
- 撤销 examples 中的 `typert-registry``typert-loader` 装配。
- 普通 build/typecheck 不运行全仓 `gen-typert`,也不要求 clean tree 预先存在业务包 Typert artifact。
- 保留 `packages/typert/generator``packages/typert/registry``packages/typert/loader` 及其独立 fixture、emitter 和 runtime registration 测试。
## 后续扩展
Runtime registry 继续作为生成 JS/Zod 后的接收与查询层,loader 继续作为自动装载机制;两者不承担第一阶段静态 catalog 的数据来源。业务包需要运行时反射时,可以按 package opt-in 发布 `package/typert``package/client/typert`,再由 loader 注册到 `ctx.typert`
后续接入不改变本阶段的分层:TypeScript 只进入 analyzer,标准模型同时服务静态生成与扫描分析,runtime artifact 由 emitter 从同一模型产生。是否把更多 package 接入 publication、是否默认启用 loader,以及 runtime registry 最终提供哪些查询能力,分别评审,不与 Cordis catalog 迁移捆绑。
+2 -2
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 docs/user/develop/basic/config.md
config.md: 26d2d48ebede74194fbf306aa97d214bdb99b722
config.zh.md: 1ca727b56b661df315026902d48fbcaf507fe08b
config.md: 11a2311464789f74537cc7c4435f83ec07ca26fd
config.zh.md: 4e827ecafa6bfaf87c3e3f118425e656e1254787
+8 -21
View File
@@ -31,13 +31,15 @@ export function apply(ctx: Context, config: Config) {
}
```
Configure it in `cordis.yml`:
Add the configuration to the inserted local plugin row in `scratch-plugin/cordis.yml`:
```yaml
- name: './src/my-plugin.ts'
config:
greeting: 'Hi there'
maxRetries: 5
- insert:
- id: hello
name: './src/my-plugin.ts'
config:
greeting: 'Hi there'
maxRetries: 5
```
When loading the plugin, Cordis uses the exported schema to validate configuration and fill defaults. Do not export a plain object as `Config`; it does not implement the Standard Schema interface required by Cordis.
@@ -91,22 +93,7 @@ The test is whether `cordis.yml` can change the value without a code edit.
### Fail loudly on invalid configuration
If configuration refers to an unregistered LLM provider route or another nonexistent resource, fail early instead of silently skipping it:
```ts
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-llm'
export interface ModelConfig {
provider: string
}
export function apply(ctx: Context, config: ModelConfig) {
if (!ctx.llm.listProviders().some(provider => provider.id === config.provider)) {
throw new Error(`LLM provider "${config.provider}" is not registered`)
}
}
```
Express self-contained constraints in the schema so invalid configuration fails while the plugin loads. References to services or registered resources require dependency injection; the [services tutorial](../framework/service.md) introduces that contract.
## Work with HMR
+8 -21
View File
@@ -31,13 +31,15 @@ export function apply(ctx: Context, config: Config) {
}
```
用户`cordis.yml` 中这样使用
`scratch-plugin/cordis.yml` 新插入的本地插件行中添加配置
```yaml
- name: './src/my-plugin.ts'
config:
greeting: 'Hi there'
maxRetries: 5
- insert:
- id: hello
name: './src/my-plugin.ts'
config:
greeting: 'Hi there'
maxRetries: 5
```
插件加载时,Cordis 会通过导出的 schema 校验配置,并填充未提供字段的默认值。不要导出普通对象作为 `Config`,因为它不满足 Cordis 要求的 Standard Schema 接口。
@@ -91,22 +93,7 @@ export interface Config {
### 配置错误要响亮
如果配置引用了未注册的 LLM 提供方路由或其他不存在的资源,应该尽早报错,而不是静默跳过:
```ts
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-llm'
export interface ModelConfig {
provider: string
}
export function apply(ctx: Context, config: ModelConfig) {
if (!ctx.llm.listProviders().some(provider => provider.id === config.provider)) {
throw new Error(`LLM provider "${config.provider}" is not registered`)
}
}
```
在 schema 中表达自身完备的约束,使无效配置在插件加载时失败。对服务或已注册资源的引用需要依赖注入;[服务教程](../framework/service.md)会介绍这项契约。
## 配合 HMR
+2 -2
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 docs/user/develop/basic/index.md
index.md: 5a9f8dfb8f2d87dfbd2ba30b4d09d002ae9b635c
index.zh.md: 6f8926aeac5dcf667e4d311505ef7b2a321659c2
index.md: 45c8dfe495cd99da46a7b259b407af8deff570b3
index.zh.md: 9d8ee47e6f07fb4a897f49487cb7327904573c1b
+21 -35
View File
@@ -2,7 +2,15 @@
English | [中文](index.zh.md)
This guide creates a minimal Harness plugin and loads it into an agent.
This tutorial creates a minimal Harness plugin and loads it into the Web UI. Start from a repository checkout that has completed the [quick start](../../guide/quickstart.md).
## Create a local project
From the repository root, create a scratch project for the tutorial:
```sh
mkdir -p scratch-plugin/src
```
## What is a plugin?
@@ -22,7 +30,7 @@ That is the complete shape.
## Create the plugin file
Create `src/my-plugin.ts` in your project:
Create `scratch-plugin/src/my-plugin.ts`:
```ts
import type { Context } from 'cordis'
@@ -37,14 +45,21 @@ export function apply(ctx: Context) {
## Register it in cordis.yml
Add an entry to `cordis.yml`:
Create `scratch-plugin/cordis.yml` as a Web overlay that inserts the local plugin:
```yaml
- id: hello
name: './src/my-plugin.ts'
- insert:
- id: hello
name: './src/my-plugin.ts'
```
After startup, the console prints `[hello-plugin] plugin loaded!`.
Start the Web UI with that overlay:
```sh
pnpm run dsh web --config ./scratch-plugin/cordis.yml
```
Open `http://127.0.0.1:3080`. The terminal prints `[hello-plugin] plugin loaded!` during startup.
## Automatic cleanup
@@ -120,35 +135,6 @@ export default class MyService extends Service {
Function form is sufficient in most cases. Use class form when the plugin provides a service to other plugins; see [services and dependencies](../framework/service.md).
## Complete example
A minimal tool plugin registers its definition on `ctx.tools`:
```ts
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet the named person.',
parameters: {
name: { type: 'string', required: true },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}
```
## Next steps
- [Build a tool](./tool.md) — learn the tool definition DSL
+21 -35
View File
@@ -2,7 +2,15 @@
[English](index.md) | 中文
文带你编写一个最小的 Harness 插件并加载到 agent(智能体)中
教程会创建一个最小的 Harness 插件,并将其加载到 Web UI 中。请从已完成[快速开始](../../guide/quickstart.md)的仓库检出开始
## 创建本地项目
在仓库根目录创建本教程使用的临时项目:
```sh
mkdir -p scratch-plugin/src
```
## 插件是什么
@@ -22,7 +30,7 @@ export function apply(ctx: Context) {
## 创建插件文件
在你的项目目录下创建 `src/my-plugin.ts`
创建 `scratch-plugin/src/my-plugin.ts`
```ts
import type { Context } from 'cordis'
@@ -37,14 +45,21 @@ export function apply(ctx: Context) {
## 注册到 cordis.yml
在你的 `cordis.yml` 中添加一条
创建 `scratch-plugin/cordis.yml`,作为插入本地插件的 Web 覆盖层
```yaml
- id: hello
name: './src/my-plugin.ts'
- insert:
- id: hello
name: './src/my-plugin.ts'
```
启动后你会在控制台看到 `[hello-plugin] plugin loaded!`
使用该覆盖层启动 Web UI
```sh
pnpm run dsh web --config ./scratch-plugin/cordis.yml
```
打开 `http://127.0.0.1:3080`。启动期间,终端会打印 `[hello-plugin] plugin loaded!`
## 自动清理
@@ -120,35 +135,6 @@ export default class MyService extends Service {
大多数情况下,函数形式足够了。当插件需要向其他插件提供服务时,可使用类形式(见 [服务与依赖](../framework/service.md))。
## 完整示例
最小的工具插件会在 `ctx.tools` 上注册其定义:
```ts
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet the named person.',
parameters: {
name: { type: 'string', required: true },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}
```
## 下一步
- [开发一个工具](./tool.md) — 详细了解工具定义 DSL
+2 -2
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 docs/user/develop/basic/tool.md
tool.md: 0d7cbc3f0b86f88fb67aeff6aa61181dff2912ee
tool.zh.md: 37362c510a07fcc32e9eee2b578d82907788e471
tool.md: 93a1a96feba814a564f8800c8e7b865fe9c0cb73
tool.zh.md: 18e6b9b5d9c17b00c26aa7b98e6ac4531315dc5e
+14 -207
View File
@@ -2,15 +2,17 @@
English | [中文](tool.zh.md)
A tool is a capability the model can call. This guide builds one with `defineTool`.
This tutorial adds a `greet` tool to the Web UI. Complete [Your first plugin](./) first and keep its `scratch-plugin` directory.
## Minimal example
## Create the tool plugin
Replace `scratch-plugin/src/my-plugin.ts` with:
```ts
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
@@ -25,221 +27,26 @@ export function apply(ctx: Context) {
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
// args is inferred as { name: string }.
return `Hello, ${args.name}!`
},
}))
}
```
## Parameter definitions
`inject` makes Cordis wait for the tool registry. `defineTool` infers and validates `args` from `parameters`; `execute` returns the canonical value declared by `output.schema`, and `output.render` converts that value to model-facing content.
`parameters` uses a compact format that the framework converts to the JSON Schema sent to the model.
## Run and call the tool
### Primitive types
Restart the development command if it is not running:
```ts
export const parameters = {
path: { type: 'string', required: true },
limit: { type: 'integer' },
recursive: { type: 'boolean' },
parent: { type: 'null' },
}
// Inferred type: { path: string; limit?: number; recursive?: boolean; parent?: null }
```sh
pnpm run dsh web --config ./scratch-plugin/cordis.yml
```
### Enums
```ts
export const parameters = {
mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] },
}
// Inferred type: { mode: 'read' | 'write' | 'append' }
```
### Nested objects
```ts
export const parameters = {
options: {
type: 'object',
additionalProperties: true,
properties: {
timeout: { type: 'number' },
retries: { type: 'number' },
},
},
}
// The declared fields are inferred; additional JSON-valued keys are allowed.
```
### Arrays
```ts
export const parameters = {
tags: {
type: 'array',
items: { type: 'string' },
},
}
// Inferred type: { tags?: string[] }
```
### Property fields
| Field | Type | Meaning |
|------|------|------|
| `type` | `'string' \| 'number' \| 'integer' \| 'boolean' \| 'null' \| 'object' \| 'array' \| 'json'` | Value type; `json` accepts any lossless JSON value |
| `required` | `true` | Marks the property required and affects inference |
| `description` | `string` | Description sent to the model |
| `enum` / `const` | matching scalar values | Allowed literal values, checked at author and runtime boundaries |
| `properties` | `ParameterSchemaSpec` | Nested properties for an object |
| `additionalProperties` | `true \| false` | Required on every explicit object node |
| `items` | `ValueSchemaSpec` | Element schema for an array |
| `oneOf` | at least two `ValueSchemaSpec` branches | Requires exactly one matching branch; used instead of `type` |
The outer `parameters` map is an implicit open object. Explicit nested objects choose their openness; raw JSON Schema registered without `defineTool` keeps JSON Schema's open-by-default behavior.
## The execute function
`execute` receives validated, inferred `args` and an `exec` execution context:
```ts
import { defineTool } from '@deepseek-ai/dsh-tools'
export const tool = defineTool({
name: 'example',
description: 'Return an example result.',
parameters: {},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args: inferred from parameters
// exec: ToolExecution context
// Return the value declared by output.schema.
void args
void exec
return 'result here'
},
})
```
### Return value
`execute` returns the lossless JSON value declared by `output.schema`. `output.render(args, value)` separately turns that validated value into the Native/model-facing content:
```ts ignore-check
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
content: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: value.content }],
},
async execute(args) {
return { path: args.path, content: await readFile(args.path, 'utf8') }
}
```
The canonical value is available to execution-time programmatic callers and is not persisted in `tool/result`; the rendered content and optional `presentationMeta` are the replayable projections. A body value that does not satisfy the schema, or is not lossless JSON, becomes an `INVALID_TOOL_OUTPUT` failure.
### Argument validation
Before calling `execute`, `defineTool` validates model-generated arguments. Invalid input raises `ToolArgsError`; the framework turns it into an `isError` result so the model can correct its call.
Do not repeat type validation inside `execute`.
## Presentation
A tool can define transport-neutral presentation methods for terminal and web clients:
```ts ignore-check
defineTool({
name: 'bash',
// ...
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
presentCall(args) {
return {
card: 'terminal',
title: args.command,
}
},
presentResult(args, result) {
return {
card: 'terminal',
output: result.content.map(b => b.type === 'text' ? b.text : '').join(''),
}
},
})
```
`presentCall` and `presentResult` are **pure functions**. Streaming UI and session replay may call them more than once.
## Registration and unloading
`ctx.tools.register()` returns a disposer, but a registration made through `ctx` is already tracked by the framework. Unloading the plugin removes the tool automatically, so the plugin does not call the disposer itself.
```ts ignore-check
// This is sufficient:
ctx.tools.register(defineTool({ /* ... */ }))
// No saved disposer or extra cleanup registration is needed.
```
## Complete example
This tool counts files in a directory:
```ts
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { readdir } from 'node:fs/promises'
export const name = 'file-counter'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'count_files',
description: 'Count files in a directory.',
parameters: {
path: { type: 'string', required: true, description: 'Directory path' },
extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
count: { type: 'integer', required: true },
files: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => [{ type: 'text', text: `Found ${value.count} files.` }],
},
async execute(args) {
const entries = await readdir(args.path, { withFileTypes: true })
let files = entries.filter(e => e.isFile())
if (args.extension) {
files = files.filter(f => f.name.endsWith(args.extension!))
}
return { count: files.length, files: files.map(file => file.name) }
},
}))
}
```
Open `http://127.0.0.1:3080` and ask: `Use the greet tool to greet Ada.` The model can call `greet` and receives `Hello, Ada!` as the tool result.
## Next steps
- [Plugin configuration](./config.md) — make the tool configurable
- [Capability layering](../practice/) — understand the interface/implementation/consumer pattern
- [Plugin configuration](./config.md) — make the greeting configurable.
- [Tool authoring reference](../../../cookbook/adding-a-tool.md) — look up nested schemas, canonical values, background work, policy hooks, Code Mode, and UI cards.
- [Capability layering](../practice/) — split a replaceable capability into interface, implementation, and consumer packages.
+15 -208
View File
@@ -1,16 +1,18 @@
# 开发一个工具
# 构建工具
[English](tool.md) | 中文
工具是模型可以调用的能力。本文介绍如何用 `defineTool` 编写一个工具
本教程会在 Web UI 中添加一个 `greet` 工具。请先完成[第一个插件](./),并保留其中的 `scratch-plugin` 目录
## 最小示例
## 创建工具插件
`scratch-plugin/src/my-plugin.ts` 替换为:
```ts
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
@@ -25,221 +27,26 @@ export function apply(ctx: Context) {
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
// args is inferred as { name: string }.
return `Hello, ${args.name}!`
},
}))
}
```
## 参数定义
`inject` 让 Cordis 等待工具注册表就绪。`defineTool` 根据 `parameters` 推导并校验 `args``execute` 返回 `output.schema` 声明的规范值,`output.render` 再将该值转换为面向模型的内容。
`parameters` 用一种简洁的格式描述参数,框架会自动转换为模型需要的 JSON Schema。
## 运行并调用工具
### 基本类型
如果开发命令未在运行,请重新启动:
```ts
export const parameters = {
path: { type: 'string', required: true },
limit: { type: 'integer' },
recursive: { type: 'boolean' },
parent: { type: 'null' },
}
// Inferred type: { path: string; limit?: number; recursive?: boolean; parent?: null }
```sh
pnpm run dsh web --config ./scratch-plugin/cordis.yml
```
### 枚举
```ts
export const parameters = {
mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] },
}
// Inferred type: { mode: 'read' | 'write' | 'append' }
```
### 嵌套对象
```ts
export const parameters = {
options: {
type: 'object',
additionalProperties: true,
properties: {
timeout: { type: 'number' },
retries: { type: 'number' },
},
},
}
// The declared fields are inferred; additional JSON-valued keys are allowed.
```
### 数组
```ts
export const parameters = {
tags: {
type: 'array',
items: { type: 'string' },
},
}
// Inferred type: { tags?: string[] }
```
### 每个属性的字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `type` | `'string' \| 'number' \| 'integer' \| 'boolean' \| 'null' \| 'object' \| 'array' \| 'json'` | 值类型;`json` 接受任意无损 JSON 值 |
| `required` | `true` | 标记为必填(影响类型推导) |
| `description` | `string` | 发送给模型的描述 |
| `enum` / `const` | 匹配类型的标量值 | 允许的字面量值,在编写和运行时边界校验 |
| `properties` | `ParameterSchemaSpec` | 对象的嵌套属性 |
| `additionalProperties` | `true \| false` | 每个显式对象节点都必须声明 |
| `items` | `ValueSchemaSpec` | 数组的元素 schema |
| `oneOf` | 至少两个 `ValueSchemaSpec` 分支 | 要求恰好匹配一个分支;代替 `type` 使用 |
外层 `parameters` 映射是一个隐式的开放对象。显式嵌套对象需自行选择是否开放;不通过 `defineTool` 注册的原始 JSON Schema 保持 JSON Schema 的默认开放语义。
## execute 函数
`execute` 接收经过校验的 `args`(类型自动推导)和一个 `exec` 上下文对象:
```ts
import { defineTool } from '@deepseek-ai/dsh-tools'
export const tool = defineTool({
name: 'example',
description: 'Return an example result.',
parameters: {},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args: inferred from parameters
// exec: ToolExecution context
// Return the value declared by output.schema.
void args
void exec
return 'result here'
},
})
```
### 返回值
`execute` 返回由 `output.schema` 声明的无损 JSON 值。`output.render(args, value)` 会将经过校验的值另外转换为 Native/模型可见的内容:
```ts ignore-check
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
content: { type: 'string', required: true },
},
},
render: (_args, value) => [{ type: 'text', text: value.content }],
},
async execute(args) {
return { path: args.path, content: await readFile(args.path, 'utf8') }
}
```
执行期间的程序化调用方可以使用规范值,但 `tool/result` 不会持久化该值;渲染后的内容和可选的 `presentationMeta` 才是可回放的投影。工具主体返回的值若不满足 schema 或不是无损 JSON,就会变为 `INVALID_TOOL_OUTPUT` 失败。
### 参数校验
`defineTool` 在调用 `execute` 之前会自动校验模型生成的参数。如果参数不合法,会抛出 `ToolArgsError`,框架将其转换为 `isError` 结果返回给模型,让模型自行修正。
你不需要在 `execute` 里手动校验参数类型。
## 展示层(Presentation
工具可以定义与传输方式无关的展示方法,供终端和 Web 客户端使用:
```ts ignore-check
defineTool({
name: 'bash',
// ...
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
presentCall(args) {
return {
card: 'terminal',
title: args.command,
}
},
presentResult(args, result) {
return {
card: 'terminal',
output: result.content.map(b => b.type === 'text' ? b.text : '').join(''),
}
},
})
```
`presentCall` 和 `presentResult` 是**纯函数**,不能有副作用——UI 可能在流式传输中和会话回放中多次调用它们。
## 注册与卸载
`ctx.tools.register()` 返回值就是 disposer。但由于你在 `ctx` 上调用,框架已经自动追踪了这个注册——插件卸载时会自动移除工具。你不需要手动调用 disposer。
```ts ignore-check
// This is sufficient:
ctx.tools.register(defineTool({ /* ... */ }))
// No saved disposer or extra cleanup registration is needed.
```
## 完整示例
一个文件计数工具:
```ts
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { readdir } from 'node:fs/promises'
export const name = 'file-counter'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'count_files',
description: 'Count files in a directory.',
parameters: {
path: { type: 'string', required: true, description: 'Directory path' },
extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
count: { type: 'integer', required: true },
files: { type: 'array', required: true, items: { type: 'string' } },
},
},
render: (_args, value) => [{ type: 'text', text: `Found ${value.count} files.` }],
},
async execute(args) {
const entries = await readdir(args.path, { withFileTypes: true })
let files = entries.filter(e => e.isFile())
if (args.extension) {
files = files.filter(f => f.name.endsWith(args.extension!))
}
return { count: files.length, files: files.map(file => file.name) }
},
}))
}
```
打开 `http://127.0.0.1:3080`,然后输入:`Use the greet tool to greet Ada.` 模型可以调用 `greet`,并收到 `Hello, Ada!` 这一工具结果。
## 下一步
- [插件配置](./config.md) — 让你的工具可配置
- [能力分层](../practice/) — 了解接口/实现/消费方模式
- [插件配置](./config.md) — 让问候语可配置
- [工具编写参考](../../../cookbook/adding-a-tool.md) — 查阅嵌套 schema、规范值、后台工作、策略钩子、Code Mode 和 UI 卡片。
- [能力分层](../practice/) — 将可替换能力拆分为接口、实现和消费方包。
+2 -2
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 docs/user/develop/practice/index.md
index.md: e197d499d7f5bd9911ea60bebf584251cd4ed915
index.zh.md: cacf13e8b23060ea5c309d30e51d732f09c76000
index.md: c3306725f47993aa9d3322423261a260754c1d5f
index.zh.md: 3609cbce21e17ce9ca0a40b69999b293dee29012
+7 -14
View File
@@ -2,6 +2,10 @@
English | [中文](index.zh.md)
This page has two parts: a concept reference for the three-layer capability pattern, followed by an advanced tutorial that builds one capability. Complete the [basic plugin path](../basic/) and [services tutorial](../framework/service.md) first.
## Concept reference
When a capability is general enough to need replaceable implementations, such as Bash execution, Harness splits it into three packages: an **interface**, an **implementation**, and a **consumer**. Each layer can evolve or be replaced independently.
## Bash example
@@ -32,10 +36,7 @@ One interface can have multiple implementations selected through `cordis.yml`:
# Local execution
- name: '@deepseek-ai/dsh-bash-local'
# Or a future remote sandbox implementation
# - name: '@deepseek-ai/dsh-bash-remote'
# config:
# endpoint: 'https://sandbox.example.com'
# Replace this row with another package that implements the same service.
```
The interface and tool remain unchanged while the implementation changes.
@@ -52,17 +53,9 @@ The interface and tool remain unchanged while the implementation changes.
- The consumer depends on the interface.
- The implementation and consumer **do not depend on each other**.
## Built-in three-layer capabilities
The [capability-seam reference](../../../capability-seams.md) owns the current built-in families and package links.
| Capability | Interface | Implementation | Consumer |
|------|-------------|------|---------------|
| Bash | `dsh-bash` | `dsh-bash-local` | `dsh-tool-bash` |
| Filesystem | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` |
| Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` |
| Subagent | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` |
| Compaction | `dsh-compact` | `dsh-compact-basic` | The implementation consumes agent-loop extension events |
## Develop a three-layer capability
## Tutorial: develop a three-layer capability
### Step 1: define the interface
+7 -14
View File
@@ -2,6 +2,10 @@
[English](index.md) | 中文
本文分为两部分:先参考三层能力模式的概念,再通过高级教程构建一项能力。请先完成[基础插件路径](../basic/)和[服务教程](../framework/service.md)。
## 概念参考
当一项能力足够通用,需要支持可替换的实现时(例如 Bash 执行),Harness 会将其拆成三个包:**接口**、**实现**和**消费方**。这样便可独立替换其中任何一层。
## 以 Bash 为例
@@ -32,10 +36,7 @@
# Local execution
- name: '@deepseek-ai/dsh-bash-local'
# Or a future remote sandbox implementation
# - name: '@deepseek-ai/dsh-bash-remote'
# config:
# endpoint: 'https://sandbox.example.com'
# Replace this row with another package that implements the same service.
```
更换实现时,接口和工具均保持不变。
@@ -52,17 +53,9 @@
- 消费方依赖接口。
- 实现和消费方**互不依赖**。
## Harness 中内置的三件套
当前内置系列及其包链接由[能力 seam 参考](../../../capability-seams.md)负责。
| 能力 | 接口(seam) | 实现 | 消费方(工具) |
|------|-------------|------|---------------|
| Bash | `dsh-bash` | `dsh-bash-local` | `dsh-tool-bash` |
| 文件系统 | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` |
| Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` |
| 子代理 | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` |
| 压缩 | `dsh-compact` | `dsh-compact-basic` | 由实现插件消费 agent-loop 的扩展事件 |
## 开发你自己的三件套
## 教程:开发三层能力
### 第一步:定义接口
+2 -2
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 docs/user/guide/config.md
config.md: 4e438cc3a400de71934d047108024ff5be4ef7d2
config.zh.md: e0b0285b110a808b1284209a84f78e114634df52
config.md: ddf4df264e5534fc3b74991941c2f3f82376d53f
config.zh.md: 56ac0146ddae83dbfc86f479030efdb5772a3aaf
+1 -1
View File
@@ -47,7 +47,7 @@ A minimal configuration is a list of plugin entries:
toolName: my_tool
```
Plugins load in file order. Place plugins that depend on services after the applications or capability plugins that provide them. Missing models, tools, and plugins fail as early as possible instead of being silently ignored.
Cordis starts sibling entries concurrently. A plugin declares required services through `inject`; Cordis waits for those services before applying the plugin, so file order does not establish dependency readiness. Missing models, tools, and plugins fail as early as possible instead of being silently ignored.
## CLI overlays

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