Merge origin/master into fix/landlock-runner-failure-classification

This commit is contained in:
Hypatia May
2026-08-04 09:14:13 +08:00
177 changed files with 4780 additions and 1686 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/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md
2026-07-27-tui-diff-card-redundant-path-header.md: 708e543ff079828b4929d2a50ac697a9c846608a
2026-07-27-tui-diff-card-redundant-path-header.zh.md: 863868ae707f37689bbc202267c5470d8c3163e9
2026-07-27-tui-diff-card-redundant-path-header.md: 608a11892a20d020087180175eff847021dc0554
2026-07-27-tui-diff-card-redundant-path-header.zh.md: bf7f1c1eeb994f9940b5f7dfb7db72d422293bd4
@@ -1,6 +1,7 @@
# Agent Note: TUI diff card dropped the duplicated file path
Status: implemented
Archived: 2026-07-31
English | [中文](2026-07-27-tui-diff-card-redundant-path-header.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: TUI diff 卡片重复打印文件路径
Status: implemented
Archived: 2026-07-31
[English](2026-07-27-tui-diff-card-redundant-path-header.md) | 中文
+3
View File
@@ -46,6 +46,9 @@
"bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81",
"bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373",
"bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8",
"bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml": "sha256:8613a1cfcf4b9c7fafa78a8d8565e2a65ef0335b7b826af9b2bb32097836af55",
"bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md": "sha256:1bd344aec5454d2a2d6e1e6a32eff035c4a99c3df409f2624b39fd32e23ee402",
"bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md": "sha256:0a1747006efb1a4b67feceb9b627a437a0f023158e90ae86e1fe8aef76485384",
"feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
"feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
"feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-19-package-owned-invariant-service.md: 2443a8f7d04b96f51bb798130078a7457f78b2a1
2026-07-19-package-owned-invariant-service.zh.md: 3c71d3b7f99a507d4c0236b7ef6dc0794814cdc8
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md
2026-07-19-package-owned-invariant-service.md: e32efe9f6b3ce6b782c61db56d928e87c160dc9a
2026-07-19-package-owned-invariant-service.zh.md: 60edaa3f6009acc516017683232ca0c07f64ec0d
@@ -8,7 +8,7 @@ English | [中文](2026-07-19-package-owned-invariant-service.zh.md)
Runtime invariant checks span session traces, agent state, scoped dispatch, and request reconstruction. Putting all checks in one diagnostics package makes that package import product vocabularies from unrelated domains, centralizes tests away from their owners, and requires the central package to change whenever a product package adds or removes a check.
Deployments also need more than presence or absence of one plugin. A standard composition should carry the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently.
Deployments that opt into diagnostics need more than presence or absence of one plugin. Such a composition carries the known invariant contributions while permitting a global off switch and package-selective diagnostics. Selection must remain stable when a package loads later or reloads under HMR, and disabled contributions must not allow two plugins to claim the same package name silently.
Package ownership must also be exhaustive. Without a mechanical repository rule, a new package can omit the companion, dependency, or publication wiring and remain invisible to diagnostics until a maintainer notices the gap.
@@ -72,9 +72,9 @@ These four owners supplied the initial stateful checks. The follow-up runtime-co
The generated scoped-event subject resolver lives in `dsh-scope`, beside the contract and invariant that consume it. `gen-scoped-events` uses the root TypeScript Program to enumerate `this: Scoped<Base>` declarations, infer routing-key types from real `scopeTarget(base, key)` calls, and require one unambiguous payload subject or an explicit unsupported marker. The committed runtime map imports no event-owner package, so semantic completeness does not expand either the service or scope package's runtime closure.
### Standard composition and SDK output
### Example composition and SDK output
The standard agent spine mounts the service and all four stateful companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name.
The example agent spine mounts the service and all four stateful companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. The shipped `dsh` TUI and Web config trees omit the service and companions under the [shipped-config decision](../simplification/2026-08-03-omit-invariants-from-shipped-config.md).
Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication surface. Generated config catalogs, module graphs, and API documentation derive from those sources.
@@ -97,7 +97,7 @@ Every Vitest configuration loads a test host that mounts an explicitly enabled s
- Product packages own and test their relational assertions while the service stays product-independent.
- Every package pays the publication and dependency cost of a companion; only owners with a meaningful runtime relationship add listener or trace-state cost.
- Standard compositions can disable all checks or select package names without changing their plugin tree.
- Compositions that mount the diagnostics can disable all checks or select package names without changing their plugin tree.
- Explicit companion entries make diagnostic cost and ownership visible in Cordis config and package exports.
- One selected executable contribution adds one child fiber and its listener/state cost; a selected empty contribution has no listener or trace-state cost, while filtered registrations retain only name ownership.
- Regex sources are deployment configuration and remain fixed until the service reloads.
@@ -8,7 +8,7 @@ Status: implemented
运行时不变式检查跨越会话轨迹、agent 状态、作用域 dispatch 和请求重建。如果所有检查都放在一个诊断包中,该包就必须导入彼此无关的产品领域词汇,测试也会离开真正的所有者;任何产品包新增或移除检查时,都要修改中央包。
部署还需要比“是否加载一个插件”更细的控制。标准组合携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR 下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。
选择启用诊断的部署还需要比“是否加载一个插件”更细的控制。这类组合携带已知的不变式贡献,同时允许全局关闭或按包选择诊断。包稍后加载或在 HMR 下重载时,选择结果必须保持稳定;被过滤的贡献也不能让两个插件静默占用同一个包名。
包所有权还必须覆盖完整。若没有机械化的仓库规则,新包可能遗漏伴随插件、依赖或发布配置,并一直不会进入诊断范围,直到维护者发现这一缺口。
@@ -72,9 +72,9 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写
生成的 scoped event 主体解析表位于 `dsh-scope`,与消费它的契约和不变式相邻。`gen-scoped-events` 使用根 TypeScript Program 枚举 `this: Scoped<Base>` 声明,从真实 `scopeTarget(base, key)` 调用推断路由键类型,并要求唯一、无歧义的 payload 主体或显式 unsupported 标记。提交的运行时映射不导入事件所有者包,因此语义完整性不会扩大服务包或 scope 包的运行时依赖闭包。
### 标准组合与 SDK 输出
### 示例组合与 SDK 输出
标准 agent spine 会挂载服务和四个有状态伴随子路径,并把 `enabled``package_allowlist``package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。
示例 agent spine 会挂载服务和四个有状态伴随子路径,并把 `enabled``package_allowlist``package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。根据[交付配置决策](../simplification/2026-08-03-omit-invariants-from-shipped-config.md),交付的 `dsh` TUI 与 Web 配置树会省略该服务及其伴随插件。
Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一发布表面。生成的配置目录、模块图和 API 文档都从这些源派生。
@@ -97,7 +97,7 @@ Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、
- 产品包拥有并测试自己的关系断言,服务保持与产品无关。
- 每个包都承担 companion 的发布与依赖成本;只有具备有意义运行时关系的所有者才增加 listener 或 trace 状态成本。
- 标准组合无需改变插件树即可关闭全部检查或按包名选择。
- 挂载诊断的组合无需改变插件树即可关闭全部检查或按包名选择。
- 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。
- 每个选中的可执行贡献增加一个子 fiber 及其 listener/状态成本;选中的空贡献不增加 listener 或 trace 状态成本,被过滤注册则只保留包名占用。
- 正则表达式源属于部署配置,在服务重载前保持固定。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md
2026-08-01-packaged-ripgrep-search.md: 7c515618a18b61bd90177a6fdf19bbd52e564209
2026-08-01-packaged-ripgrep-search.zh.md: f2b1a12c737f772bff6a6c91c17f7453dbc89748
@@ -0,0 +1,38 @@
# Agent Note: Packaged ripgrep spawn for glob/grep
Status: implemented
English | [中文](2026-08-01-packaged-ripgrep-search.zh.md)
> Supersedes [bash-backed grep/glob discovery](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md): the v1 decision's explicitly deferred alternative — directly spawning ripgrep — is now what ships.
## Problem
The `glob`/`grep` tools ran through the bash executor seam, which made a system `rg` install a host dependency. On Windows and container images there is no `rg` on `PATH` by default, so the tools silently vanished there; a deployment could only discover that from the load-time probe warning. The bash seam also forced the whole model-visible argument surface through one shell-quoting helper, because a shell sat between the tool and ripgrep — the [bash-backed note](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md) recorded that coupling as the v1 trade-off and named direct spawn as the reasonable follow-up if the shell-string domain ever proved too sensitive. It did: every model value had to survive POSIX single-quoting, the probe had to be scripted in tests, and the executor's own timeout classification duplicated what the cooperative tool-timeout policy already owns.
## Decision
`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. `rgPath` resolves lazily at the first call (memoized per process): `@vscode/ripgrep` resolves its platform package at module evaluation, so a static import would turn a missing or corrupt platform package (`--omit=optional`, partial install) into a Loader-composition failure — the load-time failure mode this change exists to remove. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`.
Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback.
The `fs-glob-sampling` ACP snapshot scenario now executes the real packaged binary against a prepared workspace whose fixed mtimes pin the `--sort=modified` order, replacing the PATH-injected `rg` stand-in (POSIX-only, because the displayed paths carry `/` separators the session-log comparison cannot normalize).
## Alternatives considered
**Keep the bash seam and probe, but document `rg` as a required host dependency.** Rejected: the host dependency is exactly the failure this change removes, and Windows support for the discovery tools was the point of the exercise; a documented requirement is still a requirement.
**Make `rgPath` injectable (a config field or env override) so tests and snapshots keep substituting a stand-in binary.** Rejected: it adds a public deployment surface whose only consumer would be test seams, and the real binary is deterministic enough to pin directly through fixture mtimes — the packaged binary is the deployment, so tests should exercise it.
**Switch to a pure-JS glob/search engine (e.g. `picomatch`/`tinyglobby`).** Rejected: the [dependency-swaps audit](../../rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md) already rejected that on the "no glob engine exists" evidence; ripgrep semantics (`--sort=modified`, VCS pruning, JSON transport, regex dialect) are the tool contract.
## Consequences
- The discovery tools work on every platform the packaged binary covers (darwin/linux/win32, x64/arm64) with no host install; the shipped TUI/Web rosters gain `glob`/`grep` as fixed members ([even-out-shipped-tool-rosters](../feature/2026-07-31-even-out-shipped-tool-rosters.md)).
- The shell-string attack surface is gone: hostile patterns are inert argv elements, pinned by the integration suite, which now runs on Windows too (it previously self-skipped without a system `rg`).
- The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config` is prepended: a host `RIPGREP_CONFIG_PATH` (or an `rg.conf` beside the binary) can otherwise inject a `--pre` preprocessor that executes an arbitrary command for every matched file. With `--no-config`, no config file — and therefore no preprocessor — can reach the search.
- The raw-output overflow path changed shape: the old bash-backed route inherited bash-local's always-on spill and could leave an unread multi-megabyte temp file; the subprocess seam now collects without spill, and overflow is a pure error (`SEARCH_RAW_OUTPUT_OVERFLOW`, "narrow pattern, path, or include and retry") with zero content returned.
- Load-time failure modes changed: a broken subprocess seam now fails the first search call (`SEARCH_FAILED`) instead of failing plugin load through the probe; a missing binary is a launch failure with the packaged path, not a PATH problem.
- The integration suite's fixture dropped a filename Windows cannot represent (`"` in a name), keeping the suite replayable on every platform.
- Regenerating `THIRD_PARTY_NOTICES.md` surfaced a latent generator bug the new dependency made visible: Node's `fs.globSync` returns OS-native separators, so on Windows the `/`-suffixed dev-area prefixes in the notices tiering never matched and dev-only packages (test tooling, support leaves) were mis-tiered as runtime. The generator now normalizes manifest paths at ingestion, and the notices are platform-independent.
- The `@vscode/ripgrep` dependency adds its MIT row to the runtime tier, and pnpm 11's truncated virtual-store directory names needed a content-scan fallback in the notices generator's metadata lookup.
@@ -0,0 +1,38 @@
# Agent Note: glob/grep 改用打包的 ripgrep 二进制直接 spawn
Status: implemented
[English](2026-08-01-packaged-ripgrep-search.md) | 中文
> 取代 [bash 承载的 grep/glob 发现工具](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md)v1 决策中明确延期的方案——直接 spawn ripgrep——现在成为实际交付的实现。
## 问题
`glob`/`grep` 工具经由 bash 执行器 seam 运行,这使系统 `rg` 安装成为宿主依赖。Windows 和容器镜像的 `PATH` 默认没有 `rg`,工具在那里会静默消失;部署方只能从加载期探针警告里发现这一点。bash seam 还迫使整个模型可见参数面经过一个 shell 引号工具,因为工具与 ripgrep 之间隔着一层 shell——[bash 承载决策](../../archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md) 把这种耦合记为 v1 的取舍,并把直接 spawn 列为 shell 字符串域一旦被证明过于敏感时的合理后续。它确实被证明了:每个模型值都要经受 POSIX 单引号转义,探针要在测试里脚本化,执行器自身的超时分类还与协作式工具超时策略已有的职责重复。
## 决策
`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal``rgPath` 在首次调用时懒解析(进程内 memoize):`@vscode/ripgrep` 在模块求值阶段解析其平台包,静态导入会把平台包缺失/损坏(`--omit=optional`、安装不全)变成 Loader 组合加载失败——这正是本次改动要消除的加载期失败模式。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools``systemPrompt``subprocess`
退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-timeout-policy` 中止 `exec.signal`subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。
`fs-glob-sampling` ACP 快照场景改为执行真实的打包二进制,作用于一个用固定 mtime 钉住 `--sort=modified` 顺序的预制工作区,取代 PATH 注入的 `rg` 替身(仅 POSIX:展示路径携带 `/` 分隔符,会话日志比较无法归一化)。
## 备选方案
**保留 bash seam 与探针,仅把 `rg` 记为必需宿主依赖。** 否决:宿主依赖正是本次改动要消除的失败模式,而让发现工具支持 Windows 正是此举的目的;写进文档的依赖仍是依赖。
**让 `rgPath` 可注入(配置字段或环境变量覆盖),让测试与快照继续替换替身二进制。** 否决:这会新增一个只有测试 seam 会消费的公开部署面,而真实二进制本身足够确定——通过 fixture mtime 即可直接钉住;打包二进制就是部署形态,测试应当拿它来测。
**改用纯 JS 的 glob/搜索引擎(如 `picomatch`/`tinyglobby`)。** 否决:[依赖替换审计](../../rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md) 已基于"不存在 glob 引擎"的证据否决过该方向;ripgrep 语义(`--sort=modified`、VCS 剪枝、JSON 传输、正则方言)就是工具契约。
## 后果
- 发现工具在打包二进制覆盖的每个平台(darwin/linux/win32x64/arm64)上开箱即用,无需宿主安装;交付的 TUI/Web 工具清单把 `glob`/`grep` 变为固定成员(见 [拉平交付的工具清单](../feature/2026-07-31-even-out-shipped-tool-rosters.md))。
- shell 字符串攻击面消失:恶意模式只是惰性 argv 元素,由集成套件钉住;该套件现在也在 Windows 上运行(此前没有系统 `rg` 时它自行跳过)。
- spawn 不受沙箱约束(普通的 `ctx.subprocess` 调用),因此前缀 `--no-config`:宿主的 `RIPGREP_CONFIG_PATH`(或二进制旁的 `rg.conf`)否则可注入 `--pre` 预处理器,对每个匹配文件执行任意命令。加上 `--no-config` 后,任何配置文件——因而任何预处理器——都无法触及搜索。
- 原始输出溢出路径的形态改变:旧的 bash 承载路径继承了 bash-local 常开的 spill,可能留下没人读的多 MB 临时文件;subprocess seam 现在无 spill 收集,溢出是纯粹的错误(`SEARCH_RAW_OUTPUT_OVERFLOW`"narrow pattern, path, or include and retry"),不返回任何内容。
- 加载期失败模式改变:subprocess seam 损坏现在让首次搜索调用失败(`SEARCH_FAILED`),而非通过探针使插件加载失败;二进制缺失是带打包路径的启动失败,而不是 PATH 问题。
- 集成套件的 fixture 去掉了 Windows 无法表示的文件名(名称含 `"`),保证套件在每个平台都能重放。
- 重新生成 `THIRD_PARTY_NOTICES.md` 暴露了一个由新依赖带出的潜在生成器 bug:Node 的 `fs.globSync` 返回操作系统原生分隔符,因此在 Windows 上 notices 分层中带 `/` 后缀的 dev 区前缀永远匹配不上,dev-only 包(测试工具、support 叶子)被错分为 runtime。生成器现在在入口处归一化清单路径,notices 与平台无关。
- `@vscode/ripgrep` 依赖为 runtime 层增加其 MIT 行;pnpm 11 截断的虚拟存储目录名需要在 notices 生成器的元数据查找中增加内容扫描回退。
@@ -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-07-30-multi-select-custom-answer-composition.md
2026-07-30-multi-select-custom-answer-composition.md: 581beec89a0f0018ec2df687f5dfe1b1b5b86d22
2026-07-30-multi-select-custom-answer-composition.zh.md: 5c9cb59822aca3fbf49fbbdf522c76f963df3480
@@ -0,0 +1,25 @@
# Agent Note: Multi-select custom answer composition
Status: implemented
English | [中文](2026-07-30-multi-select-custom-answer-composition.zh.md)
## Problem
The user-interaction result vocabulary carries selected option labels and optional custom text in separate fields, but its original semantics made them mutually exclusive for every question. On a multi-select question, opening or typing the custom answer discarded labels the user had already selected. The TUI returned only the custom text, and the Web host rejected a client response that preserved both fields.
## Decision
For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI retains pending custom text across option/custom mode switches and projects it with checked labels from either submit mode; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation.
Single-select and optionless questions keep exclusive semantics: custom text overrides any selected option. The result shape remains `{ id, selected, custom? }`, so no wire or tool-output schema changes.
## Alternatives considered
**Encode custom text as another `selected` label.** Rejected because it would erase the distinction between caller-provided option labels and human-authored text, weakening validation and forcing consumers to infer which value was custom.
**Allow `selected` and `custom` together for every question.** Rejected because a single-select question represents one answer; permitting a selected option plus custom text would make its cardinality ambiguous. The combined form is limited to questions that explicitly opt into multiple answers.
## Consequences
Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web component and assembled-browser coverage, TUI coverage, host-response coverage, and tool-projection coverage pin the combined result. Web, TUI, and tool-projection coverage also retain labels-only answers; assembled keyless TUI coverage pins the combined terminal flow, and single-select host coverage pins the remaining exclusivity rule.
@@ -0,0 +1,25 @@
# Agent Note: 多选题自定义答案组合
Status: implemented
[English](2026-07-30-multi-select-custom-answer-composition.md) | 中文
## 问题
用户交互结果的词汇分别通过不同字段携带选中的选项标签和可选的自定义文本,但最初的语义要求每个问题的这两个字段互斥。对于多选题,打开自定义答案或输入文本会丢弃用户已选中的标签。TUI 只返回自定义文本,而 Web 宿主会拒绝同时保留两个字段的客户端响应。
## 决策
对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本,Web 草稿都会保留两个值;TUI 在选项与自定义模式之间切换时会保留待提交的自定义文本,并在任一模式提交时将其与已勾选的标签一同投影;Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。
单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。
## 考虑过的替代方案
**把自定义文本编码为另一个 `selected` 标签。** 不予采纳,因为这样会抹去调用方提供的选项标签与用户填写文本之间的区别,削弱校验,并迫使消费方推断哪个值属于自定义内容。
**允许所有问题同时使用 `selected` 与 `custom`。** 不予采纳,因为单选题只表示一个回答;允许选中选项与自定义文本并存会使其基数含义模糊。组合形式仅适用于显式选择多项回答的问题。
## 后果
多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的覆盖率、TUI 覆盖率、宿主响应覆盖率和工具投影覆盖率共同固定组合结果。Web、TUI 与工具投影覆盖率还固定了仅含标签的回答形态;组装后的无密钥 TUI 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。
@@ -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-07-31-resume-selector-batch-projection.md
2026-07-31-resume-selector-batch-projection.md: 39146527f13b20813bb6f5d5f1349ecab5724662
2026-07-31-resume-selector-batch-projection.zh.md: 10333ea7cc5e7f2051c37f3374c5dc061bd0586e
@@ -0,0 +1,35 @@
# Agent Note: Resume selector folds titles only
Status: implemented
English | [中文](2026-07-31-resume-selector-batch-projection.zh.md)
## Problem
Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per listed session under an unbounded `Promise.all`. Each call re-listed the whole persistence store inside `SessionCorpus.load()` (O(N²) listings), read and decompressed the complete log, replay-validated every event through the `Session` constructor, and deep-cloned the header and events up to three times — all to derive one selector row's title, last-activity time, last `turn/end` label, provider/model route, and goal phase. On a real store (185 sessions, 87 MB compressed, ~353k events) the selector took tens of seconds to open, and the cost grew with total log size rather than session count.
## Decision
Selector rows fold nothing but titles, and everything else a row shows comes from metadata:
- Titles come from the projection system: `session-title` already registers a `title` unit, so a live row reads the registry snapshot, a persisted row reads the durable checkpoint row (`sessionProjectionCache.cachedSnapshot`, zero I/O), and only a row without a usable checkpoint pays a `coldSnapshot` — checkpoint plus a `readFrom` tail, written back so the next scan is zero-I/O. Cold reads are bounded by the TUI `resumeScanConcurrency` config. A composition without the cache falls back to one bounded `readTitleSnapshots` batch over the logs; either path isolates a per-row failure into the disabled "Unreadable session" fallback.
- The activity timestamp never reads a log: a live session uses its last in-memory event time; a persisted session stats the artifact named by the optional `sessionPersistence.locate()` (mtime), falling back to the header's creation time when the backend locates no per-session artifact (SQLite) or the stat fails. Any append moves the mtime, so a mere pickup boundary now floats a browsed session up — accepted as the price of a metadata-only timestamp.
- The last-turn label, provider/model route, and goal phase columns are gone from rows. Route availability is now enforced by the Enter-time preflight, which fully reads and replay-validates the one chosen log through `readSession` before handoff.
The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame, Enter reports that sessions are still loading, and Escape cancels. Closing the overlay aborts the scan through the `AbortSignal` the query methods accept; a signal-ignoring backend's late settlement is dropped by a staleness check. The finished scan swaps rows in through `setCandidates` (clearing a stale still-loading error) without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; one catch spans listing, titles, and mtimes, so any scan failure closes the overlay and reports a notice rather than stranding the loading placeholder.
No session-query or session-persistence surface changed. The shipped TUI composition gains the projection registry, storage, and projection-cache rows (mirroring the web overlay over the same `storages` root, so checkpoints written by either surface serve both); the first scan over a pre-existing store still reads each log once to seed checkpoints, and every later scan is metadata-only.
## Alternatives considered
**Keep per-row route/turn/goal columns via a generic batch projection (`projectSessions`).** Implemented first, then rejected: it still decompressed and parsed every log on every `/resume`, so browsing cost stayed O(total log bytes), and it grew the session-query public API for one consumer. The public seam was reverted; `readTitleSnapshots` keeps using the internal `projectMany` unchanged.
**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominated on large logs. The redundant pre-listing in `load()` remains a candidate cleanup with error-semantics implications.
**Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, both backends, and the query record shape for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times.
**A bespoke persisted title index or TUI-local title cache.** Rejected: the session-projection cache already is the owned durable checkpoint system with an invalidation contract (`stateVersion`, identity binding, shrunk-log anchoring); mounting it beats adding a parallel cache.
## Consequences
Opening `/resume` performs one listing, one stat per persisted row, and per-row title reads that touch only checkpoint rows and log tails once checkpoints exist — O(session count) metadata instead of O(total log bytes); the fallback path without the cache remains one bounded title pass. Rows show title, timestamp, status, and id only; route problems surface as an Enter-time preflight error instead of a disabled row, and a session that fails replay is caught by preflight rather than the listing. Browsed-then-abandoned sessions float up on their pickup mtime. Fake `sessionQuery` services in TUI tests provide `readTitleSnapshots` alongside `listSessions`/`readSession`, and the test harness forwards an optional `locate`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture.
@@ -0,0 +1,35 @@
# Agent Note: 恢复选择器只折叠标题
Status: implemented
[English](2026-07-31-resume-selector-batch-projection.md) | 中文
## Problem
打开 TUI `/resume` 选择器时,会在一个无界 `Promise.all` 中对每个列出的会话调用一次 `sessionQuery.readSession()`。每次调用都会在 `SessionCorpus.load()` 内部重新列出整个持久化存储(O(N²) 次列表查询)、读取并解压完整日志、通过 `Session` 构造函数对每个事件做回放验证,并将 header 和事件深克隆多达三次——而这一切只为推导一行选择器条目的标题、最近活动时间、最后一个 `turn/end` 标签、提供方/模型路由和目标阶段。在真实存储上(185 个会话、压缩后 87 MB、约 35.3 万个事件),选择器需要数十秒才能打开,且开销随日志总大小而非会话数量增长。
## Decision
选择器行除标题外不折叠任何内容,行内其余信息全部来自元数据:
- 标题来自投影系统:`session-title` 已注册 `title` 投影单元,因此实时行读取注册表快照,持久化行读取持久 checkpoint 行(`sessionProjectionCache.cachedSnapshot`,零 I/O),只有没有可用 checkpoint 的行才付出一次 `coldSnapshot`——checkpoint 加 `readFrom` 尾部折叠,并写回使下次扫描零 I/O。冷读取受 TUI `resumeScanConcurrency` 配置约束。未挂载缓存的组合回退到一次对日志的有界 `readTitleSnapshots` 批量读取;两条路径都把单行失败隔离为禁用的"Unreadable session"回退。
- 活动时间戳从不读取日志:实时会话取内存中最后一个事件的时间;持久化会话对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当后端定位不到按会话的产物(SQLite)或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的会话上浮——这是元数据时间戳的代价,予以接受。
- 行内不再有最后轮次标签、提供方/模型路由和目标阶段列。路由可用性改由 Enter 时的预检强制:预检通过 `readSession` 完整读取并回放验证选中的那一份日志后才移交。
选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入,Enter 提示会话仍在加载,Escape 取消。关闭 overlay 会通过查询方法接受的 `AbortSignal` 中止扫描;忽略信号的后端的迟到结算由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询、标题与 mtime 共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告通知,而不会让加载占位符悬置。
session-query 与 session-persistence 的任何表面都未改变。随附的 TUI 组合新增投影注册表、storage 与投影缓存行(镜像 web overlay,共用同一 `storages` 根,因此任一表面写下的 checkpoint 都服务两者);对既有存储的首次扫描仍会各读取一次日志以播种 checkpoint,之后的每次扫描都只读元数据。
## Alternatives considered
**通过通用批量投影(`projectSessions`)保留每行的路由/轮次/目标列。** 先实现后否决:它仍在每次 `/resume` 时解压并解析全部日志,浏览开销依旧是 O(日志总字节数),且为单一消费者扩大了 session-query 公开 API。该公开接缝已回退;`readTitleSnapshots` 继续使用内部 `projectMany`,保持不变。
**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。
**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从接缝角度最干净,但要触碰持久化契约、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。
**专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效契约(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。
## Consequences
打开 `/resume` 只执行一次列表查询、每个持久化行一次 stat,标题读取在 checkpoint 就绪后只触碰 checkpoint 行和日志尾部——O(会话数) 的元数据开销,而非 O(日志总字节数);无缓存的回退路径仍是一次有界标题扫描。行内只显示标题、时间戳、状态和 id;路由问题以 Enter 时预检错误的形式出现,而不再是禁用行;回放会失败的会话由预检而非列表阶段拦截。浏览后放弃的会话会因 pickup 的 mtime 上浮。TUI 测试中的伪造 `sessionQuery` 服务在 `listSessions`/`readSession` 之外提供 `readTitleSnapshots`,测试 harness 会转发可选的 `locate`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。
@@ -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-07-31-tui-diff-context-line-accounting.md
2026-07-31-tui-diff-context-line-accounting.md: d465568d5f6cad15ef4647be7ef936c2f5824bba
2026-07-31-tui-diff-context-line-accounting.zh.md: dd1a3eb1acf03477059d48461219700b224b2f96
@@ -0,0 +1,33 @@
# Agent Note: TUI diff context lines stay neutral
Status: implemented
English | [中文](2026-07-31-tui-diff-context-line-accounting.zh.md)
## Problem
Result-time filesystem diffs carry the applied change with three surrounding context lines in each `FileDiff.oldText` and `FileDiff.newText`. The TUI rendered every old-side row as removed and every new-side row as added, including the identical context present on both sides. A one-line edit therefore appeared as seven removals plus seven additions, and the footer repeated those inflated totals.
## Decision
The TUI compares each `FileDiff` whose old and new text are both available. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. `maxDiffEditLength` bounds the exact comparison by its combined added and removed line count; the default is 1000. Exceeding the bound renders the complete old side as removed and the complete new side as added, marks the footer approximate, and caches that result so redraws do not repeat the comparison. A tool result clears the pending-view cache before deriving the settled view, including when a presenter mutates and reuses the same view object.
When `oldText` is `null`, the renderer cannot distinguish a create from a pending overwrite or an argument fallback whose prior text is unavailable. It therefore shows every non-empty new-side row as added, without claiming those rows were absent from an existing file. Empty new content renders no synthetic added row.
This remains a consumer-side interpretation of the existing `FileDiff` contract. Filesystem tools continue to persist contextual before/after snippets, so other consumers keep their placement context and existing session logs replay with corrected TUI presentation. The TUI uses the same maintained `diff` package as `dsh-tool-fs` instead of introducing a second line-diff implementation.
## Alternatives considered
**Remove context from filesystem result metadata.** Rejected: contextual applied hunks are intentional producer output used by capable editors, and changing them would weaken every consumer while leaving old session logs misleading in the TUI.
**Extend `FileDiff` with persisted per-line tags.** Rejected: the tags can be derived deterministically from the existing before/after pair; persisting them would widen the cross-package and session-log contract solely for one renderer.
**Match equal lines by position without a diff algorithm.** Rejected: insertions and deletions shift subsequent context, so positional pairing would misclassify valid hunks.
**Run every comparison to completion.** Rejected: pending tool views can contain unrestricted model-authored old and new strings, and an unbounded Myers comparison can block the synchronous terminal renderer.
## Consequences
TUI diff cards distinguish evidence-bearing context from the mutation itself, and an exact `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Result-time filesystem hunks are context-bounded; unrestricted pending views either complete within the configured edit-length budget or degrade to an explicitly approximate linear rendering.
The focused TUI tests cover neutral context, exact totals, an empty create, bounded fallback, result-time cache invalidation, and redraw cache reuse. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, exact footer, and approximate fallback through collapsed and expanded card states.
@@ -0,0 +1,33 @@
# Agent Note: TUI diff 上下文行保持中性
Status: implemented
[English](2026-07-31-tui-diff-context-line-accounting.md) | 中文
## 问题
文件系统 diff 返回结果时,每个 `FileDiff.oldText``FileDiff.newText` 都会包含已应用的变更及其前后各 3 行上下文。TUI 将旧侧的每一行都渲染为删除行,将新侧的每一行都渲染为新增行,其中包括两侧相同的上下文。因此,一行编辑会显示为删除 7 行并新增 7 行,页脚还会重复这些虚高的合计值。
## 决策
TUI 会比较每个变更前后文本均可用的 `FileDiff`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。`maxDiffEditLength` 以新增行与删除行的合计数为精确比较设置上限,默认值为 1000。超过上限时,TUI 会把完整旧侧渲染为删除内容、把完整新侧渲染为新增内容,将页脚标记为近似结果,并缓存该结果,避免后续重绘重复比较。工具结果会在派生已结算视图前清除待处理视图缓存,即使 presenter 修改并复用同一个视图对象也一样。
`oldText``null` 时,渲染器无法区分文件创建、待处理覆写,以及旧文本不可用的参数回退。因此,它会把新侧的每个非空行显示并计作新增行,但不会声称这些行原先不存在于已有文件中。新内容为空时,不会渲染虚构的新增行。
该行为仍然只是消费方对现有 `FileDiff` 契约的解释。文件系统工具仍会持久化带上下文的变更前后片段,因此其他消费方仍能获得定位上下文,已有会话日志在回放时也会采用修正后的 TUI 呈现。TUI 与 `dsh-tool-fs` 共用同一个受维护的 `diff` 包(package),无需引入第二套逐行 diff 实现。
## 考虑过的替代方案
**从文件系统结果元数据中移除上下文。** 不予采纳:带上下文的已应用 hunk 是有意保留的生产方输出,供具备相应能力的编辑器使用;更改这些内容会让所有消费方丢失信息,同时旧会话日志在 TUI 中仍会产生误导。
**为 `FileDiff` 扩展持久化的逐行标签。** 不予采纳:这些标签可以根据现有的变更前后文本对确定性派生;仅为一个渲染器持久化标签,会扩大跨包契约和会话日志契约。
**不使用 diff 算法,按位置匹配相同行。** 不予采纳:插入和删除会使后续上下文发生位移,因此按位置配对会把有效 hunk 错误分类。
**让所有比较都运行至完成。** 不予采纳:待处理工具视图可能包含由模型生成且长度不受限制的新旧字符串,无界的 Myers 比较可能阻塞同步终端渲染器。
## 后果
TUI diff 卡片会区分用于佐证的上下文与变更本身,精确的 `+A -R` 页脚会报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。结果时刻的文件系统 hunk 受上下文范围限制;不受限制的待处理视图要么在配置的编辑长度预算内完成比较,要么降级为明确标注为近似结果的线性渲染。
聚焦的 TUI 测试覆盖中性上下文、精确合计值、空文件创建、有界回退、结果到达时的缓存失效和重绘缓存复用。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md
2026-07-24-tui-question-dialog-multiline.md: fc6e9bceeee4abc46a69a23124d09fcd4f3c7224
2026-07-24-tui-question-dialog-multiline.zh.md: a56821921bad1016009687bde63eae5f4d893cdf
@@ -0,0 +1,41 @@
# Agent Note: TUI QuestionDialog renders options across multiple lines
Status: implemented
English | [中文](2026-07-24-tui-question-dialog-multiline.zh.md)
## Problem
`ctx.userInteraction.ask()` must keep question text, supporting `detail`, option labels, descriptions, validation, and controls readable inside configured width and height bounds. The question panel also belongs directly above the editor: placing it at the terminal edge separates the pending decision from both the transcript that prompted it and the input that follows it.
## Decision
The TUI renders a pending question as an inline modal between the transcript/status area and the editor while retaining the shared FIFO with model and plugin overlays:
- `InlineModalComponent` applies `questionDialogWidth` and `questionDialogMaxHeight` inside the normal component flow. The effective question height is additionally clamped to the current viewport after reserving the editor, so the editor remains below the question during resize.
- `renderOptionBlock` wraps each label beneath its cursor/number prefix and renders the muted description on separately wrapped, equally indented lines. The progress header, question, custom-answer hint, validation text, and final rows are width-bounded as well; the final ellipsis clamp is only a safety boundary for prefixes or other indivisible content. The explicit `↑ N lines hidden` fallback is reserved for a viewport below the configured minimum, where the whole semantic layout cannot fit.
- When question text or `detail` exceeds the header allocation, the header becomes a paged line viewport with its own `… lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down traverse both line viewports: forward navigation exhausts the header/detail pages before entering oversized selected-option pages, and backward navigation reverses that order. This keeps plan-review detail reachable rather than leaving it behind the height clamp.
- The option-line budget subtracts padding, header, position, and footer rows before `windowBlocks` runs. The window obeys both `maxQuestionOptions` and the remaining row budget, keeps the selected option visible, and renders omitted options as `↑ N more` / `↓ N more` markers. If fixed chrome would leave fewer than four option rows, the compact header becomes the line pager so selected content, paging status, and both option markers still fit.
- When one selected block exceeds its allocation, it becomes a line viewport with a `lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down expose every wrapped line without allowing the block to hide the option markers, validation, or controls.
Package tests pin count and height bounds, header and selected-block paging order, narrow-width wrapping, selection behavior, and placement relative to retained editor input. Semantic TUI snapshots pin the assembled terminal layout, header/detail and selected-option page transitions, and validation state.
## Alternatives considered
**Ellipsis-only horizontal truncation.** Keeping one option per row would signal lost text without making the description readable and would not address vertical bounds. The implementation wraps readable content and retains an ellipsis only as a final safety boundary.
**Wrap the combined label and description.** A composite row couples their widths, so either side can starve the other. Separate lines keep both widths predictable.
**Keep the question as a bottom-edge overlay.** A terminal-edge anchor can place the panel after the editor or cover lower chrome, depending on transcript and viewport height. The inline modal preserves ordering while the modal manager retains focus and FIFO ownership.
**Push the bounds into pi-tui.** Generic overlay slicing cannot identify option boundaries, selected content, controls, or the inline editor relationship. The owning dialog therefore applies semantic count, row, and paging rules.
**Use only the option-count cap.** `maxQuestionOptions` remains a public count bound, but it cannot contain wrapped blocks by itself. The dialog enforces the count and row bounds together.
## Consequences
- Descriptions consume additional rows, so fewer options can be visible than `maxQuestionOptions`; markers state the omitted option counts.
- Long question text and plan-review detail remain reachable inside a height-bounded panel, at the cost of sharing Page Up and Page Down with selected-option paging.
- An oversized selected block reserves one status row and requires Page Up or Page Down to read beyond the current line page.
- The inline question can displace older transcript rows from a short viewport. Below the configured minimum height, the final fallback can collapse upper rows behind an explicit hidden-line marker so the input controls and editor remain available.
- The model-facing schema, selected labels, abort/cancel behavior, and ACP elicitation path are unchanged.
@@ -0,0 +1,41 @@
# Agent Note: TUI QuestionDialog 以多行方式渲染选项
Status: implemented
[English](2026-07-24-tui-question-dialog-multiline.md) | 中文
## 问题
`ctx.userInteraction.ask()` 必须确保问题正文、`detail` 补充内容、选项标签、描述、校验信息和控件在已配置的宽度与高度边界内均可读。问题面板也直接位于编辑器上方:若将其置于终端边缘,待处理决策就会同时脱离触发该决策的 transcript(文本记录)和后续输入。
## 决策
TUI 将待处理问题渲染为位于 transcript/状态区域与编辑器之间的内联模态框,同时仍与模型浮层和插件浮层共享 FIFO:
- `InlineModalComponent` 在正常组件流内应用 `questionDialogWidth``questionDialogMaxHeight`。系统在为编辑器预留空间后,还会根据当前视口限制问题的实际高度,因此调整窗口大小时,编辑器仍位于问题下方。
- `renderOptionBlock` 将每个标签换行到光标/编号前缀下方,并在另行换行且缩进相同的行上渲染弱化的描述。进度标题、问题、自定义答案提示、校验文本和末尾行也受宽度边界约束;最终的省略号截断仅作为前缀或其他不可拆分内容的安全边界。明确的 `↑ N lines hidden` 回退仅用于低于已配置最小值、无法容纳完整语义布局的视口。
- 当问题正文或 `detail` 超出头部分配的空间时,头部会成为带有独立 `… lines A-B/N • PgUp/PgDn` 状态行的分页行视口。Page Up 和 Page Down 会遍历这两个行视口:向前导航先翻完问题正文/`detail` 页面,再进入超大选中选项页面;向后导航则采用相反顺序。这样可确保计划评审的 `detail` 内容始终可达,而不会被高度边界挡住。
-`windowBlocks` 运行前,选项行预算会扣除内边距、标题行、位置行和页脚行。窗口同时遵守 `maxQuestionOptions` 和剩余行预算,保持选中项可见,并将省略的选项渲染为 `↑ N more``↓ N more` 标记。若固定界面元素会使选项行少于四行,紧凑头部会转为行分页器,从而容纳选中内容、分页状态和上下两个选项标记。
- 当一个选中块超出分配空间时,它会成为带有 `lines A-B/N • PgUp/PgDn` 状态行的行视口。Page Up 和 Page Down 可展示每一行已换行内容,同时防止该块遮住选项标记、校验信息或控件。
包(package)测试固定数量和高度边界、头部与选中块的分页顺序、窄宽度换行、选择行为,以及问题相对于保留的编辑器输入的位置。语义 TUI 快照固定组装后的终端布局、头部/详情与选中选项的分页转换,以及校验状态。
## 备选方案
**仅用省略号进行横向截断。** 保持每个选项占一行,只能提示文本有所丢失,无法使描述变得可读,也无法处理纵向边界。该实现会对可读内容换行,仅将省略号保留为最终安全边界。
**将标签与描述合并后换行。** 组合行会将两者的宽度耦合在一起,任一方都可能挤占另一方的空间。分行渲染可使二者的宽度保持可预测。
**将问题保留为终端底边浮层。** 根据 transcript 和视口高度,锚定在终端边缘的面板可能出现在编辑器之后,也可能遮盖下方界面元素。内联模态框可保留顺序,同时由模态管理器继续负责焦点和 FIFO 所有权。
**将边界处理下推至 pi-tui。** 通用浮层切片无法识别选项边界、选中内容、控件或内联编辑器关系。因此,负责该语义的对话框会应用数量、行数和分页规则。
**仅使用选项数量上限。** `maxQuestionOptions` 仍是公开的数量边界,但仅靠它无法容纳已换行的块。对话框会同时执行数量边界和行数边界。
## 后果
- 描述会占用额外行,因此可见选项数可能少于 `maxQuestionOptions`;标记会说明省略的选项数量。
- 较长的问题正文和计划评审 `detail` 在受高度约束的面板内仍然可达,代价是 Page Up 和 Page Down 需要与选中选项分页共用。
- 超出空间的选中块会预留一行状态信息;若要阅读当前页面之外的行,必须使用 Page Up 或 Page Down。
- 在较矮的视口内,内联问题可能将较早的 transcript 行挤出可见区域。低于已配置最小高度时,最终回退可能将上部行折叠到明确的隐藏行标记之后,从而让输入控件和编辑器仍然可用。
- 面向模型的 schema、选中的标签、中止/取消行为,以及 ACPAgent Client Protocol)的 elicitation 路径均保持不变。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md
2026-07-30-versioned-gui-welcome-onboarding.md: 8155838f3b6b50f3474ef6c30065ad0d79e6f8af
2026-07-30-versioned-gui-welcome-onboarding.zh.md: c221a6d663af60b03757f135045961bcbcdd0da7
2026-07-30-versioned-gui-welcome-onboarding.md: 4707769d4fa9fbf184e09a2e73087dfd326070be
2026-07-30-versioned-gui-welcome-onboarding.zh.md: c9d2e6274c476c59abc077aa3255ffe57a8a48bc
@@ -14,15 +14,15 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
**Acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once.
**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again.
**Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations.
**Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations.
**Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Welcome and conditional credential setup render as successive pages in this stage instead of independent modals. Both pages reuse the Web UI's black `BrandWordmark`. The welcome page preserves the four authored paragraphs verbatim under the `内测声明` title; every paragraph uses one 16/28 body scale, and only the requested action clause inside the final paragraph receives a subtle 500 weight. A short staggered opacity/vertical entrance supplies pacing without blocking interaction and disappears under reduced motion. The title receives initial focus, Continue is the sole button, and no close, Escape, or mask-click path exists.
## Alternatives considered
**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream.
**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. Non-loopback fallback therefore remains process-local rather than browser-profile-local.
**A second independent modal in `ui-settings-general`** — rejected because list registrants would still stack whenever welcome and credential readiness were both true. Ordered ownership belongs to the shell that declares and renders the list.
@@ -32,4 +32,4 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
## Consequences
A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. Reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. Focused store and React tests pin exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console.
A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. On loopback, reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. On non-loopback, Continue advances the live process without a privileged settings request and reload presents the notice again. Focused store and React tests pin both persistence modes, exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console.
@@ -14,15 +14,15 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
**确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。
**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authorityhostname 判定函数留在 connection 包内,其他客户端插件只消费服务状态,不跨插件导入实现函数。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。
**并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。
**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。
**引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px``rgba(0, 0, 0, 0.24)``backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出舒缓节奏,但不会阻碍交互,并会在用户启用减少动态效果时禁用。初始焦点落在标题上,「继续」是唯一按钮,且不存在关闭、Escape 或点击遮罩的退出路径。
## 曾考虑的替代方案
**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。
**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。因此,非 loopback 的回退保持为进程内状态,而不是浏览器 profile 状态。
**在 `ui-settings-general` 中再增加一个独立模态窗口**:不予采用,因为欢迎通知和凭据就绪状态同时为真时,list 注册方仍会堆叠。声明并渲染该 list 的外壳应当持有有序所有权。
@@ -32,4 +32,4 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
## 后果
全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。针对性的 store 与 React 测试固化了精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。
全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。在 loopback 上,点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。在非 loopback 上,「继续」会在不发起受保护 settings 请求的情况下推进当前进程,重新加载则再次显示通知。针对性的 store 与 React 测试固化了两种持久化模式、精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md
2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c
2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d
2026-07-30-web-diff-card.md: eb43e09d6173ca2270df97cecaab6da36c70a679
2026-07-30-web-diff-card.zh.md: 669cd49abc8eba8637705cd7c9f331cc51607755
@@ -16,13 +16,13 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff`
`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends:
The component shares the TUI's single-column framing, line-terminator rule, and distinct-path file count. Line classification differs: Web renders the complete old and new sides, while the TUI derives neutral context and exact changed rows when its bounded comparison completes and labels its whole-side fallback approximate.
- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both.
- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side.
- **Path grouping.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The TUI keeps a path header on every hunk, but both front ends count distinct paths in the `N file(s)` footer, so two hunks in one file read as `1 file`.
- **Whole-side change colors.** Every old-side line is `- ` on the error token and every new-side line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side.
- **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends.
- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry.
- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable.
- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` and the TUI use: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives.
- **Footer and copy.** A dim `└ +A -R · N file(s)` footer reports the Web card's complete new- and old-side line counts. The TUI footer instead reports exact changed rows when available and marks a bounded whole-side fallback approximate; both use the same distinct-path file count. The copy control copies the prefixed Web diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable.
Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer.
@@ -16,13 +16,13 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行
`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
组件的契约遵循 TUI 的 `diffLines``packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态:
组件与 TUI 共用单栏框架、行终止符规则和去重路径计数。两者的行分类不同:Web 渲染完整的变更前后两侧,而 TUI 会在有界比较完成时派生中性上下文和精确变更行,并把整侧回退标记为近似结果。
- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`
- **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。
- **路径分组。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。TUI 在每个 hunk 上都保留路径头,但两个前端的 `N file(s)` 页脚都按去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`
- **整侧改动配色。** 旧侧每一行都以 error token 上的 `- ` 显示,新侧每一行都以 success token 上的 `+ ` 显示,并在横向滚动的盒子里以 `white-space: pre` 逐字绘制源码行靠缩进阅读,因此滚动而不折行。新建(`oldText: null`)没有删除侧。
- **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。
- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。
- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。
- **行终止符。** 每一侧的内容按 `TerminalBlock` 与 TUI 共用的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。
- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚报告 Web 卡片完整新侧与旧侧的行数。TUI 页脚则在可用时报告精确变更行数,并把有界整侧回退标记为近似结果;两者使用相同的去重路径计数。复制控件复制带前缀的 Web diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可辨别归属。
几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。
@@ -32,7 +32,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX
**并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。
**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。
**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PRPull Request保持为对既有契约的纯 Web 消费。
**复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md
2026-07-31-even-out-shipped-tool-rosters.md: fe2ed54a70934918b739ac466dc4b0e8f4a93115
2026-07-31-even-out-shipped-tool-rosters.zh.md: 14cf6e891aa368bcaee8d977fbf5263f36a39dc6
2026-07-31-even-out-shipped-tool-rosters.md: e325f4614f8d7305ce2c6199a25afd56b51fad61
2026-07-31-even-out-shipped-tool-rosters.zh.md: a9b49c454d78387583aa7dd9e25f5d5c850a15ae
@@ -12,7 +12,7 @@ The result was a user-visible difference nobody had decided: the same model, ask
## Decision
The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster: twenty tools on every host, plus `glob` and `grep` when ripgrep is available. `tool-session-query` joined and then left again — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) keeps the model-facing consumer opt-in — while the rest of this roster stands.
The rows that are not surface-specific move into [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster: twenty-two tools on every host — the twenty shared rows plus `glob` and `grep`, which are fixed members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). `tool-session-query` joined and then left again — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) keeps the model-facing consumer opt-in — while the rest of this roster stands.
Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search.
@@ -46,7 +46,7 @@ The same smoke also pins the TUI execution posture from the same artifact. Those
[`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) covers the Web surface in the built lane, asserting its catalog, that its access default is untouched, and that `workspace-write`'s writable roots include the temp directories — a trap that makes sandbox tests lie when the workspace sits under `/tmp` ([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts)).
`glob` and `grep` are asserted as an all-or-nothing pair rather than fixed members: `dsh-tool-fs-search` probes `command -v rg` at load and registers neither tool without ripgrep, which is a host dependency.
`glob` and `grep` are asserted as fixed members rather than a host-dependent pair: `dsh-tool-fs-search` spawns the packaged ripgrep binary and registers both tools unconditionally, so the pair is always present.
Beyond the committed tests, both surfaces were driven against a real key from the built `apps/cli/lib/bin.js` under plain Node. Every mounted tool executed successfully, including `ralph` and `web_search`; the model never reached `cordis_*` or `mcp_*`, fell back to `grep` when asked for LSP navigation, and used a background `bash` task when asked for a persistent terminal.
@@ -62,7 +62,7 @@ Beyond the committed tests, both surfaces were driven against a real key from th
## Consequences
The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert the twenty unconditional names exactly and require the ripgrep-dependent pair to be either present together or absent together on both sides, so a later change that alters only one surface fails a check instead of shipping quietly; the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) is exactly such a later change, and both tests moved with it.
The same model gets the same tools on both surfaces, and the difference that existed for no recorded reason is gone. The tests assert the twenty unconditional names exactly and pin `glob` and `grep` as fixed members on both sides, so a later change that alters only one surface fails a check instead of shipping quietly; the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) is exactly such a later change, and both tests moved with it.
`apps/cli` gained five workspace dependencies: four the shipped tree mounted, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. Four remain — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) removed `@deepseek-ai/dsh-tool-session-query` along with its row.
@@ -12,7 +12,7 @@ Status: implemented
## 决策
那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query``tool-str-replace-editor``repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单:每台宿主上都有二十个工具ripgrep 可用时再加上 `glob``grep``tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。
那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml),另有三行加入:`tool-session-query``tool-str-replace-editor``repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单:每台宿主上都有二十个工具——二十个共享行加上 `glob``grep`,它们成为固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)`tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。
有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。
@@ -46,7 +46,7 @@ Status: implemented
[`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) 在构建产物 lane 中覆盖 Web surface,断言它的工具目录、它的访问默认值未被触碰,以及 `workspace-write` 的可写根包含临时目录——一个会让沙箱测试说谎的陷阱,当工作区落在 `/tmp` 下时([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts))。
`glob``grep` 被作为全有或全无的一对断言,而不是固定成员:`dsh-tool-fs-search` 在加载时探测 `command -v rg`,没有 ripgrep 就两个工具都不注册,这是宿主依赖
`glob``grep` 被作为固定成员断言而不是一对宿主依赖:`dsh-tool-fs-search` spawn 打包的 ripgrep 二进制并无条件注册两个工具,因此这一对始终在场
除入库测试外,两个 surface 都以 plain Node 从构建产物 `apps/cli/lib/bin.js` 出发、用真实密钥驱动过。每一个已挂载的工具都执行成功,包括 `ralph``web_search`;模型从未触达 `cordis_*``mcp_*`,被要求做 LSP 跳转时退化到 `grep`,被要求开持久终端时用了后台 `bash` 任务。
@@ -62,7 +62,7 @@ Status: implemented
## 后果
同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言二十个无条件提供的名称,并要求依赖 ripgrep 的一对工具在两侧要么同时存在、要么同时缺席,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去;[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)正是这样一次后来的改动,两个测试也随之移动。
同一个模型在两个 surface 上拿到同样的工具,那处没有记录理由的差异消失了。测试会精确断言二十个无条件提供的名称,并`glob``grep` 作为固定成员钉在两侧,因此日后只改一个 surface 都会让检查失败而不是悄悄发出去;[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)正是这样一次后来的改动,两个测试也随之移动。
`apps/cli` 增加了五个 workspace 依赖:四个是交付树当时挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。四个保留了下来——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)把 `@deepseek-ai/dsh-tool-session-query` 连同它的行一起移除了。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-03-omit-invariants-from-shipped-config.md
2026-08-03-omit-invariants-from-shipped-config.md: ff9a3b0ab2b4797ca4e96bea9e6b961b2e38501f
2026-08-03-omit-invariants-from-shipped-config.zh.md: 526ce0756e69b36b6f54b46b23e1814322d6a1fd
@@ -0,0 +1,30 @@
# Agent Note: Omit runtime invariants from shipped dsh config
Status: implemented
English | [中文](2026-08-03-omit-invariants-from-shipped-config.zh.md)
## Problem
`@deepseek-ai/dsh-invariants` and package-owned `./invariant` companions are optional development diagnostics. The shipped TUI mounted the service and four stateful companions while the shipped Web tree omitted them, so the two product surfaces had different diagnostic cost and failure behavior. A relational assertion failure could terminate an ordinary TUI run even though the always-on product boundary remained responsible for session validation and immutable history.
## Decision
The shipped `dsh` configuration trees under `apps/cli/config/` mount neither `@deepseek-ai/dsh-invariants` nor any package-owned `./invariant` companion. The CLI package therefore carries no direct dependency on the invariant service.
Invariant support remains available for focused tests, example bundles, generated SDK compositions, and custom deployments that opt into diagnostics explicitly. Session validation, snapshotting, freezing, and provenance remain always on and do not depend on the optional service, as defined by the [source-owned immutability decision](../architecture/2026-06-11-dev-invariants-over-deep-readonly.md).
The built CLI config-dump test checks both shipped surfaces and rejects either the service entry or any `@deepseek-ai/dsh-*/invariant` entry.
## Alternatives considered
- **Mount the service with `enabled: false`.** Rejected because the shipped tree and CLI dependency would still carry diagnostics that install no checks.
- **Keep the TUI-only mount.** Rejected because the shipped surfaces would retain different diagnostic and failure behavior.
- **Remove invariant support from the repository.** Rejected because package-owned checks remain useful in tests, examples, generated SDKs, and explicit development compositions; only the default product config is out of scope.
## Consequences
- Ordinary `dsh` TUI and Web runs install no invariant listeners or trace state and cannot fail through `InvariantError`.
- Development and custom compositions retain explicit access to the invariant service and companions.
- The shipped config absence is verified from the built CLI's composed output for both surfaces.
- Always-on session integrity remains unchanged.
@@ -0,0 +1,30 @@
# Agent Note: 从交付的 dsh 配置中省略运行时不变式
Status: implemented
[English](2026-08-03-omit-invariants-from-shipped-config.md) | 中文
## 问题
`@deepseek-ai/dsh-invariants` 与各包(package)拥有的 `./invariant` 伴随插件是可选的开发诊断。交付的 TUI 挂载了该服务和四个有状态伴随插件,而交付的 Web 配置树省略了这些条目,导致两个产品 surface 的诊断成本和失败行为不同。即使始终启用的产品边界仍负责会话验证与不可变历史,关系断言失败也可能终止普通的 TUI 运行。
## 决策
`apps/cli/config/` 下交付的 `dsh` 配置树既不挂载 `@deepseek-ai/dsh-invariants`,也不挂载任何包拥有的 `./invariant` 伴随插件。因此,CLI 包不再直接依赖不变式服务。
不变式支持仍可供聚焦测试、示例组合包、生成的 SDK 组合,以及显式选择诊断的自定义部署使用。会话验证、快照、冻结和 provenance 始终启用,且不依赖可选服务,具体由[源端拥有的不可变性决策](../architecture/2026-06-11-dev-invariants-over-deep-readonly.md)规定。
构建后 CLI 的配置转储测试会检查两个交付的 surface,并拒绝服务条目或任何 `@deepseek-ai/dsh-*/invariant` 条目。
## 已考虑的替代方案
- **挂载服务并设置 `enabled: false`。** 不予采纳,因为交付的配置树和 CLI 依赖仍会携带不安装任何检查的诊断。
- **保留仅由 TUI 挂载的方案。** 不予采纳,因为两个交付的 surface 仍会保留不同的诊断和失败行为。
- **从仓库中移除不变式支持。** 不予采纳,因为包拥有的检查在测试、示例、生成的 SDK 及显式开发组合中仍然有用;只有默认产品配置不在其范围内。
## 后果
- 普通的 `dsh` TUI 与 Web 运行不安装不变式监听器或 trace 状态,也不会因 `InvariantError` 失败。
- 开发和自定义组合仍可显式使用不变式服务及伴随插件。
- 构建后 CLI 的组合输出会验证两个 surface 的交付配置中均不存在这些条目。
- 始终启用的会话完整性保持不变。
+15 -1
View File
@@ -180,6 +180,20 @@ export function parseReferences({ body, repository }) {
}
}
/**
* Retain only references that resolve to Issues rather than pull requests.
* @param {{all: number[], resolving: number[], related: number[]}} references Parsed references.
* @param {Map<number, unknown>} issues Resolved same-repository Issues.
* @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references.
*/
export function retainIssueReferences(references, issues) {
return {
all: references.all.filter((number) => issues.has(number)),
resolving: references.resolving.filter((number) => issues.has(number)),
related: references.related.filter((number) => issues.has(number)),
}
}
/**
* Validate one Issue with its Project status.
* @param {{title: string, body: string, assignees: string[], labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot.
@@ -472,7 +486,7 @@ async function pullRequestSnapshot(number) {
reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
reviewCount: reviews.length,
labels: pull.labels.map((label) => label.name),
references,
references: retainIssueReferences(references, issues),
issues,
}
}
+19
View File
@@ -4,6 +4,7 @@ import test from 'node:test'
import {
countVisibleUnits,
parseReferences,
retainIssueReferences,
requiresPullRequestPolicy,
validateBody,
validateIssue,
@@ -117,6 +118,24 @@ test('separates resolving and informational references', () => {
)
})
test('does not treat pull request references as Issue associations', () => {
const references = {
all: [123, 1180, 1181],
resolving: [123, 1180],
related: [1181],
}
const issues = new Map([
[1180, {}],
[1181, {}],
])
assert.deepEqual(retainIssueReferences(references, issues), {
all: [1180, 1181],
resolving: [1180],
related: [1181],
})
})
test('allows informational references without cross-object constraints', () => {
const errors = validatePullRequest({
isDraft: false,
+58
View File
@@ -0,0 +1,58 @@
name: Issue lifecycle
on:
issues:
types:
- opened
- edited
- assigned
- unassigned
- labeled
- unlabeled
- closed
- reopened
- field_added
- field_removed
pull_request:
types:
- opened
- edited
- synchronize
- reopened
- labeled
- unlabeled
- ready_for_review
- review_requested
pull_request_review:
types:
- submitted
permissions:
contents: read
concurrency:
group: issue-lifecycle-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: false
jobs:
lifecycle:
name: Issue lifecycle
runs-on: ubuntu-latest
steps:
- name: Check out trusted policy
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Create project token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }}
private-key: ${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }}
owner: deepseek-harness
repositories: deepseek-harness
- name: Handle repository event
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: node .github/issue-management/policy.mjs lifecycle
+27
View File
@@ -0,0 +1,27 @@
name: Issue policy
on:
pull_request:
types: [opened, edited, synchronize, reopened, labeled, unlabeled, ready_for_review, review_requested]
pull_request_review:
types: [submitted]
permissions:
contents: read
issues: read
pull-requests: read
jobs:
policy:
name: Issue policy
runs-on: ubuntu-latest
steps:
- name: Check out trusted policy
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Validate pull request
env:
GITHUB_TOKEN: ${{ github.token }}
run: node .github/issue-management/policy.mjs pr
+1
View File
@@ -47,6 +47,7 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
| [`clsx`](https://github.com/lukeed/clsx) | MIT |
+22 -13
View File
@@ -58,25 +58,34 @@
# ── TUI-only rows ───────────────────────────────────────────────────────────
- insert:
# Relational runtime checks over the authoritative event streams; each
# companion registers the assertions its own package owns.
- id: invariants
name: '@deepseek-ai/dsh-invariants'
- id: session-invariant
name: '@deepseek-ai/dsh-session/invariant'
- id: agent-invariant
name: '@deepseek-ai/dsh-agent/invariant'
- id: scope-invariant
name: '@deepseek-ai/dsh-scope/invariant'
- id: agent-loop-invariant
name: '@deepseek-ai/dsh-agent-loop/invariant'
# The derived query index behind `/resume`. The launcher provides a unique
# process-local path because this SQLite backend has one writer owner; the
# project-local fallback applies when no launcher sets the typed slot.
- id: session-reference
name: '@deepseek-ai/dsh-session-reference'
# The projection registry plus its durable checkpoint cache (over the same
# storage root the web surface uses): `/resume` reads titles from the
# zero-I/O checkpoint row or a tail-only cold read instead of scanning
# whole logs, and checkpoints written by either surface serve both.
- id: session-projection
name: '@deepseek-ai/dsh-session-projection'
- id: storage
name: '@deepseek-ai/dsh-storage'
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: !!js dshHomePath('storages')
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
config:
backend: json
- id: session-projection-cache
name: '@deepseek-ai/dsh-session-projection-cache'
config:
writeEveryEvents: 200
writeIntervalMs: 5000
# Terminal-multiplexer context, mounted only where a terminal exists.
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
-1
View File
@@ -65,7 +65,6 @@
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
+4
View File
@@ -94,6 +94,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(stdout).toContain('model: deepseek-v4-pro')
expect(stdout).toContain('cwd: !!js process.cwd()')
expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'")
expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-invariants['"]/)
expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-[^'"]+\/invariant['"]/)
expect(stdout).toContain([
'- id: tool-web',
" name: '@deepseek-ai/dsh-tool-web'",
@@ -140,6 +142,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(code).toBe(0)
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'")
expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-invariants['"]/)
expect(stdout).not.toMatch(/name: ['"]@deepseek-ai\/dsh-[^'"]+\/invariant['"]/)
}, 30_000)
})
})
+14 -2
View File
@@ -117,8 +117,19 @@ class ScriptedTuiAdapter extends LlmAdapter {
const blocks = lastMessage?.content ?? []
if (blocks.some(block => block.type === 'tool-result')) {
const answered = blocks.some(block => block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID)
for (const chunk of textChunks(answered ? BASH_FAILURE_TEXT : FINAL_TEXT)) yield chunk
const answeredBash = blocks.some(block =>
block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID)
if (answeredBash) {
for (const chunk of textChunks(BASH_FAILURE_TEXT)) yield chunk
return
}
const toolResultText = blocks.flatMap(block => block.type === 'tool-result'
? block.content.flatMap(content => content.type === 'text' ? [content.text] : [])
: []).join('\n')
if (toolResultText !== '{"answers":[{"id":"mode","selected":["Safe"],"custom":"Release notes"}]}') {
throw new Error(`the scripted TUI request received an unexpected question answer: ${toolResultText}`)
}
for (const chunk of textChunks(FINAL_TEXT)) yield chunk
return
}
if (lastText.includes(BASH_FAILURE_PROBE)) {
@@ -140,6 +151,7 @@ class ScriptedTuiAdapter extends LlmAdapter {
id: 'mode',
header: 'Execution mode',
question: 'How should the scripted run proceed?',
multi_select: true,
options: [
{ label: 'Safe', description: 'Use the guarded path.' },
{ label: 'Fast', description: 'Use the shorter path.' },
+7 -5
View File
@@ -52,10 +52,10 @@ const EXPECTED_TUI_TOOLS = [
]
/**
* `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg`
* through the mounted bash executor at load and registers neither tool when
* ripgrep is absent. That is a host dependency, not a composition decision, so the
* pair is asserted separately — present together or absent together.
* `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED
* ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair
* is always present on every host — asserted as fixed members, not a host
* dependency.
*/
const RIPGREP_TOOLS = ['glob', 'grep']
@@ -123,7 +123,9 @@ describe('shipped dsh composition (real Loader tree in a PTY)', () => {
expect(output).toContain(COMPOSITION_REPLY_TEXT)
expect(output).toContain(PERMISSION_SUMMARY)
expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS)
expect([[], RIPGREP_TOOLS]).toContainEqual(observed?.names.filter(name => RIPGREP_TOOLS.includes(name)))
// The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host.
expect(observed?.names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
expect(observed?.bashArguments).toHaveProperty('sandbox_permissions')
expect(observed?.bashArguments).toHaveProperty('justification')
expect(observed?.permissionEvents).toEqual([
+7 -2
View File
@@ -259,6 +259,7 @@ const SELECT_PRO_MODEL = [
{ waitFor: 'scripted TUI ready.', send: '/model\r' },
{ waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' },
] as const
const ANSWER_MULTI_WITH_CUSTOM = ' \tRelease notes\r'
describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
it.each([
@@ -450,7 +451,10 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
// The question text first appears in the streamed tool-call card. Wait
// for the dialog's input legend so Enter cannot arrive before it owns
// terminal input when pre-dispatch policy yields.
{ waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' },
{
waitFor: 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt',
send: ANSWER_MULTI_WITH_CUSTOM,
},
{ waitFor: 'Decision received. Scripted TUI run complete.', send: '' },
// Session title: the first user message drives the first-message-llm
// provider's tool-less title call; the scripted adapter answers it, the
@@ -476,6 +480,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
expect(output).not.toContain('\u001B[999CMODEL_CURSOR')
expect(output).not.toContain('\u009B31mMODEL_C1')
expect(output).toContain('Safe')
expect(output).toContain('Release notes')
expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007')
expect(output).toContain('Session status')
expect(output).toContain('Title')
@@ -861,7 +866,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
actions: [
...SELECT_PRO_MODEL,
{ waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },
{ waitFor: 'How should the scripted run proceed?', send: '\r' },
{ waitFor: 'How should the scripted run proceed?', send: ANSWER_MULTI_WITH_CUSTOM },
{ waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' },
],
inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) },
+14 -5
View File
@@ -6,10 +6,10 @@
// layers, per-plugin CSS injection, and a rendered journey reaching chat
// content from the keyless FixtureApiClient transport.
//
// Behavior assertions do NOT belong here: component and wiring behavior is
// pinned by the per-package suites (SlotTestRuntime benches over src), which
// this smoke's plugin set cannot influence — bundling, module-table
// resolution, and boot layering are the only failure modes left to it.
// Component behavior remains owned by per-package suites (SlotTestRuntime
// benches over src). This smoke additionally pins the resident approval
// fixture's cross-plugin projection because only the built connection/runtime/
// workspace graph can prove that transport-to-row path end to end.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
@@ -105,8 +105,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
await within(tree).findByText('4 sessions')
// The resident approval fixture proves the assembled workspace plugin
// distinguishes a blocked running session from an ordinarily busy one.
const waitingTitle = await within(tree).findByText('Fixture 历史会话')
const waitingRow = waitingTitle.closest<HTMLElement>('[role="treeitem"]')
if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row')
expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
within(waitingRow).getByText('Waiting for approval')
// Opening a session reaches chat content through the fixture transport.
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
fireEvent.click(waitingTitle)
await waitFor(() => {
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 })
+29 -8
View File
@@ -23,15 +23,16 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
// Second golden: the answered transcript — the question resolved into its
// tool round trip and the final reply, the state the waiting golden cannot see.
const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
// Final golden: the answered transcript — the question resolved into its tool
// round trip and the final reply, the state the composer goldens cannot see.
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
const MODE = webSnapshotMode()
// The options carry long descriptions on purpose: the squeeze assertion below
// needs option copy that WRAPS, which is the only shape that reproduces a
// collapsed row painting its copy outside its own box.
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.'
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
describe('web e2e: resident question composer round trip', () => {
let scaffold: WebScaffold
@@ -124,9 +125,17 @@ describe('web e2e: resident question composer round trip', () => {
await page.setViewportSize(original)
}
await composer.getByRole('radio', { name: 'Blue' }).click()
// Submit: Enter on the focused option (the composer's documented submit).
await composer.getByRole('radio', { name: 'Blue' }).press('Enter')
const blue = composer.getByRole('checkbox', { name: 'Blue' })
await blue.click()
const custom = composer.getByRole('textbox')
await custom.fill('Include accessibility notes')
expect(await blue.getAttribute('aria-checked')).toBe('true')
expect(await custom.inputValue()).toBe('Include accessibility notes')
if (MODE !== 'record') {
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
}
await custom.press('Enter')
const sessionId = await settled
if (MODE === 'record') {
@@ -135,7 +144,14 @@ describe('web e2e: resident question composer round trip', () => {
}
// World state: the tool result carries the chosen answer, and DONE lands.
const results = sessionEvents.filter(e => e.type === 'tool/result')
expect(JSON.stringify(results.at(-1))).toContain('Blue')
const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
block.type === 'tool-result'
? block.content.filter(item => item.type === 'text').map(item => item.text)
: [],
)).at(-1)
expect(JSON.parse(answerText ?? '')).toEqual({
answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }],
})
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// Composer gone; regular input restored.
expect(await page.locator('[data-question-key]').count()).toBe(0)
@@ -149,6 +165,11 @@ describe('web e2e: resident question composer round trip', () => {
}, 200_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl',
'ui.expected.md',
'composed.expected.md',
'answered.expected.md',
])
})
})
+53
View File
@@ -0,0 +1,53 @@
// Trusted non-loopback Web access must not wedge on the loopback-only
// settings API while the mandatory product notice owns the viewport.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general'
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('#root', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('advances process-locally and presents the notice again after reload', async () => {
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
await welcome.waitFor({ timeout: 15_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
await expect.poll(
() => page.locator('#root').evaluate(root => (root as HTMLElement).inert),
{ timeout: 15_000 },
).toBe(false)
const reloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, reloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})
+16 -3
View File
@@ -89,7 +89,7 @@ const REPLAY_PROVIDERS = [{
export interface WebScaffold {
/** The active snapshot mode this scaffold booted under. */
mode: WebSnapshotMode
/** Browser-facing origin (http://127.0.0.1:<bound port>). */
/** Browser-facing origin for the bound test server. */
baseUrl: string
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
ctx: Context
@@ -166,6 +166,12 @@ export interface LaunchOptions {
}
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
welcomeNoticePending?: boolean
/**
* Browse through a trusted non-loopback hostname that the browser resolves
* to loopback (for example `*.localhost`). The test server stays bound to
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
*/
remoteAuthority?: string
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -185,6 +191,7 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste
export async function launchWebScaffold(options: LaunchOptions = {}): Promise<WebScaffold> {
requireDist()
const mode = webSnapshotMode()
const browserHost = options.remoteAuthority ?? '127.0.0.1'
if (mode === 'record') {
// Both owning vitest configs (web unconditionally, snapshot in record
// mode) load the repo-root .env before this file runs.
@@ -261,7 +268,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
// names in the ambient environment).
{ id: 'telemetry-otel', disabled: true },
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
{
id: 'webserver',
config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX },
},
...options.remoteAuthority === undefined
? []
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
{ id: 'settings', config: { dshHome: harnessHome } },
{ id: 'credentials', config: { dshHome: harnessHome } },
// The shipped directory-picker row is the -auto chooser, which resolves
@@ -352,7 +365,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
return {
harnessHome,
mode,
baseUrl: `http://127.0.0.1:${port}`,
baseUrl: `http://${browserHost}:${port}`,
ctx,
workspaceCwd,
persistenceRoot,
+7 -5
View File
@@ -47,10 +47,10 @@ const EXPECTED_TOOLS = [
]
/**
* `glob` and `grep` come from `dsh-tool-fs-search`, which probes `command -v rg`
* through the mounted bash executor at load and registers neither tool when
* ripgrep is absent. That is a host dependency, not a composition decision, so the
* pair is asserted separately — present together or absent together.
* `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED
* ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair
* is always present on every host — asserted as fixed members, not a host
* dependency.
*/
const RIPGREP_TOOLS = ['glob', 'grep']
@@ -65,7 +65,9 @@ it('assembles the shipped Web catalog with the confined access default', async (
scaffold = await launchWebScaffold()
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
expect([[], RIPGREP_TOOLS]).toContainEqual(names.filter(name => RIPGREP_TOOLS.includes(name)))
// The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host.
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
// `workspace-write` is not "the workspace and nothing else": the shared roots
// helper always admits the temp directories too. Pinning it against an
// explicit mode keeps the claim independent of this surface's default, and
@@ -7,8 +7,9 @@
- text: Show the Markdown image policy. {{clock}}
- button "Copy":
- img
- button "Branch into a new conversation":
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn
- heading "Markdown images" [level=2]
- paragraph:
- img "Remote test image"
@@ -4,7 +4,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}"
- text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}"
- button "Copy":
- img
- button "Branch into a new conversation" [disabled]:
@@ -0,0 +1,17 @@
- region "Which color do you prefer?":
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- button "Dismiss all questions":
- img
- group:
- checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- textbox "Type your answer": Include accessibility notes
- button "Previous question" [disabled]:
- img
- text: 1 / 1
- button "Next question" [disabled]:
- img
- status
- button "Skip this question"
- button "Submit"
@@ -1,20 +1,20 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}}
{"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}}
{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}}
{"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}}
{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}}
{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}}
{"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}}
{"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"],\"custom\":\"Include accessibility notes\"}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
{"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -3,9 +3,9 @@
- heading "Which color do you prefer?" [level=2]
- button "Dismiss all questions":
- img
- radiogroup:
- radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- group:
- checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- textbox "Type your answer"
- button "Previous question" [disabled]:
- img
@@ -400,6 +400,17 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) })
expect(await nestedRow.locator(':scope > *').count()).toBe(1)
const clickArea = nestedRow.locator(':scope > *')
const [treeBox, clickAreaBox] = await Promise.all([
tree.boundingBox(),
clickArea.boundingBox(),
])
expect(treeBox).not.toBeNull()
expect(clickAreaBox).not.toBeNull()
expect([
Math.round(clickAreaBox!.x - treeBox!.x),
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
]).toEqual([5, 5])
await compareOrRefreshGolden(
BRANCHLESS_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
+1
View File
@@ -36,6 +36,7 @@
"tests/settings-chrome.e2e.ts",
"tests/models-settings.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
"tests/remote-welcome.e2e.ts",
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
"tests/hmr-live.e2e.ts",
+11 -3
View File
@@ -1736,7 +1736,7 @@ Source: [`packages/fs/tool-fs/src/index.ts:24`](../packages/fs/tool-fs/src/index
## `@deepseek-ai/dsh-tool-fs-search`
Requires: `tools` · `systemPrompt` · `bash`
Requires: `tools` · `systemPrompt` · `subprocess`
```ts config-catalog
/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */
@@ -1753,12 +1753,16 @@ export interface Config {
searchMetaMaxBytes?: number
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
rawOutputMaxBytes?: number
/** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */
graceMs?: number
/** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */
stderrMaxBytes?: number
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
timeoutMs?: number
}
```
Source: [`packages/fs/tool-fs-search/src/index.ts:71`](../packages/fs/tool-fs-search/src/index.ts)
Source: [`packages/fs/tool-fs-search/src/index.ts:72`](../packages/fs/tool-fs-search/src/index.ts)
## `@deepseek-ai/dsh-tool-goal`
@@ -2067,12 +2071,16 @@ export interface TuiConfig {
showReasoning?: boolean
/** Maximum tool-card body lines retained in its collapsed head/tail preview. */
maxToolOutputLines?: number
/** Maximum added and removed lines explored while deriving an exact line diff. */
maxDiffEditLength?: number
/** Maximum options visible at once in a user-question panel. */
maxQuestionOptions?: number
/** Maximum models visible at once in the model selector. */
maxModelOptions?: number
/** Maximum sessions visible at once in the resume selector. */
maxResumeOptions?: number
/** Maximum concurrent cold projection reads in one resume scan. */
resumeScanConcurrency?: number
/** User-question panel width in terminal columns, clamped to the terminal. */
questionDialogWidth?: number
/** User-question panel maximum height in terminal rows. */
@@ -2114,7 +2122,7 @@ export interface TuiThemeConfig {
}
```
Source: [`packages/ui/tui/src/config.ts:121`](../packages/ui/tui/src/config.ts)
Source: [`packages/ui/tui/src/config.ts:129`](../packages/ui/tui/src/config.ts)
## `@deepseek-ai/dsh-typert-loader`
+1 -1
View File
@@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
```
Source: [`packages/ui/tui/src/index.ts:244`](../../packages/ui/tui/src/index.ts)
Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts)
## `ctx.typert` — `TypertRegistry`
@@ -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/user-interaction.md
user-interaction.md: 2483382eee07d379b13d456149096c8afab70e4b
user-interaction.zh.md: 57f57277975597fff712705cc741ae8e78643642
user-interaction.md: 16478168c8fcfeafbd3fdbbf30e732de81a3a8dd
user-interaction.zh.md: 7c8baaf189d759ca609c1d51652120f973ae48b6
@@ -86,14 +86,14 @@ interface AskUserQuestionRequest {
## Answer
Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch.
Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch.
```ts type-equiv
/** Answer to one question. */
interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty for custom or unanswered choices. */
/** Selected option labels. May accompany custom text for a multi-select question. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string
@@ -86,14 +86,14 @@ interface AskUserQuestionRequest {
## 回答
提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。`custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。
提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。
```ts type-equiv
/** Answer to one question. */
interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty for custom or unanswered choices. */
/** Selected option labels. May accompany custom text for a multi-select question. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string
+5 -3
View File
@@ -712,12 +712,12 @@ flowchart TD
pkg_tool_fs --> pkg_system_prompt
pkg_tool_fs --> pkg_tools
pkg_tool_fs --> pkg_user_approval
pkg_tool_fs_search --> pkg_bash
pkg_tool_fs_search --> pkg_invariants
pkg_tool_fs_search --> pkg_llm
pkg_tool_fs_search --> pkg_retention
pkg_tool_fs_search --> pkg_session
pkg_tool_fs_search --> pkg_spill
pkg_tool_fs_search --> pkg_subprocess
pkg_tool_fs_search --> pkg_system_prompt
pkg_tool_fs_search --> pkg_tools
pkg_tool_str_replace_editor --> pkg_fs
@@ -939,6 +939,8 @@ flowchart TD
pkg_tui --> pkg_llm_retry
pkg_tui --> pkg_session
pkg_tui --> pkg_session_persistence
pkg_tui --> pkg_session_projection
pkg_tui --> pkg_session_projection_cache
pkg_tui --> pkg_session_query
pkg_tui --> pkg_session_reference
pkg_tui --> pkg_session_title
@@ -1201,7 +1203,7 @@ flowchart TD
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
@@ -1237,7 +1239,7 @@ flowchart TD
| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
@@ -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: ccedba6094ffec9de95fbd52d58add4a0e6e5105
0004-landlock-partial-notice-misclassified-child-failures.zh.md: 89d85d835d58753721927be85a5581b12a490fcf
0004-landlock-partial-notice-misclassified-child-failures.md: b896fc99a460b27e1b94b225543dded9fd31fbb1
0004-landlock-partial-notice-misclassified-child-failures.zh.md: 5245f1d5bd859712df5d13a72cb81a2ae10063ef
@@ -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`; filesystem search then 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, search preserves structured executor errors, and an assembled keyless scenario pins the affected product path.
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.
## Summary
@@ -14,7 +14,7 @@ The native launcher contract distinguishes two kinds of stderr lines. A partiall
The harness represented both with one case-insensitive `landlock-run: ` substring. Its consumer classified any nonzero exit carrying that substring as runner failure. The child's status was therefore attached to the launcher's informational line: `false`, ripgrep's no-match exit 1, invalid-pattern exit 2, and even a child-selected exit 125 could be blamed on the sandbox despite successful confinement and execution.
Filesystem search added a second attribution error. `runRipgrep()` caught every rejected bash run that was not aborted and replaced it with a generic cwd/shell-start `SEARCH_FAILED`, including the structured `SandboxUnavailableError` produced by the sandbox executor.
At the time of the incident, filesystem search added a second attribution error. Its bash-backed `runRipgrep()` caught every rejected bash run that was not aborted and replaced it with a generic cwd/shell-start `SEARCH_FAILED`, including the structured `SandboxUnavailableError` produced by the sandbox executor.
## Impact
@@ -28,13 +28,13 @@ 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, shared foreground/background classification, search error preservation, and assembled replay coverage closed the two attribution gaps.
- 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.
## Root cause
The public sandbox result type could express only a bag of substrings. It could not state that Landlock failure requires exit 125, that evidence must occur within one fatal line, or that one exact line under the same prefix is informational. The boolean consumer consequently joined unrelated facts from different processes and selected the first stderr line for detail even when a later line was the fatal evidence.
The test matrix mirrored that representation. Fake providers emitted either no runner line or an unambiguously fatal prefix; they never emitted a benign runner line before a child-controlled nonzero exit. Real Landlock coverage depended on the host ABI, so full-ABI hosts could not exercise the notice. Filesystem-search tests modeled raw spawn errors but not a structured error thrown by the real sandboxed bash composition.
The test matrix mirrored that representation. Fake providers emitted either no runner line or an unambiguously fatal prefix; they never emitted a benign runner line before a child-controlled nonzero exit. Real Landlock coverage depended on the host ABI, so full-ABI hosts could not exercise the notice. In the incident-era search implementation, filesystem-search tests modeled raw spawn errors but not a structured error thrown by the real sandboxed bash composition.
Stderr remains an in-band attribution channel. A confined child can deliberately reproduce a runner's gated fatal line and exit status, causing an availability/diagnostic false attribution. The tighter conjunction prevents the accidental collision in this incident but does not authenticate the writer; an out-of-band status protocol remains separate hardening, not a sandbox-bypass fix.
@@ -43,8 +43,8 @@ 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, keeps bwrap/Seatbelt/custom behavior, and separates argv0-scoped outer-shell failures using exit 126/127.
- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) uses one evidence-returning classifier for foreground and background execution. Fatal evidence outranks denial, and foreground errors report the matched fatal line without changing captured stderr.
- [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) retains abort as `SEARCH_ABORTED`, propagates existing `HarnessError` instances unchanged, and applies `SEARCH_FAILED` only to untyped start failures. Ripgrep still owns exit 0/1/other semantics inside the adapter.
- Deterministic tests use a POSIX fake partial-Landlock launcher to cover `true`, `false`, child exit 125, permission denial, real fatal diagnostics, and foreground/background parity. A real search composition covers empty grep/glob, invalid regex, and `SANDBOX_UNAVAILABLE` propagation.
- 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 `true`, `false`, child exit 125, permission denial, real fatal diagnostics, 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.
## Lessons
@@ -6,7 +6,7 @@ Status: resolved
## 摘要
在 Landlock ABI 较旧的内核上,launcher 会在执行每个子进程前打印一条无害的部分强制执行通知。harness 把共享的 `landlock-run:` 前缀与任意非零子进程退出组合起来,判定为 launcher 失败,因此 ripgrep 在没有匹配项时以 1 退出等普通结果会呈现为 `SANDBOX_UNAVAILABLE`;文件系统搜索随后又`SEARCH_FAILED` 遮蔽这个结构化错误。过于宽泛的签名规则,以及缺少较旧 ABI 下部分强制执行的组合测试覆盖,让该缺陷得以流入。runner 分类现在会先精确排除信息性行,再要求由退出状态门控的致命证据;搜索会保留结构化执行器错误,并由一个组装后的无密钥场景固定受影响的产品路径
在 Landlock ABI 较旧的内核上,launcher 会在执行每个子进程前打印一条无害的部分强制执行通知。harness 把共享的 `landlock-run:` 前缀与任意非零子进程退出组合起来,判定为 launcher 失败,因此 ripgrep 在没有匹配项时以 1 退出等普通结果会呈现为 `SANDBOX_UNAVAILABLE`当时仍由 bash 支撑的文件系统搜索还会`SEARCH_FAILED` 遮蔽这个结构化错误。过于宽泛的签名规则,以及缺少较旧 ABI 下部分强制执行的组合测试覆盖,让该缺陷得以流入。runner 分类现在会先精确排除信息性行,再要求由退出状态门控的致命证据,并由一个组装后的无密钥场景固定仍然存在的 bash 路径。文件系统搜索后来已改为通过 subprocess seam 运行打包的 ripgrep,不再经过沙箱化 bash
## 概述
@@ -14,7 +14,7 @@ Status: resolved
harness 用一个不区分大小写的 `landlock-run: ` 子串表示这两种情况。消费方只要发现非零退出同时携带该子串,就会归类为 runner 失败。因此,子进程的退出状态被错误地关联到 launcher 的信息性行:`false`、ripgrep 无匹配时的退出码 1、无效 pattern 的退出码 2,乃至由子进程自行选择的退出码 125,都可能在约束与执行均成功的情况下被错误归因为沙箱故障。
文件系统搜索又造成第二处归因错误。`runRipgrep()` 会捕获 bash 执行器除中止外抛出的所有错误,并将其替换为关于 cwd 或 shell 启动的通用 `SEARCH_FAILED`,其中也包括沙箱执行器产生的结构化 `SandboxUnavailableError`
事故发生时,文件系统搜索又造成第二处归因错误。当时由 bash 支撑的 `runRipgrep()` 会捕获 bash 执行器除中止外抛出的所有错误,并将其替换为关于 cwd 或 shell 启动的通用 `SEARCH_FAILED`,其中也包括沙箱执行器产生的结构化 `SandboxUnavailableError`
## 影响
@@ -28,13 +28,13 @@ harness 用一个不区分大小写的 `landlock-run: ` 子串表示这两种情
- 沙箱提供方把该契约简化为 `runnerFailureSignatures: ['landlock-run: ']`;bash 消费方将此前缀与任意非零退出组合,并报告 stderr 的第一行。
- 单元测试覆盖了无诊断的成功、拒绝诊断和致命 runner 前缀。真实 runner 测试在没有可用内核时会自行跳过,也没有强制构造「部分强制执行通知后跟非零子进程退出」的情况。
- 一个最小 POSIX 包装脚本会打印该通知并 `exec` 其负载;它通过 `false` 与 ripgrep 无匹配场景复现了故障。
- 结构化规则、前台与后台共享的分类逻辑、搜索错误保留和组装后的回放覆盖共同弥补了这两处归因缺口
- 结构化规则、前台与后台共享的分类逻辑和组装后的回放覆盖共同弥补了仍然存在的沙箱归因缺口。本修复与当前 `master` 对齐前,文件系统搜索已改为通过 `ctx.subprocess` 运行打包的 ripgrep;合并时删除了过时的 bash 适配器补丁与测试,而没有把旧架构重新引入
## 根因
公开的沙箱结果类型只能表达一组子字符串。它无法表示 Landlock 失败必须使用退出码 125、证据必须出现在一行致命诊断内,或同一前缀下有一行精确文本属于信息性通知。消费方的布尔判定逻辑因此把来自不同进程且互不相关的事实组合在一起;即便致命证据位于后续行,它仍选用 stderr 的第一行作为详细信息。
测试矩阵与这种表示方式一致。模拟提供方要么不输出 runner 行,要么输出含义明确的致命前缀,从不在由子进程控制的非零退出前输出无害 runner 行。真实 Landlock 覆盖依赖主机 ABI,因此使用完整 ABI 的主机无法覆盖该通知。文件系统搜索测试模拟了原始 spawn 错误,却没有覆盖真实沙箱化 bash 组合抛出的结构化错误。
测试矩阵与这种表示方式一致。模拟提供方要么不输出 runner 行,要么输出含义明确的致命前缀,从不在由子进程控制的非零退出前输出无害 runner 行。真实 Landlock 覆盖依赖主机 ABI,因此使用完整 ABI 的主机无法覆盖该通知。在事故发生时的搜索实现中,文件系统搜索测试模拟了原始 spawn 错误,却没有覆盖真实沙箱化 bash 组合抛出的结构化错误。
stderr 仍是带内归因通道。受限子进程可以故意复现 runner 的门控致命诊断行与退出状态,造成可用性或诊断误归因。更严格的多项证据合取可以避免本次事故中的意外冲突,但无法验证写入者身份;带外状态协议仍属于独立的加固工作,而非沙箱绕过修复。
@@ -43,8 +43,8 @@ stderr 仍是带内归因通道。受限子进程可以故意复现 runner 的
- [`RunnerFailureRule`](../core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) 携带可选的允许退出码、不区分大小写的逐行致命签名,以及按不区分大小写的整行精确匹配排除的信息性行。
- [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) 把 Landlock 映射为退出码 125 加一行非通知的 `landlock-run:` 诊断,保留 bwrapSeatbelt/自定义 runner 的行为,并以退出码 126/127 单独处理按 argv0 限定的外层 shell 失败。
- [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) 为前台与后台执行使用同一个返回证据的分类器。致命证据优先于拒绝,前台错误会报告匹配到的致命行,同时保持捕获的 stderr 不变。
- [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) 把中止保留为 `SEARCH_ABORTED`,原样传播既有 `HarnessError` 实例,并且仅将无类型的启动失败归为 `SEARCH_FAILED`。适配器内部仍按 ripgrep 自身的退出码 0/1/其他语义处理
- 确定性测试使用一个模拟 Landlock 部分强制执行行为的 POSIX launcher,覆盖 `true``false`、子进程退出码 125、权限拒绝、真实致命诊断,以及前台/后台一致性。真实搜索组合覆盖空 grep/glob、无效正则表达式和 `SANDBOX_UNAVAILABLE` 传播。
- 当前 [`dsh-tool-fs-search`](../../packages/fs/tool-fs-search/) 通过 `ctx.subprocess` 运行打包的 ripgrep,不再消费沙箱化 bash seam;与新基线对齐时保持该架构不变
- 确定性测试使用一个模拟 Landlock 部分强制执行行为的 POSIX launcher,覆盖 `true``false`、子进程退出码 125、权限拒绝、真实致命诊断,以及前台/后台一致性。
- `examples/acp-agent` 的无密钥快照会通过仅用于测试的部分 Landlock 提供方直接运行 bash `false`,从而独立于文件系统搜索的实现选择固定产品层回归。
## 教训
+2 -2
View File
@@ -23,7 +23,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after successful file operations`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
| `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `user/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
@@ -526,7 +526,7 @@ Search file contents with a ripgrep regular expression. Returns matching lines w
Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-search/src/index.ts)
glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.
glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments.
## `@deepseek-ai/dsh-tool-pty`
+44 -8
View File
@@ -1,6 +1,6 @@
import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
import { mkdir, writeFile } from 'node:fs/promises'
import { mkdir, utimes, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { homedir } from 'node:os'
import { expect, it } from 'vitest'
@@ -48,7 +48,6 @@ const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url))
const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url))
const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url))
const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url))
const FS_SEARCH_BIN = fileURLToPath(new URL('./fixtures/fs-search-bin', import.meta.url))
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
@@ -61,6 +60,33 @@ async function prepareDelimiterPathWorkspace(cwd: string): Promise<void> {
])
}
/**
* Seed the over-cap glob fixture: eight files under `tree/` with fixed mtimes,
* so the packaged ripgrep's `--sort=modified` order is deterministic — three
* files under `archive/`, one each under `docs/`, `src/`, and `test/`, plus
* two flat files (six top-level entries). Scoping the search to `tree/` keeps
* the harness's own session artifacts out of the listing.
*/
async function prepareFsSearchWorkspace(cwd: string): Promise<void> {
const tree = join(cwd, 'tree')
const files: Array<[relative: string, mtime: Date]> = [
[join('archive', 'a.ts'), new Date(2000, 0, 1, 0, 0, 0, 1)],
[join('archive', 'b.ts'), new Date(2000, 0, 1, 0, 0, 0, 2)],
[join('archive', 'c.ts'), new Date(2000, 0, 1, 0, 0, 0, 3)],
[join('docs', 'guide.md'), new Date(2000, 0, 1, 0, 0, 0, 4)],
[join('src', 'index.ts'), new Date(2000, 0, 1, 0, 0, 0, 5)],
[join('test', 'spec.ts'), new Date(2000, 0, 1, 0, 0, 0, 6)],
['top.txt', new Date(2000, 0, 1, 0, 0, 0, 7)],
['notes.md', new Date(2000, 0, 1, 0, 0, 0, 8)],
]
for (const [relative, mtime] of files) {
const target = join(tree, relative)
await mkdir(dirname(target), { recursive: true })
await writeFile(target, 'fixture\n')
await utimes(target, mtime, mtime)
}
}
// FIXME: Migrate backend-oriented scenarios to the headless stream-json suite;
// this ACP suite should eventually retain only automation-protocol contracts.
@@ -166,18 +192,28 @@ const SCENARIOS: Scenario[] = [
hasModelTurn: true,
recorded: true,
},
// The real Loader/app/bash path executes a deterministic rg stand-in at the
// external-process seam, pinning over-cap glob sampling without depending on
// a host-installed ripgrep binary.
// The real Loader/app/subprocess path executes the PACKAGED ripgrep binary
// against a prepared workspace whose fixed mtimes pin the
// `--sort=modified` order, pinning over-cap glob sampling without depending
// on a host-installed ripgrep binary or a PATH stand-in. POSIX-only because
// the displayed paths carry `/` separators the session-log comparison
// cannot normalize. Recorded (not authored): the assistant turn is a real
// model transcript; re-record with `test:snapshot:record -t fs-glob-sampling`
// and then `migrate:packed-session-fixtures`, which canonicalizes the live
// log's eager-drain-packed rows into the maximal-run layout replay produces.
// The recorded fixture's `request/header` config and `request/context` are
// normalized to the replay-produced minimal shape (the live adapter logs
// model capabilities like maxTokens/reasoningEffort that llm-replay has no
// data for), and its tool-result paths are canonicalized to `/` separators.
{
name: 'fs-glob-sampling',
hasModelTurn: true,
recorded: false,
recorded: true,
posixOnly: true,
pinsHeader: true,
headerClass: 'fs-search',
configPath: FS_SEARCH_CONFIG,
env: { PATH: `${FS_SEARCH_BIN}:${process.env.PATH ?? ''}` },
posixOnly: true,
prepareWorkspace: prepareFsSearchWorkspace,
},
{ name: 'fs-read', hasModelTurn: true, recorded: true },
{ name: 'fs-write', hasModelTurn: true, recorded: true },
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
printf '%s\n' \
'archive/a.ts' \
'archive/b.ts' \
'archive/c.ts' \
'old\one' \
'old\two' \
'src/index.ts' \
'docs/guide.md' \
'test/spec.ts'
@@ -3,7 +3,7 @@
name: '@deepseek-ai/dsh-llm-replay'
config:
providers:
- id: deepseek
- id: deepseek-official
name: DeepSeek
models:
- id: deepseek-v4-pro
@@ -17,7 +17,7 @@
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek
provider: deepseek-official
model: deepseek-v4-pro
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: none
@@ -16,9 +16,10 @@
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek
provider: deepseek-official
model: deepseek-v4-pro
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext: false
skills:
enabled: false
@@ -2,6 +2,6 @@
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Call glob exactly once with pattern * and no path. Then reply with exactly GLOB_SAMPLED and nothing else." }
{ "op": "prompt", "text": "Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else." }
]
}
@@ -1,25 +1,31 @@
{"type":"session","version":0,"id":"f5a99d52-3eaa-4ce7-858d-61d4fd77df2a","createdAt":1785218400000,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":1785218400001,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1785218400002,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and no path. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"6790985f-1de2-42f8-a7f1-24e46d6439c7"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785218400003,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785218400004,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785218400005,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":5,"time":1785483397569,"data":{"provider":"deepseek","model":"deepseek-v4-pro"}}
{"type":"assistant/chunk","seq":6,"time":1785218400007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":7,"time":1785218400008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"glob-sampling-call","name":"glob","argumentsDelta":"{\"pattern\":\"*\"}"}}}
{"type":"assistant/chunk","seq":8,"time":1785218400009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}}}}
{"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}}
{"type":"assistant/chunk","seq":10,"time":1785483397579,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":11,"time":1785483397579,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"a127cfe5-39fb-462c-8e5a-a8c79bd0e52b"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"}
{"type":"tool/call","seq":12,"time":1785483397579,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}}
{"type":"tool/result","seq":13,"time":1785483398062,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"2beecb2e-627d-43dc-a936-03e1dc874093"},"meta":{"shape":"paths","paths":["archive/a.ts","old\\one","old\\two","src/index.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[12],"surfaceOp":"append"}
{"type":"step/end","seq":14,"time":1785483398062,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":15,"time":1785483398072,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":16,"time":1785218400017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":17,"time":1785218400018,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GLOB_SAMPLED"}}}
{"type":"assistant/chunk","seq":18,"time":1785218400019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GLOB_SAMPLED"}}}}
{"type":"assistant/chunk","seq":19,"time":1785218400020,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":1,"outputTokens":1}}}}
{"type":"assistant/chunk","seq":20,"time":1785483398078,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":21,"time":1785483398078,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"ce2334a4-be71-490b-a502-29186a9ced5c"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}
{"type":"step/end","seq":22,"time":1785483398078,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":23,"time":1785483398079,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"session","version":0,"id":"4428b809-66d5-4ea2-9a03-89de742fcda1","createdAt":1785591986068,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":1785591986072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1785591986073,"data":{"content":[{"type":"text","text":"Call glob exactly once with pattern * and path tree. Then reply with exactly GLOB_SAMPLED and nothing else."}],"source":{"kind":"user"},"role":"user","id":"3d05fb76-4185-460b-9c6a-8c1b2495bc9f"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785591986074,"data":{"title":"Call glob exactly once with","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785591986092,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785591986093,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":5,"time":1785591986094,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}}
{"type":"assistant/chunk","seq":6,"time":1785591987500,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":7,"time0":1785591987500,"data":{"turn":1,"step":1,"index":0,"dt":[29,58,1,0,0,0,51,0,0,46,0,191,1,0,0,0,0,0,0,0,1,0,0,0,0,0,99],"texts":["The"," user"," wants"," me"," to"," call"," glob"," exactly"," once"," with"," pattern"," *"," and"," path"," tree",","," then"," reply"," with"," exactly"," \"","G","LOB","_S","AM","PL","ED","\"."]}}
{"type":"assistant/chunk","seq":35,"time":1785591988034,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":36,"time0":1785591988035,"data":{"turn":1,"step":1,"index":1,"dt":[55,0,0,1,45,0,0,57,14,0,0,0,0,77,0,0,54],"id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","args":["","{","\"","pattern","\"",": ","\"","*","\"",", ","\"","path","\"",": ","\"","tree","\"","}"]}}
{"type":"assistant/chunk","seq":54,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}}}
{"type":"assistant/chunk","seq":55,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}}}}
{"type":"assistant/chunk","seq":56,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}}}}
{"type":"assistant/chunk","seq":57,"time":1785591988427,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":58,"time":1785591988430,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."},{"type":"tool-call","id":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b74cbab2-c017-4e44-8c09-a7745d8b274a"},"usage":{"inputTokens":1286,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57],"surfaceOp":"append"}
{"type":"tool/call","seq":59,"time":1785591988431,"data":{"turn":1,"step":1,"callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","name":"glob","arguments":"{\"pattern\": \"*\", \"path\": \"tree\"}"}}
{"type":"tool/result","seq":60,"time":1785591988476,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_1cLZjkCW0vxVw0e3xVfh3430"},"content":[{"type":"tool-result","toolCallId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","content":[{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"10284f88-4890-49ed-9a17-56edbd6bfaa7"},"meta":{"shape":"paths","paths":["tree/archive/a.ts","tree/docs/guide.md","tree/src/index.ts","tree/test/spec.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[59],"surfaceOp":"append"}
{"type":"step/end","seq":61,"time":1785591988476,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":62,"time":1785591988482,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":63,"time":1785591989939,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":64,"time0":1785591989939,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,49,36,103,1,0,0,326,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,14],"texts":["The"," glob"," result"," shows"," it"," was"," sampled"," -"," ","4"," of"," ","8"," paths"," across"," ","4"," of"," ","6"," top","-level"," entries","."," I"," need"," to"," reply"," with"," exactly"," \"","G","LOB","_S","AM","PL","ED","\""," as"," instructed","."]}}
{"type":"assistant/chunk","seq":105,"time":1785591990470,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":106,"time0":1785591990470,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,48,0],"texts":["G","LOB","_S","AM","PL","ED"]}}
{"type":"assistant/chunk","seq":112,"time":1785591990526,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."}}}}
{"type":"assistant/chunk","seq":113,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"GLOB_SAMPLED"}}}}
{"type":"assistant/chunk","seq":114,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}}}}
{"type":"assistant/chunk","seq":115,"time":1785591990527,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":116,"time":1785591990527,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."},{"type":"text","text":"GLOB_SAMPLED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"dd3a9c28-43b2-4fdc-8089-1547309a71c0"},"usage":{"inputTokens":188,"outputTokens":48,"cacheReadTokens":1280,"reasoningTokens":41}},"sourceEventSeqs":[63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115],"surfaceOp":"append"}
{"type":"step/end","seq":117,"time":1785591990527,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":118,"time":1785591990528,"data":{"turn":1,"reason":{"kind":"completed"}}}
-3
View File
@@ -568,9 +568,6 @@
"project": [
"src/**/*.ts",
"tests/**/*.ts"
],
"ignoreBinaries": [
"rg"
]
},
"packages/mcp/mcp-client": {
+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 packages/client/connection/README.md
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45
README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9
README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## /api browser-trust fence
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
## /api 浏览器信任栅栏
@@ -14,6 +14,7 @@
*/
import type { IncomingHttpHeaders } from 'node:http'
import { isLoopbackHostname } from './loopback-hostname.ts'
/** The request facts the fence reads (structural subset of IncomingMessage). */
interface ApiTrustRequest {
@@ -25,14 +26,6 @@ function header(headers: IncomingHttpHeaders, name: string): string | undefined
return typeof value === 'string' ? value : undefined
}
function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
function parseAuthority(authority: string): URL | undefined {
try {
@@ -8,6 +8,7 @@ import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
import { isLoopbackHostname } from '../loopback-hostname.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
@@ -48,6 +49,8 @@ export const inject: string[] = []
export interface ConnectionHandle {
/** Shared api client (fixture or real, decided at boot from the page URL). */
readonly api: IApiClient
/** Whether the current page authority is loopback; non-browser contexts default to true. */
readonly isLoopback: boolean
/**
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
* One consumer owns the streams (the runtime object layer); a second call
@@ -64,11 +67,13 @@ export interface ConnectionHandle {
* @param ctx - client cordis context.
*/
export function apply(ctx: Context): void {
const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture')
const pageLocation = typeof location === 'undefined' ? undefined : location
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
let started = false
const handle: ConnectionHandle = {
api,
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true
@@ -0,0 +1,18 @@
/**
* Browser-safe, zero-dependency loopback classification shared by the `/api`
* Host fence and the package's `ctx.connection` state. The predicate stays
* package-internal; client plugins consume the derived state through Cordis.
*/
/**
* Whether a normalized URL hostname names the local loopback authority.
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
*/
export function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
@@ -8,7 +8,7 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts'
import { FixtureApiClient } from '../src/client/fixture.ts'
import { WebApiClient } from '../src/client/web-api-client.ts'
type Win = { location?: { search: string } }
type Win = { location?: { hostname: string; search: string } }
afterEach(() => {
delete (globalThis as Win).location
@@ -24,20 +24,28 @@ async function mount(): Promise<ConnectionHandle> {
describe('connection client apply', () => {
it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
;(globalThis as Win).location = { search: '' }
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
const handle = await mount()
expect(handle.api).toBeInstanceOf(WebApiClient)
expect(handle.isLoopback).toBe(true)
})
it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
;(globalThis as Win).location = { search: '?fixture' }
;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' }
expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
delete (globalThis as Win).location
expect((await mount()).api).toBeInstanceOf(WebApiClient)
const handle = await mount()
expect(handle.api).toBeInstanceOf(WebApiClient)
expect(handle.isLoopback).toBe(true)
})
it('reports non-loopback page authority through the connection handle', async () => {
;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' }
expect((await mount()).isLoopback).toBe(false)
})
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
;(globalThis as Win).location = { search: '?fixture' }
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
// config omitted: the `config ?? {}` default arm is part of the surface.
const loop = handle.start({})
@@ -46,7 +54,7 @@ describe('connection client apply', () => {
})
it('WebApiClient carries requests over globalThis.fetch', async () => {
;(globalThis as Win).location = { search: '' }
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
const handle = await mount()
const original = globalThis.fetch
const seen: string[] = []
@@ -0,0 +1,18 @@
/** Shared loopback-hostname semantics for the Host fence and browser UI. */
import { describe, expect, it } from 'vitest'
import { isLoopbackHostname } from '../src/loopback-hostname.ts'
describe('isLoopbackHostname', () => {
it('accepts localhost, IPv6 loopback, and the whole IPv4 127/8 block', () => {
for (const hostname of ['localhost', '[::1]', '127.0.0.1', '127.8.9.10', '127.255.255.255']) {
expect(isLoopbackHostname(hostname)).toBe(true)
}
})
it('refuses malformed and non-loopback hostnames', () => {
for (const hostname of ['remote.localhost', '::1', '128.0.0.1', '127.0.0', '127.0.0.256', '127.0.0.-1']) {
expect(isLoopbackHostname(hostname)).toBe(false)
}
})
})
@@ -26,6 +26,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
isLoopback: true,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }
@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
isLoopback: true,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }
@@ -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 packages/client/ui-conversation/README.md
README.md: e1dbe7d4d5992b6b5b029fddfc9d9857ccae7443
README.zh.md: fd82ad65f8e903a6f7106e8b8ff8eccbf1435957
README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18
README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138
+1 -1
View File
@@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
+1 -1
View File
@@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
工具行同样是 slot:独立工具环(`ToolViewRegistry``ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
@@ -1,13 +1,12 @@
// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy
// control over one or more per-file hunks, each a bold path header followed by
// the removed block (`-`, error color) and the added block (`+`, success
// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors
// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads
// the same across front ends: the removed side is the old text in full, the
// added side the new text in full, both split on the same terminator rule, and
// the footer counts distinct paths on both ends. Output never soft-wraps — an
// aligned source line keeps its indentation and scrolls horizontally instead of
// folding. Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock.
// color), with a dim `└ +A -R · N file(s)` footer. Unlike the TUI's exact
// changed-row comparison, this block renders the old and new sides in full.
// Both front ends share the line-terminator rule and distinct-path file count.
// Output never soft-wraps — an aligned source line keeps its indentation and
// scrolls horizontally instead of folding. Colors resolve through --dsw-*
// tokens; geometry mirrors CodeBlock.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
+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 packages/client/ui-question/README.md
README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345
README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3
README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c
README.zh.md: 3c2b12b30dd2858c7b8f99193829c3274f3f8228
+1 -1
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.
+1 -1
View File
@@ -4,7 +4,7 @@
Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected``custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode``exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it``ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
@@ -98,12 +98,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
const choose = (label: string): void => {
updateDraft((current) => {
const selected = question.multiSelect === true
? current.selected.includes(label)
if (question.multiSelect === true) {
const selected = current.selected.includes(label)
? current.selected.filter(item => item !== label)
: [...current.selected, label]
: [label]
return { selected, custom: '', skipped: false }
return { ...current, selected, skipped: false }
}
return { selected: [label], custom: '', skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => current + 1)
@@ -129,7 +130,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
const custom = value.custom.trim()
return {
id: item.id,
selected: custom === '' ? value.selected : [],
selected: custom === '' || item.multiSelect === true ? value.selected : [],
...(custom === '' ? {} : { custom }),
}
}),
@@ -155,14 +156,17 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
submitDrafts(drafts)
}
// Shared by the inline custom input and the optionless textarea: typing a
// custom draft clears any selection, and Enter continues the flow
// (Shift+Enter stays a newline in the textarea; on the single-line input it
// is inert either way).
// Shared by the inline custom input and the optionless textarea: a
// multi-select draft retains checked labels, while a single-select custom
// answer replaces its selection. Enter continues the flow (Shift+Enter
// stays a newline in the textarea; on the single-line input it is inert).
const draftCustom = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, skipped: false,
...current,
selected: question.multiSelect === true ? current.selected : [],
custom: value,
skipped: false,
}))
}
@@ -104,13 +104,19 @@ describe('QuestionComposer', () => {
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
const multiCustom = screen.getByPlaceholderText('输入你的答案')
fireEvent.change(multiCustom, { target: { value: '沟通能力' } })
fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true')
expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true')
expect((multiCustom as HTMLInputElement).value).toBe('沟通能力')
fireEvent.keyDown(multiCustom, { key: 'Enter' })
// The domain face encoded the whole batch into one carrier envelope.
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
{ id: 'signals', selected: ['系统设计', '代码质量'] },
{ id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' },
]))
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
})
@@ -233,6 +239,11 @@ describe('QuestionComposer', () => {
fireEvent.keyDown(custom, { key: 'Enter' })
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('button', { name: '提交' }))
expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
{ id: 'detail', selected: [], custom: 'x' },
{ id: 'signals', selected: ['系统设计'] },
]))
expect(await screen.findByText('网络中断')).toBeTruthy()
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
@@ -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 packages/client/ui-settings-general/README.md
README.md: 4dbd339c93171b330895ab66366e76fd06013704
README.zh.md: 8ad6de99ce78d3bdb1e7b35e872e5bfe6790e758
README.md: 0202d596f509feeba39a38254e8bab2fae27b649
README.zh.md: adec73edda00d34e209772f0bcc54a994f593997
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
## Model Experience
@@ -4,7 +4,7 @@
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
## 模型体验
@@ -31,7 +31,7 @@ export interface WelcomeNoticeInjected {
export type WelcomeNoticeProps =
PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected
/** Render the mandatory notice until its current version commits durably. */
/** Render the mandatory notice until its current version is acknowledged. */
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
const { complete, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
@@ -61,7 +61,7 @@ export function apply(ctx: ClientContext): void {
// locale/change re-registration wiring.
const t = ctx.locale.bind(NS)
const connection = ctx.get('connection') as ConnectionHandle
const welcomeController = new WelcomeNoticeStore(connection.api)
const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory')
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
const welcomeInjected = (): WelcomeNoticeInjected => ({
controller: welcomeController,
@@ -1,4 +1,4 @@
/** Durable welcome-notice state over the Host settings document. */
/** Welcome-notice state, durable when the browser may use Host settings. */
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -24,7 +24,7 @@ function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
return typeof value === 'string' ? value : undefined
}
/** Coordinates welcome acknowledgement reads and the sole durable write. */
/** Coordinates durable Host acknowledgement or a process-local remote fallback. */
export class WelcomeNoticeStore {
/** uSES-safe state source shared by the registered welcome step. */
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
@@ -33,12 +33,22 @@ export class WelcomeNoticeStore {
private generation = 0
/** @param api - settings wire face used for durable reads and writes. */
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
/**
* @param api - settings wire face used for durable reads and writes.
* @param persistence - remote browsers use memory because settings is loopback-only.
*/
constructor(
private readonly api: Pick<IApiClient, 'settings'>,
private readonly persistence: 'host' | 'memory' = 'host',
) {}
/** Load the current acknowledgement from the Host settings document. */
/** Load the acknowledgement from Host settings or initialize process-local state. */
async load(): Promise<void> {
const generation = ++this.generation
if (this.persistence === 'memory') {
this.store.update((state) => { state.status = 'ready'; state.error = null })
return
}
this.store.update((state) => { state.status = 'loading'; state.error = null })
try {
const response = await this.api.settings.describe({})
@@ -64,12 +74,20 @@ export class WelcomeNoticeStore {
}
/**
* Persist this copy version. The path mutation is idempotent across tabs and
* preserves every sibling setting; failure leaves the step unacknowledged.
* @returns true only when the Host committed the acknowledgement.
* Acknowledge this copy version. The Host path mutation is idempotent across
* tabs and preserves sibling settings; remote fallback changes only this store.
* @returns true when the selected persistence mode accepted the acknowledgement.
*/
async acknowledge(): Promise<boolean> {
const generation = ++this.generation
if (this.persistence === 'memory') {
this.store.update((state) => {
state.status = 'ready'
state.acknowledged = true
state.error = null
})
return true
}
this.store.update((state) => { state.status = 'saving'; state.error = null })
try {
const response = await this.api.settings.mutate({
@@ -99,7 +117,9 @@ export class WelcomeNoticeStore {
}
/**
* Refresh only after the welcome step has begun reading durable state.
* Refresh only after welcome state has left idle. A memory-mode load retains
* acknowledgement so reconnect and settings-change refreshes do not reopen a
* process-local notice.
* @param controller - welcome state owner whose current status decides whether to load.
*/
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
@@ -25,7 +25,7 @@ const SEATS = [
['settings.onboarding', WelcomeNotice],
] as const
async function bench() {
async function bench(isLoopback = true) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
@@ -47,7 +47,7 @@ async function bench() {
},
},
}))
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never)
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
}
@@ -159,6 +159,19 @@ describe('ui-settings-general apply', () => {
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
})
it('keeps remote welcome acknowledgement process-local', async () => {
const b = await bench(false)
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.onboarding')[0]!
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
await controller.load()
await expect(controller.acknowledge()).resolves.toBe(true)
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
expect(b.settingsDescribe).not.toHaveBeenCalled()
})
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
const b = await bench()
const redeclare = declare(b.slots)

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