Merge origin/master into fix/web-sources-scroll

This commit is contained in:
Chinesezjc
2026-08-04 13:11:40 +08:00
112 changed files with 2864 additions and 1219 deletions
@@ -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 状态成本,被过滤注册则只保留包名占用。
- 正则表达式源属于部署配置,在服务重载前保持固定。
@@ -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-zstandard-jsonl-session-logs.md: 74430624c771a265fb281e588e28733bc55d3eb6
2026-07-19-zstandard-jsonl-session-logs.zh.md: b22275d1a7c54a743b11f4396318dd87e4f5b42a
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md
2026-07-19-zstandard-jsonl-session-logs.md: 287ec94a91101850e9343d36ffd27870daf1333b
2026-07-19-zstandard-jsonl-session-logs.zh.md: 4e578432640651de1eb1977229b7cdd462766c24
@@ -28,11 +28,11 @@ First materialization compresses the two initial frames before opening the tempo
### Read, listing, and crash recovery
A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are decompressed independently and sequentially, which validates their checksums, and their plaintext is passed to the existing JSONL scanner. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects.
A frame-boundary scanner reads the standard magic, variable header fields, block headers and payload sizes, and optional checksum trailer. It does not interpret compressed blocks. Complete frames are decompressed independently and sequentially with Node's default `ZSTD_e_end`, which requires frame completion and validates their checksums, and their plaintext is passed to the existing JSONL scanner. A checksum/decompression failure in any complete frame, a malformed complete-frame JSONL tail, or invalid frame structure is corruption and rejects.
Listing reads in bounded chunks only until the first complete frame is available, validates and decompresses that header frame, and never reads an event frame. The dedicated header frame therefore preserves metadata-only listing even for very large session logs.
EOF inside the final frame is a recoverable torn tail. Node's decoder is given the available frame prefix; every complete newline-terminated event it emits is retained. Repair truncates from that frame's starting byte and appends one new checksummed frame containing the recovered complete events followed by the coordinator's synthetic tool, step, and turn closers. If the tear occurs before any complete event is decodable, repair drops the partial frame and retains all prior complete frames.
EOF inside the final frame is a recoverable torn tail. After the scanner establishes that boundary, a dedicated prefix decoder uses `finishFlush: ZSTD_e_flush` so Node emits available plaintext without requiring frame or checksum completion; every complete newline-terminated event it emits is retained. Repair truncates from that frame's starting byte and appends one new checksummed frame containing the recovered complete events followed by the coordinator's synthetic tool, step, and turn closers. If the tear occurs before any complete event is decodable, repair drops the partial frame and retains all prior complete frames.
### Consumers and verification
@@ -28,11 +28,11 @@ JSONL 持久化后端会逐字保留每个 `SessionEvent`,其中包括数量
### 读取、列举与崩溃恢复
帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。后端独立且按顺序解压完整帧,由此验证各帧校验和,再把明文交给既有 JSONL 扫描器。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。
帧边界扫描器会读取标准魔数、可变头字段、块头与负载长度,以及可选校验和尾部,但不会解释压缩块。后端使用 Node 默认的 `ZSTD_e_end` 独立且按顺序解压完整帧;该模式要求帧完整并验证各帧校验和,再把明文交给既有 JSONL 扫描器。任何完整帧的校验和或解压失败、完整帧中畸形的 JSONL 尾部,或者无效帧结构都属于损坏并拒绝加载。
列举只按有界分片读取到第一个完整帧可用为止,验证并解压该头部帧,绝不读取事件帧。因此,即使会话日志很大,专用头部帧仍能维持仅元数据列举。
最终帧内部遇到 EOF 属于可恢复的撕裂尾部。后端把已有帧前缀交给 Node 解码器,并保留其产出的每个完整以换行结束的事件。修复从该帧起始字节截断,再追加一个新的带校验和帧,其中依次包含恢复出的完整事件,以及协调器生成的工具、步骤与轮次闭合事件。如果撕裂位置尚不足以解码任何完整事件,修复会丢弃该不完整帧并保留此前全部完整帧。
最终帧内部遇到 EOF 属于可恢复的撕裂尾部。扫描器确定该边界后,专用前缀解码器会使用 `finishFlush: ZSTD_e_flush`,使 Node 不必等到帧结束或读到完整校验和就能产出已有明文;其中每个完整以换行结束的事件都会保留。修复从该帧起始字节截断,再追加一个新的带校验和帧,其中依次包含恢复出的完整事件,以及协调器生成的工具、步骤与轮次闭合事件。如果撕裂位置尚不足以解码任何完整事件,修复会丢弃该不完整帧并保留此前全部完整帧。
### 消费方与验证
@@ -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-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-08-03-tui-long-session-render-costs.md
2026-08-03-tui-long-session-render-costs.md: c5b03960b6951cb2de2b847f03ec8eb2b92cc55c
2026-08-03-tui-long-session-render-costs.zh.md: b41c5a8c546e296525645d82808117673fdeec6d
@@ -0,0 +1,33 @@
# Agent Note: TUI long-session render costs — shared step-timing scan and card line caches
Status: implemented
English | [中文](2026-08-03-tui-long-session-render-costs.zh.md)
## Problem
On a long resumed session (196k events, 2.2k steps, 1.8k tool cards) the TUI took ~12 s to render the transcript and ~800 ms to echo one keystroke. Profiling attributed both to the render path, not to session load (zstd + parse + surface seed is ~1.7 s):
- Every step's timing footer called `stepTimingAt`, which replayed the whole event log from index 0 per footer — O(steps × events) on the initial render, ~6 s of CPU.
- pi-tui re-renders every component each frame and relies on per-component line caches (its own `Text`/`Markdown` cache by `(text, width)`). `ToolCardComponent.render()` and `ContextCardComponent.render()` built throwaway `new Text(...)`/`new Markdown(...)` instances inside `render(width)`, so every frame — every keystroke — re-wrapped every settled card's output.
## Decision
`packages/ui/tui/src/chat/timing.ts` replaces `stepTimingAt` with `StepTimingTracker`: one accumulator per chat mount, created in `createTuiChat` and threaded through `StreamingAssistantComponent` into each `StepTimingComponent`. A query advances a cursor over events appended since the previous query and keeps per-step bucket state in a map, so all footers together cost O(events). The open bucket is accumulated to the query clock at lookup, and a step is pinned at its `step/end`. The tracker requires the append-only session log (the `seq = log length` contract).
`ToolCardComponent` and `ContextCardComponent` cache their rendered rows keyed by width. The cache drops on every state mutator (`updateResult`, `setVisibility`, `setExpanded`) and on `invalidate()` (pi-tui's tree-wide cascade), so a state change always re-renders; everything else — including every keystroke frame — returns the cached rows. This restores upstream pi's own component convention (persistent child components plus explicit `cachedWidth`/`cachedLines` where rendering is custom, e.g. pi `coding-agent` `bash.ts`), which the imperative `render(width)` bodies here had silently defeated.
Measured on the 196k-event session (tmux, 200×50): resume prompt-ready 12.2 s → 7.2 s; per-keystroke echo 796 ms median → 17 ms (fresh-session parity).
## Alternatives considered
- **Index `step/start` offsets, keep per-footer replay** — removes the `findIndex` but each footer still scans its step's span from a shared array; the tracker's single shared pass is the same complexity win with less bookkeeping.
- **Restructure the cards into persistent pi-tui child components** (upstream pi's primary style) — equivalent steady-state cost, but a larger diff across card state handling for no additional win over the width-keyed cache.
- **Cache inside pi-tui's `Container.render`** — wrong layer: the vendored patch surface would grow, and the contract (components own their caches) already exists upstream.
## Consequences
- Typing latency no longer scales with total tool output; the residual per-frame cost is pi-tui's tree traversal and row concatenation, linear in rendered rows. Resume render cost is now dominated by pi-tui's one-time initial layout (~4 s at 196k events) plus load (~1.7 s), both linear.
- The tracker consumes event times as logged and drops the removed implementation's mid-scan `time > at` cutoff, which per-footer `at` values make impossible in a shared scan; under a backward wall-clock step each bucket clamps at zero, which can differ from the old cutoff's totals.
- Card `render()` is no longer a pure function of `(state, width)` per call — mutators must drop `linesCache`. A new mutator that forgets to do so shows stale rows; the cache tests in `packages/ui/tui/tests/transcript-card-cache.spec.ts` pin the contract for the existing mutators.
- `StepTimingTracker` assumes step coordinates are not reused after `step/end`; a duplicate `step/start` for a closed step is ignored rather than restarting the step.
@@ -0,0 +1,33 @@
# Agent Note: TUI 长会话渲染开销:共享步骤耗时扫描与卡片行缓存
Status: implemented
[English](2026-08-03-tui-long-session-render-costs.md) | 中文
## 问题
在一个恢复后的长会话(196k 条事件、2.2k 个步骤、1.8k 张工具卡片)中,TUI 渲染 transcript(文本记录)耗时约 12 秒,回显一次按键耗时约 800 毫秒。性能剖析表明,两项耗时都来自渲染路径,而非会话加载(zstd + 解析 + 表层播种约为 1.7 秒):
- 每个步骤的耗时页脚都会调用 `stepTimingAt`,而它会针对每个页脚从索引 0 起回放整个事件日志,因此初次渲染的复杂度为 O(步骤数 × 事件数),占用约 6 秒 CPU 时间。
- pi-tui 每一帧都会重新渲染所有组件,并依赖各组件自己的行缓存(它的 `Text`/`Markdown` 会按 `(text, width)` 缓存)。`ToolCardComponent.render()``ContextCardComponent.render()` 构造用后即弃的 `new Text(...)`/`new Markdown(...)` 实例,且构造发生在 `render(width)` 内,因此每一帧,也就是每次按键,都会重新对每张已结算卡片的输出进行折行。
## 决策
`packages/ui/tui/src/chat/timing.ts` 不再使用 `stepTimingAt`,改用 `StepTimingTracker`:每次挂载聊天界面时在 `createTuiChat` 中创建一个累加器,再经 `StreamingAssistantComponent` 传入每个 `StepTimingComponent`。每次查询都会推进游标,扫描上次查询后追加的事件,并在一个映射表中保存各步骤的 bucket 状态,因此所有页脚合计只需 O(事件数)。查询时,系统把未闭合 bucket 累加到查询时刻;步骤在其 `step/end` 处固定。该跟踪器要求会话日志仅追加,即遵守 `seq = log length` 契约。
`ToolCardComponent``ContextCardComponent` 按宽度键控缓存渲染行。调用任一状态修改方法(`updateResult``setVisibility``setExpanded`)或 `invalidate()`(pi-tui 的全树级联)时会清空缓存,因此状态变化一定会重新渲染;其他情况,包括每一次按键帧,都会返回缓存行。这恢复了上游 pi 自身的组件惯例:使用常驻子组件;自定义渲染时显式使用 `cachedWidth`/`cachedLines`,例如 pi `coding-agent``bash.ts`。而这里命令式的 `render(width)` 函数体此前让这套惯例失效。
在该 196k 条事件的会话上测得(tmux,200×50):恢复后提示符就绪耗时从 12.2 秒降至 7.2 秒;每次按键的回显耗时中位数从 796 毫秒降至 17 毫秒(与新会话持平)。
## 曾考虑的替代方案
- **索引 `step/start` 偏移量,保留逐页脚回放**:这会消除 `findIndex`,但每个页脚仍要从共享数组扫描所属步骤的区间;跟踪器的一次共享遍历以更少的额外状态记录取得相同的复杂度改进。
- **把卡片重构为常驻 pi-tui 子组件**(上游 pi 的主要风格):稳定状态下成本相同,但卡片状态处理所需改动更大,相较按宽度键控的缓存并无额外收益。
- **在 pi-tui 的 `Container.render` 内缓存**:层级不对:对第三方内嵌代码的补丁范围会扩大,而上游已经约定由组件拥有各自的缓存。
## 后果
- 输入延迟不再随工具输出总量增长;剩余的每帧成本是 pi-tui 的树遍历与行拼接,与渲染行数呈线性关系。恢复时的渲染成本现由 pi-tui 的一次性初始布局(196k 条事件时约 4 秒)与加载(约 1.7 秒)主导,两者均为线性。
- 该跟踪器直接采用日志记录的事件时间,不再像已移除的实现那样,在扫描中途遇到 `time > at` 时截断;由于每个页脚的 `at` 值不同,共享扫描无法采用这种截断;挂钟时间倒退时,每个 bucket 都以零为下限,所得总计值可能与旧截断下的总计值不同。
- 卡片的 `render()` 不再是每次调用时 `(state, width)` 的纯函数,状态修改方法必须清空 `linesCache`。若新增状态修改方法时忘记清空,界面会显示陈旧行;`packages/ui/tui/tests/transcript-card-cache.spec.ts` 中的缓存测试固定了现有状态修改方法的契约。
- `StepTimingTracker` 假定步骤坐标在 `step/end` 后不会复用;对已关闭步骤重复出现的 `step/start` 会被忽略,不会重新启动该步骤。
@@ -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-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)
})
})
+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([
+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
@@ -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",
+9 -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`
@@ -2075,6 +2079,8 @@ export interface TuiConfig {
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. */
@@ -2116,7 +2122,7 @@ export interface TuiThemeConfig {
}
```
Source: [`packages/ui/tui/src/config.ts:125`](../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:245`](../../packages/ui/tui/src/index.ts)
Source: [`packages/ui/tui/src/index.ts:246`](../../packages/ui/tui/src/index.ts)
## `ctx.typert` — `TypertRegistry`
+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 -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'
@@ -47,7 +47,6 @@ const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath(
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 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'
@@ -60,6 +59,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.
@@ -153,18 +179,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
@@ -567,9 +567,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-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)
@@ -30,6 +30,21 @@ function deferred<T>() {
}
describe('WelcomeNoticeStore', () => {
it('acknowledges in memory without calling loopback-only settings APIs', async () => {
const describe = vi.fn()
const mutate = vi.fn()
const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory')
await controller.load()
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null })
await expect(controller.acknowledge()).resolves.toBe(true)
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
await controller.load()
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
expect(describe).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
it('acknowledges only the exact current copy version', async () => {
for (const [version, acknowledged] of [
[undefined, false],
@@ -67,7 +67,7 @@
min-width: 0;
}
.menu > .node {
margin-left: -8px;
margin-left: -3px;
}
.row {
+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/fs/tool-fs-search/README.md
README.md: b12ffda9869c7d6bef5ea5b54594781ecf555ff4
README.zh.md: 7dd6cdf9a209f2fe357b4ffe48d20d574266ce60
README.md: 78ffa069e56da5fc987913acf761eb5c6ae15b1a
README.zh.md: 42b123d5c47d8f48bc21b6f9bed4905372ca8625
+15 -13
View File
@@ -2,21 +2,21 @@
English | [中文](README.zh.md)
The **model-facing filesystem discovery tools**`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)``ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
The **model-facing filesystem discovery tools**`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (`--no-config` prepended so a host `RIPGREP_CONFIG_PATH` cannot inject a `--pre` preprocessor into the unconfined spawn; model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
```ts ignore-check
// A deployment chooses how over-cap glob pages are selected.
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
await ctx.plugin(LocalSubprocessService) // @deepseek-ai/dsh-subprocess-local
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false })
// Optional: a spill backend makes capped results fully recoverable.
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
```
Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails.
Why spawn-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The subprocess seam owns spawn execution, process-tree termination, environment scrubbing, and bounded output capture; this package owns schemas, argument validation, argv construction, parsing, retention, formatted-result spill, and timeout declaration. The tools never expose a background task — the call returns only after `rg` exits, is terminated by the cooperative timeout, is aborted, or fails.
## Deployment requirement: rg + co-located bash/filesystem
## Deployment requirement: no host rg, co-located workdir/filesystem
The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin load; otherwise `glob` and `grep` are absent from the model-visible tool schema. Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
The binary ships with the package on every supported platform (macOS/Linux/Windows, x64/arm64), so no host `rg` install is required and the tools register on every deployment. Returned paths are displayed relative to the resolved workdir (the calling agent's session cwd when present, else `process.cwd()`) and are follow-up-readable with `read` only when that workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
## Config
@@ -29,24 +29,26 @@ The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin
| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. |
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the bash backend's own timeout stays a second safety cap. |
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. |
| `graceMs` | `3000` | Terminate-escalation grace period the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`. |
| `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). |
## Tools
| Tool | Arguments | Behavior |
|---|---|---|
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. |
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. |
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: <preview>`. |
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
## Two budgets, two artifacts
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
Raw `rg` stdout and stderr are internal transport details. Each search requests collect-mode budgets from the subprocess seam — complete stdout within `rawOutputMaxBytes` and a `stderrMaxBytes` diagnostic tail — with no spill files on either stream (the tool never reads a raw spill path). If the seam still reports a lossy stdout read, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query; a lossy stderr read only marks the diagnostic excerpt `[stderr truncated]`. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
## Errors
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (a failed `rg` launch, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still lossy after the requested stdout capture budget), and `SEARCH_ABORTED` (cooperative tool timeout or caller cancellation). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
## Model Experience
@@ -54,7 +56,7 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass
#### What the model sees
After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
##### Glob guidance with `sampleOverCapGlobResults: true`
@@ -86,7 +88,7 @@ Prefix-stable while the plugin scope, sampling choice, and guidance text are unc
#### What the model sees
The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; schemas are visible only after the load-time `rg` probe succeeds.
The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; the tools are registered unconditionally.
#### Token effect
@@ -126,7 +128,7 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer.
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
- **The packaged binary is fixed at dependency version** — `@vscode/ripgrep` covers the platforms it ships (macOS/Linux/Windows, x64/arm64); an unsupported platform or a corrupted install fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located workspace or another search consumer.
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.
- **Sampling, when enabled, groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred.
+29 -27
View File
@@ -2,21 +2,21 @@
[English](README.md) | 中文
**面向模型的文件系统发现工具**`glob``grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时,本包(package)探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep,就记录警告,并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)``ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools``systemPrompt``bash`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`
**面向模型的文件系统发现工具**`glob``grep`)由 **打包的 ripgrep 二进制**`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools``systemPrompt``subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`
```ts ignore-check
// A deployment chooses how over-cap glob pages are selected.
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
await ctx.plugin(LocalSubprocessService) // @deepseek-ai/dsh-subprocess-local
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false })
// Optional: a spill backend makes capped results fully recoverable.
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
```
采用 bash 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。bash 执行器负责请求默认值/上限、子进程执行、进程终止、环境清理、原始输出捕获和后端替换(本地、沙箱化、远程);本包负责 schema、参数校验、shell 引用、解析、保留、格式化结果 spill 和超时声明。工具绝不调用 `ctx.bash.start()`,也不公开 bash task id只有在 `rg` 退出、超时、中止或失败后,调用才会返回。
采用 spawn 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。subprocess seam 负责 spawn 执行、进程终止、环境清理和有界输出捕获;本包负责 schema、参数校验、argv 构造、解析、保留、格式化结果 spill 和超时声明。工具绝不暴露后台任务——只有在 `rg` 退出、被协作式超时终止、被中止或失败后,调用才会返回。
## 部署要求:rg 与共置的 bash/文件系统
## 部署要求:无需宿主 rg,但工作目录与文件系统需共置
已挂载的 bash 执行器必须能在插件加载时解析 `rg`,其来源是执行器的 `PATH`;否则面向模型的工具 schema 中不会出现 `glob` 和 `grep`。返回路径会相对于解析后的 bash 工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用执行器配置的默认值);只有 bash 工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。
二进制随包交付,覆盖所有受支持平台(macOS/Linux/Windowsx64/arm64),因此无需宿主 `rg` 安装,工具在每个部署上都注册。返回路径会相对于解析后的工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用 `process.cwd()`);只有该工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。
## 配置
@@ -29,24 +29,26 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
| `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 |
| `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 |
| `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 |
| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;bash 后端自身的超时仍作为第二道安全上限。 |
| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 |
| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期;超过后搜索以 `SEARCH_ABORTED` 失败。 |
| `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获;lossy 读取只保留尾部(标记 `[stderr truncated]`)。 |
## 工具
| 工具 | 参数 | 行为 |
|---|---|---|
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 |
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 |
| `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录**目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: <preview>` 的匹配。 |
常规预算不进入面向模型的 schema(没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。
## 两类预算、两类产物
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout;如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
原始 `rg` stdout 与 stderr 是内部传输细节。每次搜索从 subprocess seam 请求 collect 模式预算——`rawOutputMaxBytes` 内的完整 stdout 与 `stderrMaxBytes` 的诊断尾部——两条流都不产生 spill 文件(工具从不读取原始 spill 路径)。如果 seam 仍报告 lossy stdout 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询lossy stderr 读取只把诊断摘录标记为 `[stderr truncated]`。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
## 错误
搜索失败携带本包拥有的 `SearchError``HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`ripgrep 拒绝正则/glob)、`SEARCH_FAILED`注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时调用方取消或 bash 执行器自身超时)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。
搜索失败携带本包拥有的 `SearchError``HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`ripgrep 拒绝正则/glob)、`SEARCH_FAILED``rg` 启动失败、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍 lossy)和 `SEARCH_ABORTED`协作式工具超时调用方取消)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。
## 模型体验
@@ -54,7 +56,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
#### 模型看到的内容
加载时 `rg` 探测成功后,该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。
该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。
##### 启用 `sampleOverCapGlobResults: true` 时的 Glob 指导
@@ -76,57 +78,57 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
#### Token 影响
工具注册期间每个请求支付固定指导成本;必填的采样选决定采用哪个 glob 变体。
工具注册期间每个请求固定指导成本;必填的采样选决定采用哪个 glob 变体。
#### KV Cache 影响
只要插件作用域、采样选项和指导文本不变前缀就保持稳定。启用、dispose(资源释放)或更改该选项,可能该提示词段开始使复用失效。
插件作用域、采样选择与指导文本不变前缀稳定。激活、销毁或改变选择可能使该提示词段复用失效。
### 工具 schema
#### 模型看到的内容
glob 描述会说明配置所指定的超限结果排序方式。生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`只有加载时 `rg` 探测成功后,这些 schema 才可见
glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`工具无条件注册
#### Token 影响
工具可见每个请求都支付固定 schema 成本。
工具可见每个请求固定 schema 成本。
#### KV Cache 影响
只要工具可见性定义不变前缀就保持稳定。注册生命周期或作用域限制可能从首个变化的 schema token 开始使复用失效。
工具可见性定义不变前缀稳定。注册生命周期或作用域限制可能从第一个改变的 schema token 使复用失效。
### 结果与 spill 通知
### 结果与 spill 提示
#### 模型看到的内容
`glob` 每行返回一个路径;`grep` 在每个路径下 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 后端检索提示,或说明完整结果无法保存。`sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面会在实际搜索根正下方的条目之间按轮转方式取路径,footer 会说明采样依据和触达的顶层条目数;无法触达全部条目footer 会要求模型缩小 `path`。设为 `false` 时页面保留按修改时间排序的前部,并沿用通常用于达到上限结果的 footer。未超过上限的结果原样不动;扁平采样结果也沿用普通 footer,因为其样本等同于按修改时间排序的前部。spill 产物始终保存按修改时间排序的完整列表。
`glob` 每行返回一个路径;`grep` 在每个路径下分组展示 `Line <line>: <preview>` 匹配。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果以省略计数结尾,并附 spill locator 后端检索提示;否则说明完整结果无法保存。启用 `sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面实际搜索根正下方的条目轮转取路径,页脚说明采样依据及其覆盖的顶层条目数;无法覆盖全部条目时,页脚提示模型收窄 `path`。`false` 时页面按修改时间排序的前部,并保留普通的上限结果页脚。未超过上限的结果原样呈现;扁平采样结果也保留普通页脚,因为其采样等于按修改时间排序的前部。spill 产物始终持有按修改时间排序的完整列表。
#### Token 影响
内联路径匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 限制;调用保留结果留在历史中,直到上下文压缩(compaction)
内联路径匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 约束;调用保留结果在压缩前留在历史中。
#### KV Cache 影响
追加;新可见内容位于可复用请求前缀之后,不会使有 KV-cache 条目失效。
追加;新可见内容跟在可复用请求前缀之后,不会使有 KV-cache 条目失效。
### 工具错误
#### 模型看到的内容
失败规范化为 `Error: <message>`,并向调用方提供结构化 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据。
失败规范化为 `Error: <message>`,并携带结构化 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据供调用方使用
#### Token 影响
只有失败调用会加这些保留 token。
只有失败调用会加这些保留 token。
#### KV Cache 影响
追加;新可见内容位于可复用请求前缀之后,不会使有 KV-cache 条目失效。
追加;新可见内容跟在可复用请求前缀之后,不会使有 KV-cache 条目失效。
## 已知限制与暂缓事项
## 已知局限与延期工作
- **搜索文件访问没有共享工作区证明**:只有 bash 工作目录文件系统根目录表示同一工作区时,返回路径才继续读取;本包不执行运行时跨服务校验。
- **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。
- **schema 只公开一个有界页面**offset 分页、大小写模式开关、其他输出模式提供方支的发现不在本包内;达到上限的完整输出需要 spill 后端。
- **启用采样时,只按搜索根下的路径首段分组**超过上限的 `glob` 页面在这些顶层条目之间做均衡,因此集中在更深的结果(一棵总体均匀树里某个特别庞大的子目录)在该层级下仍然分布不均;递归均衡已延期。
- **搜索文件访问没有共享工作区证明**——只有当工作目录文件系统根目录指向同一工作区时,返回路径才保证可继续读取;本包不执行运行时跨服务校验。
- **打包二进制固定在依赖版本上**——`@vscode/ripgrep` 覆盖其随附的平台(macOS/Linux/Windowsx64/arm64);不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。
- **schema 只暴露一个有界页面**——偏移分页、大小写开关、替代输出模式提供方支的发现不在本包范围内;达到上限的完整输出需要 spill 后端。
- **启用采样时按搜索根正下方的第一段路径分组**——超过上限的 `glob` 页面在这些顶层条目之间衡,因此集中在更深的结果(一棵均匀树里某个繁忙目录)在该层级下仍会呈现不均;递归平衡被延期。
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tool-fs-search",
"description": "Model-facing filesystem discovery tools (glob, grep) backed by the DeepSeek Harness bash seam (ctx.bash)",
"description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -27,23 +27,23 @@
],
"license": "BSD-3-Clause",
"dependencies": {
"@vscode/ripgrep": "^1.18.0",
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-retention": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-spill": "^0.0.1",
"@deepseek-ai/dsh-subprocess": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+29 -23
View File
@@ -1,10 +1,11 @@
/**
* The model-facing `glob` tool: discover files whose paths match a glob
* pattern, sorted by modification time. Execution goes through the bash seam
* (`ctx.bash`) with a fixed `rg --files` command — this module owns the
* model-facing schema, argument validation, shell-safe command construction,
* result parsing, inline sampling, and formatting; process concerns (defaulting,
* scrubbing, kill, backend substitution) stay behind `ctx.bash`.
* pattern, sorted by modification time. Execution spawns the packaged
* ripgrep binary (`@vscode/ripgrep`) directly through the subprocess seam
* with a plain argv vector — this module owns the model-facing schema,
* argument validation, argv construction, result parsing, inline sampling,
* and formatting; process concerns (spawn execution, tree termination,
* environment scrubbing, output capture) stay behind `ctx.subprocess`.
* @module @deepseek-ai/dsh-tool-fs-search/glob
*/
@@ -13,11 +14,9 @@ import { sep } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { globSearchMeta, searchViewFromMeta } from './presentation.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
/**
@@ -48,6 +47,10 @@ export interface GlobToolCaps {
maxMetaBytes: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
rawOutputMaxBytes: number
/** Terminate-escalation grace period (ms) for the search process. */
graceMs: number
/** Cap on the retained stderr diagnostic tail. */
stderrMaxBytes: number
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
timeoutMs: number
}
@@ -73,32 +76,35 @@ export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInp
}
/**
* Build the fixed `rg --files` command for one `glob` call. Every
* Build the fixed `rg --files` argv for one `glob` call. Every
* model-controlled value ({@link GlobInput.pattern}, {@link GlobInput.path})
* passes through {@link singleQuote}; the search root rides behind `--` so a
* leading-dash path can never be parsed as a flag. `--sort=modified` orders by
* modification time, `--no-ignore --hidden` searches ignored and hidden files,
* and {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out.
* is a plain argv element — no shell layer exists, so no quoting applies; the
* search root rides behind `--` so a leading-dash path can never be parsed as
* a flag. `--sort=modified` orders by modification time, `--no-ignore
* --hidden` searches ignored and hidden files, and
* {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out.
*
* @param input - the validated arguments.
* @returns the complete, shell-safe command string.
* @returns the complete ripgrep argument vector (excluding the binary itself).
*/
export function buildGlobCommand(input: GlobInput): string {
export function buildGlobCommand(input: GlobInput): string[] {
const parts = [
'rg --files',
`--glob=${singleQuote(input.pattern)}`,
'--sort=modified --no-ignore --hidden',
'--files',
`--glob=${input.pattern}`,
'--sort=modified',
'--no-ignore',
'--hidden',
// Two negated globs per VCS name: the bare form prunes the directory
// during traversal; the /** form still excludes the contents when the
// search root is AT or INSIDE the directory (where the bare form,
// matched against root-prefixed paths, never fires).
...GLOB_VCS_EXCLUDES.flatMap(name => [
`--glob=${singleQuote(`!**/${name}`)}`,
`--glob=${singleQuote(`!**/${name}/**`)}`,
`--glob=!**/${name}`,
`--glob=!**/${name}/**`,
]),
]
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
return parts.join(' ')
if (input.path !== undefined) parts.push('--', input.path)
return parts
}
/**
@@ -285,7 +291,7 @@ export function presentGlobResult(_args: { pattern: string; path?: string }, res
* Register the `glob` tool and its system-prompt guidance.
*
* @param ctx - the plugin context; registrations are effects scoped to it, and
* execution uses its `bash` service.
* execution uses its `subprocess` service.
* @param caps - the deployment's resolved glob caps (plugin config after defaulting).
*/
export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
@@ -335,7 +341,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
},
async execute(args, exec) {
const input = parseGlobArgs(args)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes)
const root = input.path === undefined ? '.' : toWorkdirRelative(input.path, run.workdir)
if (run.noMatches) return { root, paths: [] }
+24 -20
View File
@@ -1,11 +1,12 @@
/**
* The model-facing `grep` tool: search file contents with a ripgrep regular
* expression. Execution goes through the bash seam (`ctx.bash`) with a fixed
* line-oriented `rg --json` command so file path, line number, and line text
* parse without colon-splitting ambiguity — this module owns the model-facing
* schema, argument validation, shell-safe command construction, `--json`
* record parsing, per-line preview retention, match retention, grouping, and
* formatting; process concerns stay behind `ctx.bash`.
* expression. Execution spawns the packaged ripgrep binary
* (`@vscode/ripgrep`) directly through the subprocess seam with a plain argv
* vector using a fixed line-oriented `rg --json` command so file path, line
* number, and line text parse without colon-splitting ambiguity — this module
* owns the model-facing schema, argument validation, argv construction,
* `--json` record parsing, per-line preview retention, match retention,
* grouping, and formatting; process concerns stay behind `ctx.subprocess`.
*
* @module @deepseek-ai/dsh-tool-fs-search/grep
*/
@@ -15,12 +16,10 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { SpillRef } from '@deepseek-ai/dsh-spill'
import type {} from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type { GrepMatch } from './search-core.ts'
import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
import { grepSearchMeta, searchViewFromMeta } from './presentation.ts'
import { singleQuote } from './shell-quote.ts'
import { acceptedSurfaceValue } from './surface.ts'
/**
@@ -46,6 +45,10 @@ export interface GrepToolCaps {
maxMetaBytes: number
/** Cap on the complete raw `rg` stdout the tool will parse. */
rawOutputMaxBytes: number
/** Terminate-escalation grace period (ms) for the search process. */
graceMs: number
/** Cap on the retained stderr diagnostic tail. */
stderrMaxBytes: number
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
timeoutMs: number
}
@@ -96,20 +99,21 @@ export function parseGrepArgs(args: { pattern: string; path?: string; include?:
}
/**
* Build the fixed line-oriented `rg --json` command for one `grep` call. Every
* Build the fixed line-oriented `rg --json` argv for one `grep` call. Every
* model-controlled value ({@link GrepInput.pattern}, {@link GrepInput.path},
* {@link GrepInput.include}) passes through {@link singleQuote}; the pattern
* and include ride in `--flag=value` form and the target behind `--`, so a
* leading-dash value can never be parsed as a flag.
* {@link GrepInput.include}) is a plain argv element — no shell layer exists,
* so no quoting applies; the pattern and include ride in `--flag=value` form
* and the target behind `--`, so a leading-dash value can never be parsed as
* a flag.
*
* @param input - the validated arguments.
* @returns the complete, shell-safe command string.
* @returns the complete ripgrep argument vector (excluding the binary itself).
*/
export function buildGrepCommand(input: GrepInput): string {
const parts = ['rg --json', `--regexp=${singleQuote(input.pattern)}`]
if (input.include !== undefined) parts.push(`--glob=${singleQuote(input.include)}`)
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
return parts.join(' ')
export function buildGrepCommand(input: GrepInput): string[] {
const parts = ['--json', `--regexp=${input.pattern}`]
if (input.include !== undefined) parts.push(`--glob=${input.include}`)
if (input.path !== undefined) parts.push('--', input.path)
return parts
}
/**
@@ -265,7 +269,7 @@ export function presentGrepResult(
* Register the `grep` tool and its system-prompt guidance.
*
* @param ctx - the plugin context; registrations are effects scoped to it, and
* execution uses its `bash` service.
* execution uses its `subprocess` service.
* @param caps - the deployment's resolved grep caps (plugin config after defaulting).
*/
export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
@@ -315,7 +319,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
},
async execute(args, exec) {
const input = parseGrepArgs(args)
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes)
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes)
if (run.noMatches) return { matches: [] }
const all: GrepMatch[] = []
+38 -54
View File
@@ -1,28 +1,27 @@
/**
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
* bash executor seam (`ctx.bash`). This single plugin registers both tools
* only when the mounted bash executor can find `rg` on its `PATH`.
* packaged ripgrep binary (`@vscode/ripgrep`). This single plugin registers
* both tools; the binary ships inside the npm dependency, so no system `rg`
* install and no shell layer is involved.
*
* ## Bash-backed, not a `ctx.fs` provider method
* ## Spawn-backed, not a `ctx.fs` provider method
*
* Local workspace discovery is a process-backed `rg` workflow, so these tools
* execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` with fixed
* ripgrep command templates — never `ctx.bash.start()`, never a model-visible
* background task. The tool layer owns schemas, argument validation, shell
* quoting ({@link module:@deepseek-ai/dsh-tool-fs-search/shell-quote}), result
* parsing, retention, formatted-result spill, and timeout declaration; the
* bash executor owns request defaulting/capping, subprocess execution,
* process-group termination, environment scrubbing, raw output capture, and
* backend substitution. At load, the package probes `command -v rg` through the
* same bash seam; if ripgrep is absent, `glob` / `grep` and their prompt
* sections are not registered. The package injects `tools`, `systemPrompt`,
* and `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read
* execute through `ctx.subprocess.spawn()` with fixed ripgrep argv templates —
* never `ctx.bash`, never `ctx.bash.start()`, never a model-visible background
* task. The tool layer owns schemas, argument validation, argv construction
* ({@link module:@deepseek-ai/dsh-tool-fs-search/glob} /
* {@link module:@deepseek-ai/dsh-tool-fs-search/grep}), result parsing,
* retention, formatted-result spill, and timeout declaration; the subprocess
* seam owns spawn execution, process-tree termination, environment scrubbing,
* and raw output capture. The package injects `tools`, `systemPrompt`, and
* `subprocess` — deliberately NOT `fs`, and `ctx.spillStore` is read
* opportunistically with `ctx.get()` because formatted-result spill is optional.
*
* Returned paths are displayed relative to the resolved bash workdir and are
* follow-up-readable only in co-located deployments where the bash workdir and
* the filesystem `read` root are the same workspace — a documented v1
* deployment requirement, not runtime-validated.
* Returned paths are displayed relative to the resolved workdir and are
* follow-up-readable only in co-located deployments where the workdir and the
* filesystem `read` root are the same workspace — a documented v1 deployment
* requirement, not runtime-validated.
*
* @module @deepseek-ai/dsh-tool-fs-search
*/
@@ -31,7 +30,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
import { RAW_OUTPUT_MAX_BYTES, SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult, sampleAcrossTopLevel } from './glob.ts'
export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts'
@@ -50,22 +49,24 @@ export {
export type { GrepInput, GrepToolCaps } from './grep.ts'
export {
RAW_OUTPUT_MAX_BYTES,
SEARCH_GRACE_MS,
SEARCH_META_MAX_BYTES,
SEARCH_STDERR_MAX_BYTES,
SEARCH_TIMEOUT_MS,
SearchError,
previewLine,
resolveRgPath,
runRipgrep,
toWorkdirRelative,
trySaveFormattedResult,
} from './search-core.ts'
export type { GrepMatch, RipgrepRun, SearchErrorCode } from './search-core.ts'
export { singleQuote } from './shell-quote.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-fs-search'
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
export const inject = ['tools', 'systemPrompt', 'bash']
export const inject = ['tools', 'systemPrompt', 'subprocess']
/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */
export interface Config {
@@ -81,6 +82,10 @@ 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
}
@@ -92,15 +97,14 @@ export const Config: z<Config> = z.object({
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES),
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
graceMs: z.number().default(SEARCH_GRACE_MS),
stderrMaxBytes: z.number().default(SEARCH_STDERR_MAX_BYTES),
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS),
})
/** The shape after schemastery applied the defaults. */
type ResolvedConfig = Required<Config>
/** POSIX-shell builtin probe for the ripgrep binary in the bash executor environment. */
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
@@ -109,36 +113,14 @@ function assertPositiveInteger(name: string, value: number): void {
}
/**
* Check whether the mounted bash executor can find `rg`.
*
* Nonzero exit means "not available" and disables this optional tool suite.
* Infrastructure failures stay loud: a deployment with a broken bash executor
* should not silently lose tools in a way that looks like a deliberate skip.
*
* @param ctx - plugin context whose `bash` service is the executor the tools will use.
* @returns true when `command -v rg` exits 0, false when it exits nonzero.
*/
async function ripgrepAvailable(ctx: Context): Promise<boolean> {
const spec = ctx.bash.resolve({ command: RG_PROBE_COMMAND })
let result
try {
result = await ctx.bash.run(spec)
} catch (error: unknown) {
throw new Error(`tool-fs-search: ripgrep availability probe could not start: ${String(error)}`, { cause: error })
}
if (result.aborted || result.timedOut || result.signal !== null || result.exitCode === null) {
throw new Error('tool-fs-search: ripgrep availability probe did not complete')
}
return result.exitCode === 0
}
/**
* Register the `glob`/`grep` filesystem discovery tool suite when `rg` exists.
* Register the `glob`/`grep` filesystem discovery tool suite. The packaged
* ripgrep binary is always available (an npm dependency), so registration is
* unconditional.
*
* @param ctx - plugin context; registrations are effects scoped to this plugin.
* @param config - resolved plugin configuration from schemastery.
* @returns when ripgrep is unavailable, resolves without registering any tools.
*/
// oxlint-disable-next-line typescript/require-await -- async keeps a load-time config rejection a rejection, not a synchronous throw
export async function apply(ctx: Context, config: Config): Promise<void> {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
@@ -147,16 +129,16 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes)
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
assertPositiveInteger('graceMs', resolved.graceMs)
assertPositiveInteger('stderrMaxBytes', resolved.stderrMaxBytes)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
if (!await ripgrepAvailable(ctx)) {
ctx.logger.warn('tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered')
return
}
applyGlobTool(ctx, {
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
maxResults: resolved.globMaxResults,
maxMetaBytes: resolved.searchMetaMaxBytes,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
graceMs: resolved.graceMs,
stderrMaxBytes: resolved.stderrMaxBytes,
timeoutMs: resolved.timeoutMs,
})
applyGrepTool(ctx, {
@@ -164,6 +146,8 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
maxLineBytes: resolved.grepMaxLineBytes,
maxMetaBytes: resolved.searchMetaMaxBytes,
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
graceMs: resolved.graceMs,
stderrMaxBytes: resolved.stderrMaxBytes,
timeoutMs: resolved.timeoutMs,
})
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Minimal type surface for the `@vscode/ripgrep` package: an ESM module that
* resolves the platform ripgrep binary (`@vscode/ripgrep-<platform>-<arch>`
* optional dependency) and exports its absolute path as the named export
* `rgPath` (no bundled type declarations).
* @module @deepseek-ai/dsh-tool-fs-search/ripgrep-types
*/
declare module '@vscode/ripgrep' {
/** Absolute path to the packaged ripgrep executable for the current platform. */
export const rgPath: string
}
+151 -83
View File
@@ -1,16 +1,19 @@
/**
* Shared execution plumbing for the `glob` / `grep` search tools: the
* package-owned `SEARCH_*` error vocabulary, one bash-seam run helper that
* turns a fixed `rg` command into complete raw stdout, the best-effort
* formatted-result spill handoff, and workdir-relative path display.
* package-owned `SEARCH_*` error vocabulary, one spawn helper that runs the
* PACKAGED ripgrep binary (`@vscode/ripgrep`) with a plain argv vector and
* returns complete raw stdout, the best-effort formatted-result spill handoff,
* and workdir-relative path display.
*
* Both tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`
* as ordinary foreground tool calls — never `ctx.bash.start()`, never a
* model-visible background task. Raw `rg` stdout is an internal transport
* detail: the tools request a per-run stdout capture budget from the bash seam,
* parse only complete in-memory stdout within `rawOutputMaxBytes`, and never
* read executor spill files. The model-facing recovery artifact is the
* formatted result saved through `ctx.spillStore.saveText()`
* Both tools execute as ordinary foreground spawns through `ctx.subprocess` —
* never `ctx.bash`, never `ctx.bash.start()`, never a model-visible background
* task. The ripgrep binary ships inside the npm package, so no system `rg`
* install is required, and no shell layer exists between the argv vector and
* ripgrep, so no shell quoting is involved. Raw `rg` stdout is an internal
* transport detail: the tools request a per-run stdout capture budget from the
* subprocess seam, parse only complete in-memory stdout within
* `rawOutputMaxBytes`, and never read spill files. The model-facing recovery
* artifact is the formatted result saved through `ctx.spillStore.saveText()`
* ({@link trySaveFormattedResult}).
*
* @module @deepseek-ai/dsh-tool-fs-search/search-core
@@ -21,7 +24,7 @@ import type { Context } from 'cordis'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -38,6 +41,16 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000
*/
export const SEARCH_TIMEOUT_MS = 30_000
/**
* Default cap in bytes on the retained stderr tail of one search run — a
* diagnostic excerpt only (the tool never reads a stderr spill path, and the
* collect disposition requests none).
*/
export const SEARCH_STDERR_MAX_BYTES = 64 * 1024
/** Default terminate grace period for a search process (ms). */
export const SEARCH_GRACE_MS = 3_000
/**
* Default cap in bytes on one search's serialized `presentationMeta` (the
* `searchMetaMaxBytes` config). The inline match/path caps already bound the item
@@ -52,14 +65,14 @@ export const SEARCH_META_MAX_BYTES = 65_536
/**
* Stable, machine-routable codes for search failures. Package-owned (not
* `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs`
* `FsErrorCode`) because these tools are spawn-backed discovery, not `ctx.fs`
* provider operations: `SEARCH_INVALID_PATTERN` — ripgrep rejected the regex or
* glob; `SEARCH_FAILED` — the search could not run or its output could not be
* parsed (missing `rg`, inaccessible target, signal kill, malformed `--json`);
* `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded `rawOutputMaxBytes`
* or stayed truncated after that requested stdout budget; `SEARCH_ABORTED` — the tool
* timeout, caller cancellation, or the bash executor's own timeout cut the
* search short.
* parsed (a failed `rg` launch, inaccessible target, signal kill, malformed
* `--json`); `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded
* `rawOutputMaxBytes` or stayed truncated after that requested stdout budget;
* `SEARCH_ABORTED` — the cooperative tool timeout or caller cancellation cut
* the search short.
*/
export type SearchErrorCode =
| 'SEARCH_INVALID_PATTERN'
@@ -84,7 +97,7 @@ export class SearchError extends HarnessError {
/** The completed acquisition of one `rg` run: complete stdout plus the resolved workdir. */
export interface RipgrepRun {
/** Complete raw stdout retained by the bash executor within the requested cap. */
/** Complete raw stdout retained by the subprocess seam within the requested cap. */
stdout: string
/** True when ripgrep exited 1: a successful search with zero results. */
noMatches: boolean
@@ -94,128 +107,183 @@ export interface RipgrepRun {
/**
* The retained stderr tail as a diagnostic excerpt, with a truncation note when
* the executor dropped bytes (the tool never reads `stderr.spillPath`).
* the subprocess seam dropped bytes.
*/
function stderrExcerpt(stderr: CollectedOutput): string {
const text = stderr.text.trim()
function stderrExcerpt(stderrText: string, truncated: boolean): string {
const text = stderrText.trim()
if (text.length === 0) return ''
return stderr.truncated ? `${text} [stderr truncated]` : text
return truncated ? `${text} [stderr truncated]` : text
}
/** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */
function classifyRunFailure(toolName: string, result: BashRunResult): SearchError {
const stderr = stderrExcerpt(result.stderr)
/**
* Classify a nonzero-exit `rg` run into the search error vocabulary. There is
* no shell layer, so an exit 127 or shell "command not found" text cannot
* occur — a launch failure rejects at spawn (see {@link runRipgrep}).
*/
function classifyRunFailure(toolName: string, exitCode: number, stderrText: string, stderrTruncated: boolean): SearchError {
const stderr = stderrExcerpt(stderrText, stderrTruncated)
if (/regex parse error|error parsing glob/i.test(stderr)) {
return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN')
}
if (result.exitCode === 127 || /command not found/i.test(stderr)) {
return new SearchError(`${toolName} requires ripgrep (rg) on the bash executor's PATH${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
}
return new SearchError(`${toolName} search failed (exit ${result.exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
return new SearchError(`${toolName} search failed (exit ${exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
}
/**
* Acquire the COMPLETE raw stdout of a finished run, enforcing
* `rawOutputMaxBytes` on the in-memory transport. A truncated result means the
* bash backend could not retain complete stdout within the requested budget, so
* the tool fails clearly instead of parsing a silently-partial stream.
* subprocess seam could not retain complete stdout within the requested
* budget, so the tool fails clearly instead of parsing a silently-partial
* stream.
*/
function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): string {
function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutputMaxBytes: number): string {
const narrow = 'narrow pattern, path, or include and retry'
if (!result.stdout.truncated) {
const inlineBytes = Buffer.byteLength(result.stdout.text, 'utf8')
if (!stdout.lossy) {
const inlineBytes = Buffer.byteLength(stdout.text, 'utf8')
if (inlineBytes > rawOutputMaxBytes) {
throw new SearchError(
`${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
'SEARCH_RAW_OUTPUT_OVERFLOW',
)
}
return result.stdout.text
return stdout.text
}
throw new SearchError(
`${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
`${toolName} produced more raw output than the subprocess seam retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
'SEARCH_RAW_OUTPUT_OVERFLOW',
)
}
let rgPathPromise: Promise<string> | undefined
/**
* Run one fixed `rg` command through the bash seam and return its complete raw
* stdout. The bash request workdir is the calling agent's session cwd
* (`exec.agent.session.header.cwd`) when available — mirroring `dsh-tool-bash` /
* `dsh-tool-fs` — else omitted so the implementation's `resolve()` applies its
* configured default. `exec.signal` is forwarded so the cooperative tool
* timeout (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation kill the
* command; the bash backend's own timeout stays a second safety cap.
* The packaged ripgrep binary path, resolved lazily once per process.
*
* `@vscode/ripgrep` resolves its platform package (`@vscode/ripgrep-<platform>
* -<arch>`) at module evaluation, so a static import would turn a missing or
* corrupt platform package (`pnpm install --omit=optional`, partial install)
* into a failure of the whole Loader composition. Resolving at the call
* boundary keeps that failure at the first search call as `SEARCH_FAILED` —
* the package's documented no-load-time-probe contract.
*
* @returns the packaged binary's absolute path; the memoized promise rejects
* when the platform package cannot be resolved.
*/
export function resolveRgPath(): Promise<string> {
rgPathPromise ??= import('@vscode/ripgrep').then(module => module.rgPath)
return rgPathPromise
}
/**
* Run the packaged ripgrep binary with a plain argv vector and return its
* complete raw stdout. The working directory is the calling agent's session
* cwd (`exec.agent.session.header.cwd`) when available, else
* `process.cwd()`. `exec.signal` is forwarded so the cooperative tool timeout
* (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation terminate the
* process tree.
*
* The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config`
* is prepended: a host `RIPGREP_CONFIG_PATH` (or `rg.conf` next to the
* binary) can otherwise inject `--pre` and make ripgrep execute an arbitrary
* preprocessor for every matched file. The collect dispositions are the
* seam's diagnostic-tail shape (no spill files): the tools never read a raw
* spill path, and truncated stdout fails as `SEARCH_RAW_OUTPUT_OVERFLOW`.
*
* Exit semantics are tool-owned: exit 0 is success with results, exit 1 is
* success with zero results (`noMatches`), anything else throws a
* {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern →
* `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` /
* `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's
* infrastructure failures (pre-aborted signal, unusable workdir, missing
* shell) — is translated into the same taxonomy: a pre-aborted signal becomes
* `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as
* `cause`.
* `SEARCH_RAW_OUTPUT_OVERFLOW`). Both launch-time failure domains are
* classified: a synchronous throw at spawn CREATION (a NUL in argv, an abort
* racing the pre-check, a rejected `@vscode/ripgrep` resolution) and a
* rejection of `handle.done` (the seam's infrastructure failures) both become
* `SEARCH_FAILED` with the original as `cause` — an abort already observed by
* creation time becomes `SEARCH_ABORTED` instead.
*
* @param ctx - the plugin context; execution uses its `bash` service.
* @param ctx - the plugin context; execution uses its `subprocess` service.
* @param exec - the tool-execution context; supplies the session cwd and the abort signal.
* @param toolName - `glob` or `grep`, used in error messages.
* @param command - the fully-quoted `rg` command string (every model value already through `singleQuote`).
* @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists).
* @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse.
* @param graceMs - the seam's terminate-escalation grace period.
* @param stderrMaxBytes - cap on the retained stderr diagnostic tail.
* @returns the complete stdout, the zero-result flag, and the resolved workdir.
*/
export async function runRipgrep(
ctx: Context,
exec: ToolExecution,
toolName: string,
command: string,
argv: readonly string[],
rawOutputMaxBytes: number,
graceMs: number,
stderrMaxBytes: number,
): Promise<RipgrepRun> {
const cwd = exec.agent?.session.header.cwd
const spec = ctx.bash.resolve({
command,
stdoutMaxBytes: rawOutputMaxBytes,
...cwd !== undefined ? { workdir: cwd } : {},
signal: exec.signal,
})
let result: BashRunResult
try {
result = await ctx.bash.run(spec)
} catch (error: unknown) {
// The seam contract: run() REJECTS only for infrastructure failures — a
// pre-aborted signal, an unusable workdir, a missing shell. Translate them
// so these failures stay machine-routable under the SEARCH_* taxonomy.
if (spec.signal?.aborted === true) {
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error })
}
throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error })
}
if (result.aborted) {
if (exec.signal.aborted) {
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
}
if (result.timedOut) {
throw new SearchError(`${toolName} timed out after ${result.timeoutMs}ms in the bash executor; narrow pattern, path, or include and retry`, 'SEARCH_ABORTED')
const cwd = exec.agent?.session.header.cwd
const workdir = cwd ?? process.cwd()
let handle: SubprocessHandle
try {
handle = ctx.subprocess.spawn({
argv: [await resolveRgPath(), '--no-config', ...argv],
cwd: workdir,
stdio: {
stdin: 'ignore',
stdout: { maxBytes: rawOutputMaxBytes },
stderr: { maxBytes: stderrMaxBytes },
},
graceMs,
signal: exec.signal,
} satisfies SubprocessSpawnSpec)
} catch (error: unknown) {
// Node's spawn() throws synchronously for a NUL in argv, and the local
// impl can throw synchronously when the signal aborts between the check
// above and this call (or when the platform-package resolution rejects).
// The static narrowing that proves this re-check "always false" cannot
// see AbortSignal state changes.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (exec.signal.aborted) {
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
}
throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error })
}
if (result.signal !== null || result.exitCode === null) {
throw new SearchError(`${toolName} search command was killed by signal ${result.signal ?? '(unknown)'}`, 'SEARCH_FAILED')
let outcome: SubprocessOutcome
try {
outcome = await handle.done
} catch (error: unknown) {
throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error })
}
if (result.exitCode !== 0 && result.exitCode !== 1) {
throw classifyRunFailure(toolName, result)
const stdout = handle.collected.stdout?.readFrom(0)
const stderr = handle.collected.stderr?.readFrom(0)
if (stdout === undefined || stderr === undefined) {
throw new SearchError(`${toolName} search command produced no collected output streams`, 'SEARCH_FAILED')
}
const stdout = completeStdout(toolName, result, rawOutputMaxBytes)
return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir }
// The signal can abort while the spawn is awaited; the static narrowing that
// proves this re-check "always false" cannot see AbortSignal state changes.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (exec.signal.aborted) {
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
}
if (outcome.signal !== null || outcome.exitCode === null) {
throw new SearchError(`${toolName} search command was killed by signal ${outcome.signal ?? '(unknown)'}`, 'SEARCH_FAILED')
}
if (outcome.exitCode !== 0 && outcome.exitCode !== 1) {
throw classifyRunFailure(toolName, outcome.exitCode, stderr.text, stderr.lossy)
}
const text = completeStdout(toolName, stdout, rawOutputMaxBytes)
return { stdout: text, noMatches: outcome.exitCode === 1, workdir }
}
/**
* Map an `rg` output path to its display form: absolute paths inside the
* resolved bash workdir become workdir-relative; everything else (relative
* output, paths outside the workdir) passes through unchanged. Display-only —
* returned paths are follow-up-readable in co-located bash/filesystem
* resolved workdir become workdir-relative; everything else (relative output,
* paths outside the workdir) passes through unchanged. Display-only —
* returned paths are follow-up-readable in co-located workdir/filesystem
* deployments where both resolve the same workspace (the documented v1
* deployment requirement).
*
* @param path - one path as ripgrep printed it.
* @param workdir - the resolved bash workdir the command ran in.
* @param workdir - the resolved workdir the command ran in.
* @returns the workdir-relative display path when possible, else `path` unchanged.
*/
export function toWorkdirRelative(path: string, workdir: string): string {
@@ -1,27 +0,0 @@
/**
* The one shell-quoting helper both search tools MUST route every
* model-controlled value through before it enters an `rg` command string. The
* bash seam (`ctx.bash`) accepts a command STRING, not an argv vector, so this
* is the safety boundary that stops a `pattern`, `path`, or `include` from
* breaking out of its argument and injecting shell syntax.
*
* Command builders in `glob.ts` / `grep.ts` must never hand-roll quoting or
* concatenate an unquoted model value — they call {@link singleQuote}.
*
* @module @deepseek-ai/dsh-tool-fs-search/shell-quote
*/
/**
* POSIX single-quote a string for safe use as ONE shell word. Wraps the value
* in single quotes and rewrites every embedded single quote as `'\''` (close
* quote, an escaped literal quote, reopen quote). Inside single quotes the shell
* treats every other byte literally — spaces, newlines, `$`, backticks, `;`,
* `|`, `&`, glob metacharacters, and a leading `-` are all inert — so the result
* is a single, injection-safe argument regardless of the input.
*
* @param value - the raw, possibly model-controlled string to quote.
* @returns the value wrapped as one safe single-quoted shell word.
*/
export function singleQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
@@ -1,15 +1,16 @@
/**
* Integration tests: the REAL local bash executor (`dsh-bash-local`) plus a
* REAL ripgrep binary, exercised through `ctx.tools.execute()`. These verify
* the WORLD — actual files on disk are discovered and grepped, hostile
* patterns stay inert in a real shell, and real `rg` stderr classifies into
* the `SEARCH_*` vocabulary. The whole suite self-skips when `rg` is not on
* PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor
* suite (tools.spec.ts) carries the coverage gate.
* Integration tests: the REAL local subprocess service plus the PACKAGED
* ripgrep binary (`@vscode/ripgrep`), exercised through `ctx.tools.execute()`.
* These verify the WORLD — actual files on disk are discovered and grepped,
* hostile patterns stay inert (they are plain argv elements; there is no
* shell layer to escape), and real `rg` stderr classifies into the
* `SEARCH_*` vocabulary. The binary ships inside the npm dependency, so the
* suite runs on every platform without a system `rg` install; the
* fake-service suite (tools.spec.ts) carries the coverage gate.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -17,14 +18,11 @@ import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
const testToolSignal = new AbortController().signal
const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0
let dir: string
let ctx: Context
@@ -43,7 +41,10 @@ function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
}
describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () => {
/** The fixture workspace as a session cwd, so relative paths resolve inside `dir`. */
const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } })
describe('search tools over the real subprocess service + the packaged rg', () => {
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-'))
await mkdir(join(dir, 'src'), { recursive: true })
@@ -54,7 +55,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
await writeFile(join(dir, 'notes.md'), 'alpha appears here too\n')
await writeFile(join(dir, '.hidden.ts'), 'export const hidden = 3\n')
await writeFile(join(dir, '.git', 'config.ts'), 'never listed\n')
await writeFile(join(dir, 'spaced dir', "wei'rd \"name\".ts"), 'const inside = true\n')
await writeFile(join(dir, 'spaced dir', "wei'rd name.ts"), 'const inside = true\n')
// Deterministic --sort=modified order: alpha oldest, beta newest.
await utimes(join(dir, 'src', 'alpha.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1))
await utimes(join(dir, 'src', 'beta.ts'), new Date(2020, 0, 1), new Date(2020, 0, 1))
@@ -63,7 +64,6 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
})
@@ -73,33 +73,33 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
describe('glob', () => {
it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => {
const result = await call('glob', { pattern: '**/*.ts' })
const result = await call('glob', { pattern: '**/*.ts' }, agent())
expect(result.isError).toBe(false)
const paths = text(result).split('\n')
expect(paths.indexOf('src/alpha.ts')).toBeLessThan(paths.indexOf('src/beta.ts'))
expect(paths.indexOf(join('src', 'alpha.ts'))).toBeLessThan(paths.indexOf(join('src', 'beta.ts')))
expect(paths).toContain('.hidden.ts')
expect(paths).toContain("spaced dir/wei'rd \"name\".ts")
expect(paths).not.toContain('.git/config.ts')
expect(paths).toContain(join('spaced dir', "wei'rd name.ts"))
expect(paths).not.toContain(join('.git', 'config.ts'))
expect(paths).not.toContain('notes.md')
})
it('scopes to a directory search root (path arg)', async () => {
const result = await call('glob', { pattern: '*.ts', path: 'src' })
expect(text(result).split('\n').sort()).toEqual(['src/alpha.ts', 'src/beta.ts'])
const result = await call('glob', { pattern: '*.ts', path: 'src' }, agent())
expect(text(result).split('\n').sort()).toEqual([join('src', 'alpha.ts'), join('src', 'beta.ts')])
})
it('reports zero discoveries as No files found', async () => {
expect(text(await call('glob', { pattern: '*.nomatch' }))).toBe('No files found')
expect(text(await call('glob', { pattern: '*.nomatch' }, agent()))).toBe('No files found')
})
it('excludes VCS internals even when the search root IS the VCS directory', async () => {
// The prune glob alone never matches root-prefixed paths when rg is
// rooted at .git; the paired contents glob keeps the exclusion airtight.
expect(text(await call('glob', { pattern: '*', path: '.git' }))).toBe('No files found')
expect(text(await call('glob', { pattern: '*', path: '.git' }, agent()))).toBe('No files found')
})
it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => {
const result = await call('glob', { pattern: '[' })
const result = await call('glob', { pattern: '[' }, agent())
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } })
})
@@ -107,37 +107,42 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
describe('grep', () => {
it('greps a directory tree with grouped, line-numbered output', async () => {
const result = await call('grep', { pattern: 'alpha' })
const result = await call('grep', { pattern: 'alpha' }, agent())
expect(result.isError).toBe(false)
const output = text(result)
expect(output).toContain('Found 3 matches')
expect(output).toContain('src/alpha.ts\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha')
expect(output).toContain(`${join('src', 'alpha.ts')}\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha`)
expect(output).toContain('notes.md\nLine 1: alpha appears here too')
})
it('greps a single FILE target', async () => {
const result = await call('grep', { pattern: 'alpha', path: 'notes.md' })
const result = await call('grep', { pattern: 'alpha', path: 'notes.md' }, agent())
expect(text(result)).toBe('Found 1 match\n\nnotes.md\nLine 1: alpha appears here too')
})
it('greps a directory target with an include filter', async () => {
const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' })
const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' }, agent())
const output = text(result)
expect(output).toContain('alpha.ts')
expect(output).not.toContain('notes.md')
})
it('a hostile pattern stays inert (no command substitution, the world untouched)', async () => {
it('a hostile pattern stays inert (a plain argv element, the world untouched)', async () => {
// There is no shell layer between the argv vector and rg, so the pattern
// is a literal regex — but the world-untouched guarantee is the shipped
// contract, and a future shell-wrapping change must not reintroduce it.
// The canary name carries no path so the regex stays valid on every
// platform (a Windows path's backslashes would be regex escapes).
const canary = join(dir, 'pwned')
const result = await call('grep', { pattern: `$(touch ${canary})` })
const result = await call('grep', { pattern: '$(touch pwned)' }, agent())
expect(result.isError).toBe(false) // exit 1: found nothing, executed nothing
expect(text(result)).toBe('No matches found')
expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
expect(existsSync(canary)).toBe(false)
})
it('a leading-dash pattern is a pattern, not a flag', async () => {
await writeFile(join(dir, 'dashes.txt'), 'value --flag value\n')
const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' })
const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' }, agent())
expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value')
})
@@ -155,7 +160,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
})
describe('per-session cwd', () => {
it('resolves the search in the SESSION workspace, not the executor config cwd', async () => {
it('resolves the search in the SESSION workspace, not the process cwd', async () => {
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-'))
try {
await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n')
@@ -170,7 +175,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
})
})
describe('pre-dispatch cancellation and bash-start failures', () => {
describe('pre-dispatch cancellation and spawn failures', () => {
it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => {
const controller = new AbortController()
controller.abort()
@@ -3,14 +3,15 @@
* a NAMESPACE plugin with `inject` — so a stray `export default apply` would
* make the cordis Loader's `unwrapExports` (`exports.default ?? exports`)
* collapse the module to the bare `apply` function, DROPPING `inject`. The
* plugin would then read `ctx.bash` without having injected it and throw
* plugin would then read `ctx.subprocess` without having injected it and throw
* `cannot get property … without inject` the moment it loads (postmortem 0001).
*
* A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it
* bypasses `unwrapExports`. So this test unwraps the module through the REAL
* `Loader.prototype.unwrapExports` and mounts the result over a bash executor,
* exercising the exact path the Loader uses. Prove the guard bites: add
* `export default apply` to `src/index.ts`, watch this go red, revert.
* `Loader.prototype.unwrapExports` and mounts the result over the real local
* subprocess service, exercising the exact path the Loader uses. Prove the
* guard bites: add `export default apply` to `src/index.ts`, watch this go
* red, revert.
*/
import { describe, expect, it } from 'vitest'
@@ -18,48 +19,9 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
/**
* Deterministic bash service for this Loader guard: the test wants to exercise
* the real unwrap/inject path, not depend on whether the host image has rg.
*/
class ProbeSuccessBashExecutor extends BashExecutor {
override resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxPolicy: request.sandboxPolicy,
}
}
override run(spec: BashExecSpec): Promise<BashRunResult> {
if (spec.command !== RG_PROBE_COMMAND) {
throw new Error(`unexpected command in load-path guard: ${spec.command}`)
}
return Promise.resolve({
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: spec.timeoutMs,
stdout: { text: '', truncated: false },
stderr: { text: '', truncated: false },
})
}
override start(): BashProcess {
throw new Error('load-path guard must not start background processes')
}
}
describe('dsh-tool-fs-search real-load-path guard', () => {
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
expect('default' in toolFsSearch).toBe(false)
@@ -68,16 +30,16 @@ describe('dsh-tool-fs-search real-load-path guard', () => {
const unwrapped = loader.unwrapExports(toolFsSearch) as Record<string, unknown>
expect(unwrapped).toBe(toolFsSearch)
expect(unwrapped.name).toBe('tool-fs-search')
expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'bash'])
expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'subprocess'])
expect(typeof unwrapped.Config).toBe('function')
expect(typeof unwrapped.apply).toBe('function')
})
it('boots over ctx.bash through the unwrapped module without an inject error', async () => {
it('boots over ctx.subprocess through the unwrapped module without an inject error', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ProbeSuccessBashExecutor)
await ctx.plugin(LocalSubprocessService)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
@@ -0,0 +1,37 @@
/**
* Failure-path tests for the lazy packaged-ripgrep resolution. The success
* path (the real `@vscode/ripgrep` module) is exercised throughout
* tools.spec.ts; here the module is mocked to throw at evaluation, proving a
* missing or corrupt platform package (`--omit=optional`, partial install)
* surfaces as a per-call `SEARCH_FAILED` — not a composition-load failure.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { resolveRgPath, runRipgrep } from '@deepseek-ai/dsh-tool-fs-search'
// Any access to the mocked module's surface throws — the shape a missing
// platform package produces at module evaluation.
vi.mock('@vscode/ripgrep', () => new Proxy({}, {
get() {
throw new Error('platform package @vscode/ripgrep-win32-x64 is not installed')
},
}))
describe('lazy packaged-ripgrep resolution', () => {
it('fails the first search call with SEARCH_FAILED instead of failing module load', async () => {
// The resolution rejects before any spawn, so no subprocess service is needed.
const controller = new AbortController()
const exec = { signal: controller.signal, name: 'glob', callId: CallId('missing-platform-package') } as unknown as ToolExecution
await expect(runRipgrep(new Context(), exec, 'glob', ['--files'], 1_000_000, 3_000, 64 * 1024))
.rejects.toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
})
it('keeps failing every subsequent call (the resolution is memoized)', async () => {
await expect(resolveRgPath()).rejects.toThrow(/platform package/)
await expect(resolveRgPath()).rejects.toThrow(/platform package/)
})
})
@@ -1,59 +0,0 @@
/**
* Unit tests for the shell-quoting safety boundary, plus a REAL round-trip:
* every adversarial value, quoted, must survive `bash -c "printf '%s' <quoted>"`
* byte-for-byte — proving the quoting is inert in an actual shell, not just
* against a mental model of one.
*/
import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { singleQuote } from '@deepseek-ai/dsh-tool-fs-search'
/** Adversarial values a model could pass as pattern / path / include. */
const HOSTILE: readonly string[] = [
'plain',
'with spaces',
"it's got 'quotes'",
'"double quoted"',
'$(rm -rf /tmp/nope)',
'`touch /tmp/nope`',
'$HOME and ${PATH}',
'semi;colon && chain || pipe | bg &',
'newline\nin the middle',
'-leading-dash',
'--leading-double-dash',
'*?[a-z]{x,y}',
'!bang',
'\\backslash\\',
'~tilde',
'# not a comment',
'>redirect <input 2>&1',
]
describe('singleQuote', () => {
it('wraps a plain value in single quotes', () => {
expect(singleQuote('abc')).toBe("'abc'")
})
it("rewrites embedded single quotes as '\\''", () => {
expect(singleQuote("a'b")).toBe("'a'\\''b'")
expect(singleQuote("''")).toBe("''\\'''\\'''")
})
it.each(HOSTILE.map(value => [JSON.stringify(value), value] as const))(
'round-trips %s through a real bash -c unchanged',
(_label, value) => {
const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(value)}`], { encoding: 'utf8' })
expect(result.status).toBe(0)
expect(result.stdout).toBe(value)
},
)
it('a quoted command substitution does not execute (the world stays untouched)', () => {
const canary = `/tmp/dsh-quote-canary-${process.pid}`
const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(`$(touch ${canary})`)}`], { encoding: 'utf8' })
expect(result.stdout).toBe(`$(touch ${canary})`)
// The canary file must NOT exist — the substitution stayed literal.
expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
})
})
File diff suppressed because it is too large Load Diff
@@ -244,8 +244,10 @@ describe('tool-str-replace-editor', () => {
expect(listing).not.toContain('too-deep.txt')
expect(listing).not.toContain('index.js')
expect(listing).not.toContain('module.pyc')
expect(listing).toContain('node_modules_old/kept.js')
expect(listing).toContain('__pycache__backup/kept.py')
// The listing carries absolute display paths; the POSIX-style substrings
// only match on Linux, so assert with platform separators.
expect(listing).toContain(join('node_modules_old', 'kept.js'))
expect(listing).toContain(join('__pycache__backup', 'kept.py'))
const clipped = await setup({ maxOutputChars: 10 })
await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100))
@@ -22,7 +22,7 @@ import {
encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, toHeaderLine,
type JsonlCompression,
} from './format.ts'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts'
import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from './zstd.ts'
import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
export type { JsonlCompression } from './format.ts'
@@ -232,7 +232,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
let recoveredPlaintext: Buffer = Buffer.alloc(0)
try {
signal?.throwIfAborted()
recoveredPlaintext = await decompressZstdFrame(buffer.subarray(tornStart))
recoveredPlaintext = await decompressZstdPrefix(buffer.subarray(tornStart))
} catch {
/* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
if (signal?.aborted) signal.throwIfAborted()
@@ -14,6 +14,9 @@ const zstdDecompressAsync = promisify(zstdDecompress)
const CHECKSUM_OPTIONS: ZstdOptions = {
params: { [constants.ZSTD_c_checksumFlag]: 1 },
}
const INCOMPLETE_FRAME_OPTIONS: ZstdOptions = {
finishFlush: constants.ZSTD_e_flush,
}
/** Byte range occupied by one structurally complete Zstandard frame. */
export interface ZstdFrameRange {
@@ -106,11 +109,21 @@ export async function compressZstdFrame(input: Buffer | string): Promise<Buffer>
}
/**
* Decompress one complete frame or the available prefix of a torn final frame.
* Complete-frame checksums are validated by Node's decoder.
* @param input - bytes beginning at a Zstandard frame boundary.
* @returns plaintext produced from the available input.
* Decompress one complete frame and validate its checksum.
* @param input - one structurally complete Zstandard frame.
* @returns the frame plaintext.
*/
export async function decompressZstdFrame(input: Buffer): Promise<Buffer> {
return zstdDecompressAsync(input)
}
/**
* Recover available plaintext from a structurally incomplete final frame.
* `ZSTD_e_flush` deliberately suppresses final-frame and checksum completion;
* callers must establish the torn frame boundary before using this helper.
* @param input - available bytes from a known incomplete Zstandard frame.
* @returns plaintext produced from the available input.
*/
export async function decompressZstdPrefix(input: Buffer): Promise<Buffer> {
return zstdDecompressAsync(input, INCOMPLETE_FRAME_OPTIONS)
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from '../src/zstd.ts'
describe('JSONL Zstandard compatibility', () => {
it('round-trips concatenated checksummed frames through the built-in Node API', async () => {
@@ -19,6 +19,6 @@ describe('JSONL Zstandard compatibility', () => {
const eventFrame = encoded.subarray(frames[1]!.start, frames[1]!.end)
const missingChecksumByte = eventFrame.subarray(0, -1)
expect(scanZstdFrames(missingChecksumByte)).toEqual({ frames: [], tornStart: 0 })
expect((await decompressZstdFrame(missingChecksumByte)).toString()).toContain('"type":"turn/start"')
expect((await decompressZstdPrefix(missingChecksumByte)).toString()).toContain('"type":"turn/start"')
})
})
@@ -8,7 +8,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
import { compressZstdFrame, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames } from '../src/zstd.ts'
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
@@ -69,7 +69,7 @@ async function tornFrame(
const candidate = frame.subarray(0, end)
if (scanZstdFrames(candidate).tornStart !== 0) continue
try {
const decoded = (await decompressZstdFrame(candidate)).toString('utf8')
const decoded = (await decompressZstdPrefix(candidate)).toString('utf8')
if (accepts(decoded)) return candidate
} catch {
// Some early cuts precede the first decodable block; keep searching for
+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/ui/tui/README.md
README.md: 5199e75d732f384497b28518920cc2c775c600c2
README.zh.md: 02d4431fdab575b5596152c985b72594a948b081
README.md: 78d56a6cacd040fdd32b73f779bab3fb4c77fcce
README.zh.md: c403605bb13d252eec00a2b0ebafb5f953c884f8
+3 -3
View File
@@ -32,11 +32,11 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
`/resume` opens a full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
`/resume` opens a full-viewport keyboard selector instead of a centered dialog. The selector opens as soon as the command runs and takes input focus while the session scan is still pending, showing a loading placeholder until the rows arrive; Escape cancels an in-flight scan the same way it cancels the loaded list. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Rows read no whole logs: when the optional projection cache is mounted, titles come from the live projection registry or the durable checkpoint row, with a cold read folding only the log tail since the checkpoint (written back so the next scan is zero-I/O, bounded by `resumeScanConcurrency`); a composition without the cache falls back to one bounded batch title read over the logs. Candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
Selection repeats those checks, fully reads and replay-validates the one chosen log, rejects it when its logged provider has no current adapter, and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
+3 -3
View File
@@ -32,11 +32,11 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。选择器在命令执行时立即打开并接管输入焦点,会话扫描仍在进行时显示加载占位符,直到行数据就绪;Escape 取消进行中的扫描,方式与取消已加载列表相同。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。行数据不读取任何完整日志:挂载可选的投影缓存时,标题来自实时投影注册表或持久化 checkpoint 行,冷读取只折叠 checkpoint 之后的日志尾部(并写回,使下次扫描零 I/O,受 `resumeScanConcurrency` 约束);未挂载缓存的组合回退到一次对日志的有界批量标题读取。候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime,再回退到创建时间——可按标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`
选择时会重复这些检查,完整读取并回放验证所选中的那一份日志,在其日志所记提供方没有当前适配器时拒绝,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
+4
View File
@@ -42,6 +42,8 @@
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
@@ -82,6 +84,8 @@
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
+208 -72
View File
@@ -1,16 +1,20 @@
/**
* Session-resume sub-controller for the interactive chat channel: the
* `/resume` selector, per-candidate summary reads that tolerate a corrupt
* `/resume` selector, one metadata-plus-title scan that tolerates a corrupt
* neighbor, the pre-handoff preflight, and the terminal handoff itself.
* @module @deepseek-ai/dsh-tui/chat/resume
*/
import { stat } from 'node:fs/promises'
import type { TUI } from '@earendil-works/pi-tui'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type {} from '@deepseek-ai/dsh-session-projection'
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
import type {} from '@deepseek-ai/dsh-session-title'
import type {
SessionLogSnapshot,
SessionQueryService,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
@@ -66,50 +70,137 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
const workspaceLabel = (cwd: string | undefined): string =>
runtime.formatCwd?.(cwd) ?? formatCwd(cwd)
/** Build one display candidate without letting a corrupt neighbor abort the selector. */
const readResumeCandidate = async (
/** Summarize one record from metadata and its batch-folded title. */
const summarize = (
record: SessionRecord,
providers: ReadonlySet<string>,
): Promise<ResumeCandidate> => {
title: string | undefined,
lastActivityAt: number | undefined,
): ResumeCandidate => summarizeResumeCandidate(
record,
title,
lastActivityAt,
agent.session.id,
agent.session.header.cwd,
workspaceLabel,
)
/** The disabled fallback row for a session whose title read failed. */
const unreadableCandidate = (
record: SessionRecord,
lastActivityAt: number | undefined,
error: unknown,
): ResumeCandidate => ({
record,
title: 'Unreadable session',
lastActivityAt: lastActivityAt ?? record.header.createdAt,
currentWorkspace: record.header.cwd === agent.session.header.cwd,
workspaceLabel: workspaceLabel(record.header.cwd),
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
})
/**
* Metadata-only activity time: a live session's last in-memory event time,
* otherwise the persisted artifact's mtime. Never reads a log, so browsing
* cost stays independent of log size; any append (including bookkeeping)
* moves it.
*/
const lastActivityAt = async (record: SessionRecord): Promise<number | undefined> => {
const live = ctx.sessions.get(record.header.id)
if (live !== undefined) return live.events.at(-1)?.time
const location = ctx.get('sessionPersistence')?.locate(record.header)
if (location === undefined) return undefined
try {
let snapshot: SessionLogSnapshot
const live = ctx.sessions.get(record.header.id)
if (live !== undefined) {
snapshot = {
session: structuredClone(live.header),
events: live.events.map(event => structuredClone(event)),
}
} else {
const readQuery = sessionQuery()
/* v8 ignore start -- caller proves the optional service before mapping records */
if (readQuery === undefined) throw new Error('session query is unavailable')
/* v8 ignore stop */
snapshot = await readQuery.readSession(record.header.id)
}
return summarizeResumeCandidate(
record,
snapshot,
agent.session.id,
agent.session.header.cwd,
providers,
workspaceLabel,
)
} catch (error: unknown) {
return {
record,
title: 'Unreadable session',
lastActivityAt: record.header.createdAt,
lastTurn: 'log unavailable',
currentWorkspace: record.header.cwd === agent.session.header.cwd,
workspaceLabel: workspaceLabel(record.header.cwd),
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
}
return (await stat(location.path)).mtimeMs
} catch {
// Only a just-deleted or never-materialized artifact fails stat; the row falls back to created-at.
return undefined
}
}
/**
* One persisted row's title through the projection-cache ladder: the
* zero-I/O checkpoint row when usable, otherwise a cold read that folds
* only the log tail since the checkpoint and writes the refreshed row
* back — so a store scanned once serves later scans without log reads.
*/
const projectedTitle = async (
cache: SessionProjectionCache,
record: SessionRecord,
signal: AbortSignal,
): Promise<string | null | undefined> => {
const live = ctx.sessions.get(record.header.id)
if (live !== undefined) return ctx.get('sessionProjections')?.snapshot(live).values.title
const cached = cache.cachedSnapshot(record.header)
if (cached !== undefined && 'title' in cached.values) return cached.values.title
return (await cache.coldSnapshot(record.header.id, signal)).values.title
}
/** One per-record title resolution: a title (absent for untitled) or an isolated failure. */
type TitleResolution = { title?: string; failure?: unknown }
/**
* Resolve every row's title without reading whole logs when the projection
* cache is mounted (live registry snapshot / checkpoint row / tail-only
* cold read, bounded by `resumeScanConcurrency`); a composition without
* the cache falls back to one bounded raw-log title batch.
*/
const resolveTitles = async (
listQuery: SessionQueryService,
records: readonly SessionRecord[],
signal: AbortSignal,
): Promise<TitleResolution[]> => {
const cache = ctx.get('sessionProjectionCache')
if (cache === undefined) {
const results = await listQuery.readTitleSnapshots(records.map(record => record.header.id), signal)
return records.map((record, index): TitleResolution => {
const result = results[index]
/* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */
if (result === undefined || result.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`)
if (result.status === 'rejected') return { failure: result.reason }
const title = result.value.title?.title
return title === undefined ? {} : { title }
})
}
const resolutions = new Array<TitleResolution>(records.length)
let cursor = 0
const worker = async (): Promise<void> => {
for (;;) {
const index = cursor
if (index >= records.length) return
cursor += 1
const record = records[index] as SessionRecord
try {
const value = await projectedTitle(cache, record, signal)
resolutions[index] = typeof value === 'string' ? { title: value } : {}
} catch (failure: unknown) {
resolutions[index] = { failure }
}
}
}
await Promise.all(Array.from(
{ length: Math.min(resolved.resumeScanConcurrency, records.length) },
() => worker(),
))
return resolutions
}
/** The latest logged provider/model route, for the preflight availability check. */
const resumeRoute = (events: readonly SessionEvent[]): { provider: string; model: string } | undefined => {
const header = events.findLast(item => item.type === 'request/header')
if (header?.type === 'request/header') {
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
}
const assistant = events.findLast(item => item.type === 'assistant/message')
return assistant?.type === 'assistant/message'
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
: undefined
}
/**
* Re-read every mutable precondition immediately before terminal handoff and
* resolve the exact identity and workspace the host will re-exec into.
* resolve the exact identity and workspace the host will re-exec into. This
* is where the one chosen log is fully read, replay-validated, and checked
* for a currently-available route — the listing never does any of that.
*/
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
const query = sessionQuery()
@@ -120,17 +211,24 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
const candidate = await readResumeCandidate(
record,
new Set(ctx.llm.listProviders().map(provider => provider.id)),
)
const candidate = summarize(record, undefined, undefined)
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
const cwd = candidate.record.header.cwd
let events: readonly SessionEvent[]
try {
events = (await query.readSession(record.header.id)).events
} catch (error: unknown) {
throw new Error(`session cannot be loaded: ${errorChain(error)}`)
}
const route = resumeRoute(events)
if (route !== undefined && !ctx.llm.listProviders().some(provider => provider.id === route.provider)) {
throw new Error(`session is complete, but route is currently unavailable (${route.provider}/${route.model})`)
}
const cwd = record.header.cwd
/* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */
if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`)
const finalStatus = deps.agentStatus()
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
return { id: candidate.record.header.id, cwd }
return { id: record.header.id, cwd }
}
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
@@ -194,40 +292,78 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
}
const scan = ++resumeScan
void resumeOverlay?.close()
void listQuery.listSessions().then(async (records) => {
if (deps.isDisposed() || scan !== resumeScan) return
// Every workspace in the store is summarized; the picker owns the
// current-workspace/all-workspaces scope split over the whole set.
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
const candidates = await Promise.all(records.map(record => readResumeCandidate(record, providers)))
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|| a.record.header.id.localeCompare(b.record.header.id))
if (deps.isDisposed() || scan !== resumeScan) return
const session = overlayManager.open({
create: host => new ResumePicker(
candidates,
// The picker opens before the scan settles so the terminal stops feeding
// the editor immediately; a queued activation (the closing predecessor
// still holds the slot) receives an already-scanned set through
// `scanned` instead of a loading placeholder.
let picker: ResumePicker | undefined
let scanned: ResumeCandidate[] | undefined
const session = overlayManager.open({
create: (host) => {
picker = new ResumePicker(
scanned,
resolved.maxResumeOptions,
workspaceLabel(agent.session.header.cwd),
() => host.viewport.rows,
palette,
(candidate) => { void handoffResume(candidate, session) },
() => { void session.close() },
),
options: {
width: '100%',
maxHeight: '100%',
anchor: 'top-left',
margin: 0,
},
})
resumeOverlay = session
void session.closed.then(() => {
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
if (resumeOverlay === session) resumeOverlay = undefined
)
return picker
},
options: {
width: '100%',
maxHeight: '100%',
anchor: 'top-left',
margin: 0,
},
})
resumeOverlay = session
// Closing the picker — Escape, supersession, disposal — aborts the scan:
// the borrowed-log pass over a large store must not outlive its overlay.
const scanAbort = new AbortController()
void session.closed.then(() => {
scanAbort.abort()
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
if (resumeOverlay === session) resumeOverlay = undefined
})
deps.requestRender()
/** Whether this scan's overlay, session generation, or TUI is gone. */
const scanStale = (): boolean =>
deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted
const scanCandidates = async (): Promise<void> => {
// Every workspace in the store is listed; the picker owns the
// current-workspace/all-workspaces scope split over the whole set.
const records = await listQuery.listSessions(scanAbort.signal)
if (scanStale()) return
// Rows need only metadata, an mtime, and a title — resolved without
// whole-log reads when the projection cache is mounted. A corrupt
// neighbor degrades to one disabled row.
const [titles, activity] = await Promise.all([
resolveTitles(listQuery, records, scanAbort.signal),
Promise.all(records.map(record => lastActivityAt(record))),
])
const candidates = records.map((record, index) => {
const resolution = titles[index] as TitleResolution
return 'failure' in resolution
? unreadableCandidate(record, activity[index], resolution.failure)
: summarize(record, resolution.title, activity[index])
})
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|| a.record.header.id.localeCompare(b.record.header.id))
if (scanStale()) return
scanned = candidates
picker?.setCandidates(candidates)
deps.requestRender()
}, (error: unknown) => {
if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
}
// One catch covers listing, titles, and mtimes, so a scan failure
// cannot strand the overlay on its loading placeholder; an aborted
// scan's rejection stays silent because the user already dismissed the
// picker.
void scanCandidates().catch((error: unknown) => {
if (scanStale()) return
void session.close()
deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
})
},
}
+49 -23
View File
@@ -132,32 +132,58 @@ function timingTotalsAt(state: TimingState, at?: number): TimingTotals {
return totals
}
function stepKey(position: StepPosition): string {
return `${position.turn}:${position.step}`
}
interface TrackedStep extends TimingState {
/** Set at the step's `step/end`; later same-coordinate events no longer advance the step. */
closed: boolean
}
/**
* Replay one step's accumulated per-phase timing up to clock `at`.
* @param events - Session events to replay.
* @param position - Turn/step coordinates of the step.
* @param at - Render clock to accumulate the open bucket up to.
* @returns The step's per-phase totals.
* Incremental per-step timing accumulator shared by every step's timing footer
* in one transcript. One forward pass over the append-only session log serves
* all steps' totals: each query advances a cursor over the events appended
* since the previous query, so a transcript of S steps costs O(events) in
* total instead of the O(S × events) of replaying the whole log per footer
* ([rationale](../../../../../.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md)).
*
* The log must be append-only with stable indices (the session `seq = log
* length` contract). Event times are consumed as logged: a backward wall-clock
* step clamps each bucket at zero rather than cutting the scan off at the
* query clock. The open bucket is accumulated to the query clock at lookup,
* never during the scan.
*/
export function stepTimingAt(
events: readonly SessionEvent[],
position: StepPosition,
at: number,
): TimingTotals {
const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position))
if (startIndex < 0) return emptyTimingTotals()
const start = events[startIndex] as Extract<SessionEvent, { type: 'step/start' }>
const state = timingState(start.time)
for (let index = startIndex + 1; index < events.length; index += 1) {
const event = events[index] as SessionEvent
if (event.time > at) break
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
&& sameStep(event, position)) {
advanceStepTiming(state, event)
if (event.type === 'step/end') break
export class StepTimingTracker {
private scanned = 0
private readonly steps = new Map<string, TrackedStep>()
/**
* Advance over events appended since the previous query, then return one
* step's accumulated per-phase timing up to clock `at`.
* @param events - Current session event log (append-only).
* @param position - Turn/step coordinates of the queried step.
* @param at - Render clock to accumulate the open bucket up to.
* @returns The step's per-phase totals; empty when the step never started.
*/
totalsAt(events: readonly SessionEvent[], position: StepPosition, at: number): TimingTotals {
for (; this.scanned < events.length; this.scanned += 1) {
const event = events[this.scanned] as SessionEvent
if (event.type === 'step/start') {
const key = stepKey(event.data)
if (!this.steps.has(key)) this.steps.set(key, { ...timingState(event.time), closed: false })
} else if (event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end') {
const state = this.steps.get(stepKey(event.data))
if (state !== undefined && !state.closed) {
advanceStepTiming(state, event)
if (event.type === 'step/end') state.closed = true
}
}
}
const state = this.steps.get(stepKey(position))
return state === undefined ? emptyTimingTotals() : timingTotalsAt(state, at)
}
return timingTotalsAt(state, at)
}
/**
@@ -191,7 +217,7 @@ const COMPACTING_GLYPH = '⊙'
/**
* Derive the currently open step's active timing bucket, or `undefined` when no
* step is open. The open step is the last `step/start` with no later matching
* `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}.
* `step/end`; its bucket is replayed with the same rules as {@link StepTimingTracker}.
* @param events - Session events to scan.
* @returns The open step's active bucket, or `undefined`.
*/
+54 -80
View File
@@ -23,14 +23,8 @@ import {
type AgentLlmTarget,
} from '@deepseek-ai/dsh-agent'
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { lastActivityTime } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
SessionLogSnapshot,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
import { dialogSelectTheme, type Palette } from './theme.ts'
@@ -506,95 +500,53 @@ export class DetailsDialog implements Component {
}
}
/** The provider/model route recovered from a resume candidate's log. */
export interface ResumeRoute {
provider: string
model: string
}
/** A preflighted resume selector row summarizing one persisted session. */
/** A resume selector row summarizing one session from metadata and its folded title. */
export interface ResumeCandidate {
record: SessionRecord
title: string
/** Last observed change: live last-event time or artifact mtime, falling back to creation. */
lastActivityAt: number
lastTurn: string
/** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */
currentWorkspace: boolean
/** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */
workspaceLabel: string
route?: ResumeRoute
goalPhase?: GoalPhase
disabledReason?: string
}
function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
const event = snapshot.events.findLast(item => item.type === 'turn/end')
if (event === undefined) return 'no completed turn'
const reason = event.data.reason
switch (reason.kind) {
case 'completed': return `turn ${event.data.turn}: completed`
case 'aborted': return `turn ${event.data.turn}: cancelled`
case 'error': return `turn ${event.data.turn}: error`
case 'disposed': return `turn ${event.data.turn}: disposed`
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
case 'interrupted': return `turn ${event.data.turn}: interrupted`
default: return `turn ${event.data.turn}: unknown result`
}
}
function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
const header = snapshot.events.findLast(item => item.type === 'request/header')
if (header?.type === 'request/header') {
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
}
const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
return assistant?.type === 'assistant/message'
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
: undefined
}
/**
* Build one resume selector row from a record and its log snapshot, deriving the
* title, route, goal phase, workspace scope, and any reason the session cannot
* be resumed here. A workspace other than the current one is a scope, not a
* disabled reason: resuming it hands the process off into that directory.
* Build one resume selector row from a record, its batch-folded title, and a
* metadata-derived activity time, deriving the workspace scope and any reason
* the session cannot be resumed here. A workspace other than the current one
* is a scope, not a disabled reason: resuming it hands the process off into
* that directory. Rows carry no per-log detail beyond the title — route and
* replay validity are checked by the Enter-time preflight against the one
* chosen log.
* @param record - The session record.
* @param snapshot - The session's log snapshot.
* @param title - The session's batch-folded title, absent for an untitled log.
* @param lastActivityAt - Metadata activity time; absent falls back to the header's creation time.
* @param currentId - The current session id.
* @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in.
* @param availableProviders - Providers registered in this runtime.
* @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label.
* @returns The summarized resume candidate.
*/
export function summarizeResumeCandidate(
record: SessionRecord,
snapshot: SessionLogSnapshot,
title: string | undefined,
lastActivityAt: number | undefined,
currentId: SessionId,
cwd: string | undefined,
availableProviders: ReadonlySet<string>,
formatWorkspace: (cwd: string | undefined) => string,
): ResumeCandidate {
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
const route = resumeRoute(snapshot)
const foldedGoal = foldGoal(snapshot.events).goal
let disabledReason: string | undefined
if (record.header.id === currentId) disabledReason = 'current session'
else if (record.live) disabledReason = 'session is already live in this runtime'
else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace'
else if (route !== undefined && !availableProviders.has(route.provider)) {
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
}
return {
record,
title,
// Excludes a prior pickup's boundary, or every browsed session floats up.
lastActivityAt: lastActivityTime(snapshot.events) ?? snapshot.session.createdAt,
lastTurn: resumeTurnLabel(snapshot),
title: title ?? 'Untitled session',
lastActivityAt: lastActivityAt ?? record.header.createdAt,
currentWorkspace: record.header.cwd === cwd,
workspaceLabel: formatWorkspace(record.header.cwd),
...route === undefined ? {} : { route },
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
...disabledReason === undefined ? {} : { disabledReason },
}
}
@@ -609,6 +561,10 @@ export type ResumeScope = 'workspace' | 'all'
* current session's workspace, `all` lists every workspace and labels each row
* with its own. Tab toggles between them; the search query and selection reset
* on a scope change so the highlighted row always belongs to the visible list.
*
* The picker opens before the session scan settles: an `undefined` candidate
* set renders a loading placeholder that keeps input away from the editor,
* and `setCandidates` swaps the scanned rows in without replacing the overlay.
*/
export class ResumePicker implements Component, Focusable {
private readonly search = new Input()
@@ -616,27 +572,43 @@ export class ResumePicker implements Component, Focusable {
private selectedIndex = 0
private error = ''
private scope: ResumeScope = 'workspace'
private candidates: readonly ResumeCandidate[] | undefined
focused = false
constructor(
private readonly candidates: readonly ResumeCandidate[],
candidates: readonly ResumeCandidate[] | undefined,
private readonly maxVisible: number,
private readonly workspaceLabel: string,
private readonly viewportRows: () => number,
private readonly palette: Palette,
private readonly done: (candidate: ResumeCandidate) => void,
private readonly cancel: () => void,
) {}
) {
this.candidates = candidates
}
invalidate(): void {
this.search.invalidate()
}
/**
* Replace the loading placeholder with the scanned candidate set.
* @param candidates - the summarized rows the finished scan produced.
*/
setCandidates(candidates: readonly ResumeCandidate[]): void {
this.candidates = candidates
this.selectedIndex = 0
// A still-loading error is false the moment rows exist.
this.error = ''
this.invalidate()
}
/** Candidates in the active scope, before the search query narrows them. */
private scoped(): ResumeCandidate[] {
const candidates = this.candidates ?? []
return this.scope === 'all'
? [...this.candidates]
: this.candidates.filter(candidate => candidate.currentWorkspace)
? [...candidates]
: candidates.filter(candidate => candidate.currentWorkspace)
}
private filtered(): ResumeCandidate[] {
@@ -653,7 +625,7 @@ export class ResumePicker implements Component, Focusable {
private visibleCandidateCount(): number {
// The all-workspaces scope adds a per-row workspace line, so a row costs
// one more terminal row there than in the single-workspace scope.
const rowHeight = this.scope === 'all' ? 5 : 4
const rowHeight = this.scope === 'all' ? 4 : 3
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight))
return Math.min(this.maxVisible, candidateBudget)
}
@@ -718,7 +690,8 @@ export class ResumePicker implements Component, Focusable {
this.error = ''
} else if (matchesKey(data, Key.enter)) {
const selected = filtered[this.selectedIndex]
if (selected === undefined) this.error = 'No session matches this search.'
if (this.candidates === undefined) this.error = 'Sessions are still loading.'
else if (selected === undefined) this.error = 'No session matches this search.'
else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
else this.done(selected)
} else {
@@ -738,12 +711,13 @@ export class ResumePicker implements Component, Focusable {
* workspace it means, and the inactive scope with the count Tab would reveal.
*/
private renderScopeLine(): string {
const inWorkspace = this.candidates.filter(candidate => candidate.currentWorkspace).length
const candidates = this.candidates ?? []
const inWorkspace = candidates.filter(candidate => candidate.currentWorkspace).length
const active = this.scope === 'workspace'
? `this workspace ${displayText(this.workspaceLabel)}`
: `all workspaces (${this.candidates.length})`
: `all workspaces (${candidates.length})`
const other = this.scope === 'workspace'
? `all workspaces (${this.candidates.length})`
? `all workspaces (${candidates.length})`
: `this workspace (${inWorkspace})`
return `${this.palette.accent(active)}${this.palette.dim(`${other}`)}`
}
@@ -758,9 +732,12 @@ export class ResumePicker implements Component, Focusable {
if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
const selected = filtered[this.selectedIndex]
const position = selected === undefined ? 0 : this.selectedIndex + 1
const title = this.candidates === undefined
? 'Resume session'
: `Resume session (${position} of ${filtered.length})`
const lines: string[] = [
'',
`${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`,
`${indent}${this.palette.bold(this.palette.accent(title))}`,
'',
]
@@ -795,11 +772,7 @@ export class ResumePicker implements Component, Focusable {
].filter((value): value is string => value !== undefined).join(' · ')
const lead = `${active ? '' : ' '} ${displayText(candidate.title)}`
push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
/* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${status} · ${displayText(candidate.record.header.id)}`))
// Only the all-workspaces scope mixes directories, so the per-row
// workspace is redundant in the scope that already names one.
if (this.scope === 'all') {
@@ -809,7 +782,8 @@ export class ResumePicker implements Component, Focusable {
push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
}
}
if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
if (this.candidates === undefined) push(this.palette.dim('Loading sessions'))
else if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
if (this.error !== '') {
lines.push('')
push(this.palette.error(displayText(this.error)))
+51 -12
View File
@@ -33,8 +33,8 @@ import { contentText, type ParsedArguments } from './content.ts'
import {
formatCompletionTime,
formatTimingTotals,
stepTimingAt,
type StepPosition,
type StepTimingTracker,
} from '../chat/timing.ts'
/** Concatenate the text of every block of one type, separated by blank lines. */
@@ -228,6 +228,7 @@ class StepTimingComponent extends Container {
constructor(
private readonly position: StepPosition,
private readonly events: () => readonly SessionEvent[],
private readonly tracker: StepTimingTracker,
private readonly now: () => number,
private readonly palette: Palette,
) {
@@ -247,7 +248,7 @@ class StepTimingComponent extends Container {
private rebuild(): void {
this.clear()
const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now())
const totals = this.tracker.totalsAt(this.events(), this.position, this.completionTime ?? this.now())
const timing = formatTimingTotals(totals, true)
const header = this.completionTime === undefined
? timing
@@ -277,13 +278,14 @@ export class StreamingAssistantComponent extends Container {
/** The step's turn/step coordinates, used to group steps into their turn. */
readonly position: StepPosition,
events: () => readonly SessionEvent[],
tracker: StepTimingTracker,
now: () => number,
private showReasoning: boolean,
private readonly palette: Palette,
private readonly mdTheme: MarkdownTheme,
) {
super()
this.timing = new StepTimingComponent(position, events, now, palette)
this.timing = new StepTimingComponent(position, events, tracker, now, palette)
this.rebuild()
}
@@ -409,8 +411,43 @@ interface CardBody {
*/
export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded'
/**
* Transcript card with a width-keyed rendered-row cache. pi-tui re-renders
* every component each frame and relies on per-component line caches (its own
* `Text`/`Markdown` do this); a card that rebuilds rows inside `render(width)`
* would re-wrap its output every frame
* ([rationale](../../../../../.agents/notes/implemented/bug-fix/2026-08-03-tui-long-session-render-costs.md)).
* Subclasses render through {@link renderLines} and call {@link dropLines}
* from every state mutator; with `invalidate()` (pi-tui's tree-wide cascade)
* also dropping, a state change always re-renders.
*/
abstract class CachedCardComponent implements Component {
private cached: { width: number; lines: string[] } | undefined
/** Discard the cached rows so the next render recomputes them. */
protected dropLines(): void {
this.cached = undefined
}
invalidate(): void {
this.cached = undefined
}
render(width: number): string[] {
if (this.cached?.width !== width) this.cached = { width, lines: this.renderLines(width) }
return this.cached.lines
}
/**
* Render the card's rows for `width` without caching.
* @param width - Render width the rows are wrapped to.
* @returns The card's rows.
*/
protected abstract renderLines(width: number): string[]
}
/** A tool call and its result, rendered as a collapsible status card. */
export class ToolCardComponent implements Component {
export class ToolCardComponent extends CachedCardComponent {
private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
private visibility: ToolCardVisibility = 'collapsed'
private callView: ToolCallView
@@ -426,6 +463,7 @@ export class ToolCardComponent implements Component {
private readonly palette: Palette,
private readonly mdTheme: MarkdownTheme,
) {
super()
this.callView = this.presentCall()
}
@@ -447,6 +485,7 @@ export class ToolCardComponent implements Component {
*/
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
this.diffBodyCache = undefined
this.dropLines()
const result = event.message.content[0]
this.result = {
content: [...result.content],
@@ -469,11 +508,10 @@ export class ToolCardComponent implements Component {
*/
setVisibility(visibility: ToolCardVisibility): void {
this.visibility = visibility
this.dropLines()
}
invalidate(): void {}
render(width: number): string[] {
protected renderLines(width: number): string[] {
// Hidden renders nothing — not even the leading gap — so the transcript
// keeps only the conversation, the way Codex hides tool calls.
if (this.visibility === 'hidden') return []
@@ -725,7 +763,7 @@ function stripReminderFrame(text: string): string {
* well-formed XML, which made both the fold and the frame-line suppression
* content-dependent.
*/
export class ContextCardComponent implements Component {
export class ContextCardComponent extends CachedCardComponent {
private expanded = false
constructor(
@@ -733,7 +771,9 @@ export class ContextCardComponent implements Component {
private readonly text: string,
private readonly maxOutputLines: number,
private readonly palette: Palette,
) {}
) {
super()
}
/**
* Expand or collapse the card body.
@@ -741,11 +781,10 @@ export class ContextCardComponent implements Component {
*/
setExpanded(expanded: boolean): void {
this.expanded = expanded
this.dropLines()
}
invalidate(): void {}
render(width: number): string[] {
protected renderLines(width: number): string[] {
const header = this.palette.dim(`Context · ${displayText(this.label)}`)
// Emptiness is decided on the stripped text: styling a blank body would yield
// one escape-only row, which reads as a stray blank line under the header.
+6
View File
@@ -42,6 +42,8 @@ export interface TuiConfig {
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. */
@@ -72,6 +74,7 @@ const maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
const resumeScanConcurrencySchema = z.number().step(1).min(1).default(4)
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
@@ -105,6 +108,7 @@ const tuiConfigSchemaFields = {
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
maxResumeOptions: maxResumeOptionsSchema,
resumeScanConcurrency: resumeScanConcurrencySchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
@@ -178,6 +182,7 @@ export interface ResolvedTuiConfig {
maxQuestionOptions: number
maxModelOptions: number
maxResumeOptions: number
resumeScanConcurrency: number
questionDialogWidth: number
questionDialogMaxHeight: number
modelDialogWidth: number
@@ -205,6 +210,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
maxModelOptions: config?.maxModelOptions ?? 8,
maxResumeOptions: config?.maxResumeOptions ?? 8,
resumeScanConcurrency: config?.resumeScanConcurrency ?? 4,
questionDialogWidth: config?.questionDialogWidth ?? 200,
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 76,
+5
View File
@@ -88,6 +88,7 @@ import {
runningPhaseGlyph,
STATUS_ANIMATION_INTERVAL_MS,
STATUS_FADE_MS,
StepTimingTracker,
TIMING_BUCKET_GLYPHS,
type StepPosition,
} from './chat/timing.ts'
@@ -358,6 +359,9 @@ export function createTuiChat(
let toolsVisibility: ToolCardVisibility = 'collapsed'
let streaming: StreamingAssistantComponent | undefined
let completedStreaming: StreamingAssistantComponent | undefined
// One shared accumulator serves every step's timing footer; per-footer
// replay of the whole log is quadratic on a long resumed session.
const stepTimingTracker = new StepTimingTracker()
// Assistant step components in model order per turn, for hidden-mode folding:
// with tool cards hidden, a turn keeps one Assistant header and later steps
// render as headerless continuations (see applyTurnFolding).
@@ -769,6 +773,7 @@ export function createTuiChat(
streaming = new StreamingAssistantComponent(
position,
() => agent.session.events,
stepTimingTracker,
now,
showReasoning,
palette,
+3 -1
View File
@@ -69,6 +69,8 @@ export interface TuiHarnessOptions {
sessionPersistence?: {
list(): Promise<SessionHeader[]>
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
/** Per-session artifact location for mtime-based activity; defaults to none. */
locate?(meta: SessionHeader): { kind: string; path: string } | undefined
}
handoffResume?: TuiRuntime['handoffResume']
/** Host-supplied exit line; absent exercises the no-message path. */
@@ -156,7 +158,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const persistence = options.sessionPersistence
ctx.provide('sessionPersistence', {
...persistence,
locate: () => undefined,
locate: (meta: SessionHeader) => persistence.locate?.(meta),
create: () => Promise.resolve(),
append: () => Promise.resolve(),
load: persistence.load === undefined
@@ -22,28 +22,25 @@ buffer
8| " "
9| " Untitled session "
style 2-19 fg=bright-magenta bold
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
style 2-67 dim
11| " current · live · main-session "
style 2-32 dim
12| " workspace /workspace/project "
10| " 2026-07-23T08:00:00.000Z · current · live · main-session "
style 2-59 dim
11| " workspace /workspace/project "
style 2-31 dim
13| " unavailable: current session "
12| " unavailable: current session "
style 2-31 fg=yellow
14| " Other workspace work "
15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
16| " persisted · elsewhere-session "
style 2-32 dim
17| " workspace /workspace/other "
13| " Other workspace work "
14| " 2024-02-02T00:00:00.000Z · persisted · elsewhere-session "
style 2-59 dim
15| " workspace /workspace/other "
style 2-29 dim
18| " Resume selector design "
19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
20| " persisted · earlier-session "
style 2-30 dim
21| " workspace /workspace/project "
16| " Resume selector design "
17| " 2024-01-01T00:00:00.000Z · persisted · earlier-session "
style 2-57 dim
18| " workspace /workspace/project "
style 2-31 dim
19| " "
20| " "
21| " "
22| " "
23| " "
24| " "

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