diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index 6c7c2c635b..7f5b16b9b8 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write .agents/notes/README.md README.md: 3cfbb5154713046846a3bfcb2ccea62c0e4cb6c0 -README.zh.md: ddecac79519219c4a76cf9ba19edea312eea9d0d +README.zh.md: a3369a94c6761e27567b1408d98a81665443f2ef diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index ddecac7951..a3369a94c6 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -11,7 +11,7 @@ - **生命周期**(顶层文件夹)是 Agent Note 的状态,Agent Note 随状态变化在文件夹之间移动: - **`proposed/`**:实施前评审的提案;尚未构建(或仅部分构建)。 - **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后续移动文件、重命名包(package)或更改键名/默认值时,Agent Note 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。 - - **`rejected/`**:提案经过讨论后被否决。仅当其决策依据仍能避免一种诱人且影响重大的错误时保留;否则删除完整的三个配对文件。 + - **`rejected/`**:提案经过讨论后被否决。仅当其决策依据仍能避免一种诱人且影响重大的错误时保留;否则删除完整的英文、中文和伴随记录三文件组。 - **类别**(嵌套文件夹)是决策的*种类*——见下方[分类](#classification)。 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 @@ -28,7 +28,7 @@ |---|---| | `feature` | 面向用户或模型的新功能。 | | `bug-fix` | 修正缺陷或弥补事故复盘(postmortem)发现的缺口。 | -| `simplification` | 在不增加功能的前提下移除代码、行为或对外表面积。 | +| `simplification` | 在不增加功能的前提下移除代码、行为或对外范围。 | | `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇。 | | `process` | 代码**周边**的工具、策略或工作流——门禁、包管理器、vendor 化——不涉及运行时行为。 | | `testing` | 测试基础设施与策略。 | @@ -39,13 +39,13 @@ 当一份 implemented Agent Note 记录的交付决策已经完整落地,且其决策依据不太可能再指导未来工作时,将其归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。 -归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随文件,并修复或删除入站链接。归档时只允许对内容做这些更改。 +归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) 记录了设计依据。 ## 何时需要写一份 -每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 +每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘存储格式、协议格式(wire format)或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 @@ -112,7 +112,7 @@ Status: ### 曾考虑的替代方案——必需 -每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not ?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——正是这些 Agent Note 存在的意义所要防止的。 +每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not ?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——这正是 Agent Note 旨在防止的问题。 替代方案是记录下来的,不是凭空编造的。日期早于 2026-07-05 且替代方案无法从记录中重建的 Agent Note,在该章节位置放置以下精确注释,门禁仅对格式规范之前的文件接受此注释: @@ -122,8 +122,8 @@ Status: ### 在生命周期之间移动 -将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——即 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写,使之机械化。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。 +将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——也就是将 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写变成可机械检查的规则。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。 ### 中文对侧文件 -`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 +`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文对侧文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index a29fa6e073..5b70e37ebe 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -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-06-14-session-persistence.md: 75e13b860f621ed407849b3b4c62ff7287ab4812 -2026-06-14-session-persistence.zh.md: a6bd400a053779c742940236737447d1687622de +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md +2026-06-14-session-persistence.md: 137b2b01126214629952812f3dd3b71985a3acda +2026-06-14-session-persistence.zh.md: 2846ee92349c297fb3a173ba9dd3e2ff3cd9ee1a diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 75e13b860f..137b2b0112 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -29,7 +29,7 @@ Key choices recorded here because they are durable, contested, and surprising: Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. +Format versioning: the header carries a `version`; `load` rejects any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index a6bd400a05..2846ee9234 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -29,7 +29,7 @@ Status: implemented 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 +格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 055804b569..70618612a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -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-gui-layering-and-rpc-protocol.md: 63db4786adcc007d09b7a58824a59f4d1e1e8be1 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: b3037ceb8c172925581d2862ea675e53a7f8c54e +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +2026-07-19-gui-layering-and-rpc-protocol.md: b9718da4725316c64686adef24827e2984d8723d +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 2add148054e8f97c65600cd719fb4f8e0283f52d diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 63db4786ad..b9718da472 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -165,7 +165,7 @@ One example row (the table structure is the reading key): |---|---|---|---| | `session.list` | `{ cursor?: string }` (cursor is a reserved seat, unimplemented) | `{ items: SessionSummary[] }` | persisted sessions, updatedAt descending; v1 builds no index | -The remaining methods (`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`. +The remaining methods (`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`. ### Frames (server→client, named unions) @@ -187,7 +187,7 @@ The remaining frame types are not re-copied here; the full unions are `MuxFrame` - **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it). - **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only. - **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears. -- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. +- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. (`session.rename` graduated from this list: it appends a user-source `session/title` event.) ## The client carrier: the AbstractApiClient class family (`fetch/client.ts`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index b3037ceb8c..2add148054 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -163,7 +163,7 @@ export type ResponseValue = |---|---|---|---| | `session.list` | `{ cursor?: string }`(cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 session,updatedAt 倒序;v1 不建索引 | -其余方法(`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。 +其余方法(`session.create`/`session.history`/`session.rename`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。 ### 帧(server→client,具名 union) @@ -185,7 +185,7 @@ export type ResponseValue = - **冷 session 隐式 resume**:`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume,并发触发用在途表去重;attach 与否不对客暴露(`running` 已覆盖)。 - **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。 - **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。 -- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。 +- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。(`session.rename` 已从本清单毕业:追加 user 来源的 `session/title` 事件。) ## 客户端载体:AbstractApiClient 类体系(`fetch/client.ts`) diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 121879629c..7abc19ba70 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640 +2026-07-25-web-input-machine-and-slash-pipeline.md: f446f42c9e202afcb404c7a551a4f715228bb8e5 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 8f5e449bb878811b70bc5bc29e4a09bbc1a33bfa diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 8cf3be7b3b..f446f42c9e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -62,8 +62,8 @@ Calls that stay un-evented (registry registration → explicit call → await): A trigger/menu/pick pipeline with zero knowledge of "commands": -- The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique, registration order = group order = polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in registration order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects). -- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. +- The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects). +- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); a `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. - Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core. ### hub / facade: the resident shell and the strict-session input body diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index b148889355..8f5e449bb8 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -62,8 +62,8 @@ occurrence 表与 chip 三投影: 对"命令"零知识的触发/菜单/pick 管线: -- service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一、注册序 = 组序 = 轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按注册序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。 -- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)、pick 编排(outcome → 自派 bail 事件);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 +- service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。 +- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)、pick 编排(outcome → 自派 bail 事件);`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 - 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。 ### hub / facade:常驻外壳与严格 session 输入体 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 82047e9d87..5e423f9383 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml @@ -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-26-subprocess-consumer-migration.md: b31f69f1251a7219e168ed7800ba16ff7d6b328c -2026-07-26-subprocess-consumer-migration.zh.md: 47e7519a72f5482f255d15d87b483e9295f6cd2c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md +2026-07-26-subprocess-consumer-migration.md: 477dffc2271db08b8986b34645067b247ad7ace7 +2026-07-26-subprocess-consumer-migration.zh.md: 5e1035872c6e2101e41d2801cccdfb5ef2c088fc diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md index b31f69f125..477dffc227 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -6,7 +6,7 @@ English | [中文](2026-07-26-subprocess-consumer-migration.zh.md) ## Problem -The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, mcp-client and pty-local and the SDK helper each had a third/fourth/fifth copy of the credential scrub — and none of it was swappable or centrally testable. +The [subprocess seam](2026-07-26-subprocess-seam.md) shipped shaped for exactly one consumer family: batch-collected stdout/stderr, batch stdin, a single escalating `kill()`. That was deliberate scope control, and its own note records "migrate the other spawn sites" as rejected-for-now. Review on the introducing PR reversed that deferral: the stacked follow-up should reshape the interface toward Node's API and move the remaining process-running places onto the service. The remaining spawners each carried a private copy of some slice of the same mechanics — lsp-local had its own detached-tree signalling (POSIX group + Windows taskkill + liveness polling), subagent-subprocess had the dispose ladder and its own scrub, and mcp-client, pty-local, the SDK helper, and the TUI Git probe each carried another credential scrub — and none of it was swappable or centrally testable. ## Decision @@ -15,7 +15,7 @@ The seam's vocabulary is now Node-shaped, and every spawner that can ride the se - **Per-stream stdio dispositions** on `SubprocessSpawnSpec`: `'pipe'` (the raw `Readable`/`Writable`, for consumer-owned protocol framing), `'inherit'` (diagnostics to the parent's stream), and collect mode `{ maxBytes, spill? }` — the original bounded tail-keep shape, with the spill file now optional so a diagnostic tail (a language server's stderr) buffers without touching disk. stdin is `'ignore'`, `'pipe'`, or `{ data }` (write-and-close batch). - **`SubprocessOutcome` carries exit facts only** (Node's close-event vocabulary); collected output stays readable through `handle.collected` after settlement (spill fds seal at the settle boundary), so batch and streaming callers share one access path and nothing is copied into the outcome. - **Tree-scoped termination behind one verb**: `terminate()` owns the SIGTERM→grace→SIGKILL escalation (serves the spec's abort signal too, and is a no-op once the tree is gone) — the handle exposes no single-signal `kill(signal?)`, so a consumer cannot skip the grace window; `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. (The stdin-EOF-first dispose ladder initially absorbed from `subagent-subprocess` later moved back out to its one consumer — see the [ladder-ownership Agent Note](2026-07-27-dispose-ladder-to-consumer.md).) -- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; the SDK helper's `scrubEnvironment()` defaults through it as well. +- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam, and credential-shaped names contain `KEY`, `PASSWORD`, `SECRET`, or `TOKEN`, case-insensitively. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork), mcp-client (the MCP SDK owns the transport spawn), and the TUI's synchronous Git branch probe — import the function, so environment policy is single-sourced even where process ownership is not. The SDK helper's `scrubEnvironment()` defaults through the function and applies the exported pattern when its caller supplies an explicit environment; the pattern remains public for this production consumer. Migrations landed with the reshape: **bash-local/bash-sandbox** (collect modes + batch stdin; the bash `kill()` maps to `terminate()` so `task_kill` keeps escalation semantics), **lsp-local** (piped protocol streams + a no-spill collected stderr tail; `LspConnection` takes the seam's spawn function; its private tree-op helpers deleted), **subagent-acp** (piped ndjson streams + inherited stderr; spawn failure surfaces through `done` rejection into the same startup race; disposal is the backend-owned `disposeAcpChild` ladder over the seam's verbs, with the plugin's configured graces). **`dsh-subagent-subprocess` is deleted** — the dispose ladder and scrub are the seam's; the unused isolated-config-dir helper died with it (no consumer existed). @@ -23,16 +23,16 @@ Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` ## Alternatives considered -**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and five of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams. +**Keep the batch-only seam and let stream consumers stay bespoke.** The introducing note's position, rejected by review: it leaves three private copies of tree signalling and six of the scrub, and any future runner (containerized executor, remote process host) would have to pick which private copy to fork. The Node-shaped dispositions cover all three observed stream shapes without widening the outcome type or buffering piped streams. **A single `stdio: 'pipe' | 'inherit' | 'collect'` mode for all three streams at once.** Rejected: real consumers mix modes per stream (lsp: pipe/pipe/collect; acp: pipe/pipe/inherit; bash: data/collect/collect). Per-stream dispositions are exactly Node's shape and avoid a second spawn call for the mixed cases. **Migrate pty-local and mcp-client spawns too.** Rejected on ownership grounds, not scope: node-pty's `fork()` allocates the terminal itself, and the MCP SDK's `StdioClientTransport` spawns internally — neither call site is ours to route. They adopt the shared scrub (the part that is policy), and their READMEs say why the spawn stays put. -**Migrate the test-support launchers (acp-snapshot, loader-smoke) and the SDK package-manager runner.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams, and the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; it shares the scrub instead. +**Migrate the test-support launchers (acp-snapshot, loader-smoke), the SDK package-manager runner, and the TUI Git probe.** Rejected: the support packages are deliberately dependency-light test infrastructure that must not depend on product seams; the SDK wizard's `stdio: 'inherit'`-with-redirect semantics plus its out-of-composition lifecycle (no cordis context at all) make the service a poor fit; and the TUI probe is synchronous. The production callers share the scrub instead. ## Consequences Bought: one implementation of tree signalling, escalation, bounded collection, and the scrub, tested once in `dsh-subprocess-local`'s suites (including injected-platform Windows coverage that lsp-local's private copy never had); lsp-local and subagent-acp shed their process plumbing and their children now survive plugin reloads and die with composition teardown like bash's; a whole package (`dsh-subagent-subprocess`) is gone. The seam README's "one consumer family" limitation is retired. -Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. +Cost: the seam is wider — three stdio modes and the terminate/waitForExit/dispose lifecycle surface instead of one mode and one verb — so a future backend implements more surface; the compositions for lsp-local/subagent-acp each carry the subprocess row now; and `SubprocessOutcome` no longer carries output, a breaking shape change inside the still-unreleased stack (the PR2 layer was updated in place rather than shimmed, per the pre-release stance). pty-local/mcp-client/SDK/TUI/test-support spawns remain outside the service by ownership or execution shape, with the scrub as the shared floor and its pattern intentionally exported for explicit-environment consumers. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 47e7519a72..5e1035872c 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local 与 SDK helper 则各自持有凭据清除的第三、第四、第五份副本——而这一切既不可替换,也无法集中测试。 +[进程 seam](2026-07-26-subprocess-seam.md) 交付时恰好只为一个消费方家族塑形:批量收集的 stdout/stderr、批量 stdin、单一的升级式 `kill()`。那是有意的范围控制,其自身的 Agent Note 也把「迁移其余 spawn 调用点」记为暂缓否决项。引入该 seam 的 PR(Pull Request)上的评审推翻了这一暂缓决定:堆叠其上的后续变更应当把接口向 Node 的 API 方向重塑,并把其余运行进程之处迁到该服务上。其余各 spawn 调用点此前各自持有同一套机制中某个切片的私有副本——lsp-local 自带 detached 进程树信号发送(POSIX 进程组 + Windows taskkill + 存活轮询),subagent-subprocess 自带 dispose(资源释放)阶梯和自己的凭据清除,mcp-client、pty-local、SDK helper 与 TUI Git 探测则各自持有另一份凭据清除——而这一切既不可替换,也无法集中测试。 ## 决策 @@ -15,7 +15,7 @@ Status: implemented - **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 - **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 - **以进程树为范围的终止,集中在一个动词后面**:`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(也承接 spec 的 abort 信号,进程树消亡后为空操作)——句柄不暴露单信号的 `kill(signal?)`,因此消费方无法跳过宽限窗口;`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界)。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。(最初从 `subagent-subprocess` 吸收的以 stdin EOF 打头的 dispose 阶梯,后来又移回其唯一消费方——见[阶梯归属 Agent Note](2026-07-27-dispose-ladder-to-consumer.md)。) -- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 mcp-client(MCP SDK 拥有传输层的 spawn)——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源;SDK helper 的 `scrubEnvironment()` 默认同样委托给它。 +- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上,且凭据形状的名称不区分大小写地包含 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN`。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)、mcp-client(MCP SDK 拥有传输层的 spawn)与 TUI 的同步 Git 分支探测——改为导入该函数,因此即便进程所有权无法统一,环境策略仍是单一来源。SDK helper 的 `scrubEnvironment()` 默认委托给该函数,并在调用方显式传入环境时应用导出的正则;该正则因此生产消费方而保持公开。 各项迁移随这次重塑一并落地:**bash-local/bash-sandbox**(收集模式 + 批量 stdin;bash 的 `kill()` 映射到 `terminate()`,因此 `task_kill` 保有升级语义),**lsp-local**(管道化的协议流 + 无 spill 的 stderr 收集尾部;`LspConnection` 改为接收 seam 的 spawn 函数;其私有的进程树操作辅助函数已删除),**subagent-acp**(管道化的 ndjson 流 + inherit 的 stderr;spawn 失败经 `done` 的 reject 汇入同一个启动竞态;dispose 是后端自有的 `disposeAcpChild` 阶梯,经由 seam 的动词运行,携带插件所配置的宽限期)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 @@ -23,16 +23,16 @@ Status: implemented ## 曾考虑的替代方案 -**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和五份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。 +**保持只支持批量的 seam,让流式消费方继续各自为政。**这正是引入该 seam 的 Agent Note 当初的立场,评审将其否决:这样会留下三份进程树信号发送的私有副本和六份凭据清除的私有副本,而未来任何运行器(容器化执行器、远程进程宿主)都得挑选去 fork 哪一份私有副本。Node 形状的处置方式覆盖已观察到的全部三种流形状,既不拓宽结果类型,也不缓冲管道化的流。 **用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式一次性统辖全部三条流。**否决:真实消费方按流混用模式(lsp:pipe/pipe/collect;acp:pipe/pipe/inherit;bash:data/collect/collect)。按流划分的处置方式恰好就是 Node 的形状,也免去了混用场景的第二个 spawn 调用。 **把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。 -**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。 +**迁移 test-support 启动器(acp-snapshot、loader-smoke)、SDK package-manager 运行器与 TUI Git 探测。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;TUI 探测则是同步调用。这些生产调用点改为共享凭据清除。 ## 后果 换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 -代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/TUI/test-support 的 spawn 因所有权归属或执行形状留在该服务之外,以凭据清除作为共底线,且为显式环境的生产消费方有意保持该正则导出。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml new file mode 100644 index 0000000000..c462dcec34 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml @@ -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 +2026-07-27-compiler-independent-typert-model.md: 338476924dfb5d9832d0b64bf01b8d3c297cd6d6 +2026-07-27-compiler-independent-typert-model.zh.md: a88f4dbba50696071552ea12a63b69ecac202418 diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md new file mode 100644 index 0000000000..338476924d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md @@ -0,0 +1,53 @@ +# Agent Note: Compiler-independent Typert type model + +Status: implemented + +English | [中文](2026-07-27-compiler-independent-typert-model.zh.md) + +## Problem + +Constructing Zod and reflection text directly from the TypeScript AST couples type analysis and business-semantic recognition to a single generation target. Such a generator can answer only “can this syntax be generated?” It cannot provide a canonical representation of packages, faces, public exports, services, events, objects, and their type relationships, nor can static checks and later generation targets reuse it. + +The host and client are independent TypeScript projects; placing both in one `ts.Program` merges conflicting Cordis `Context` and `Events` declarations. At the same time, client types still need to reference host types explicitly, so neither complete isolation nor duplicating types on both sides can express the actual dependencies. + +## Decision + +[`dsh-typert-generator`](../../../../packages/typert/generator/README.md) builds separate `ts.Program` instances from the host and client projects and uses compiler nodes, symbols, and checkers only as extraction tools. After analysis, every generator and scanner consumes only Typert's own `WorkspaceModel`, `FaceModel`, and `TypeGraph`; the model retains no AST or checker objects. The generator has no dependency on `@deepseek-ai/dsh-typert-registry`. + +TypeGraph preserves the developer-authored, pre-evaluation type structure, including generic parameters and applications, explicit inheritance, conditional and mapped types, recursive references, and JSDoc. A reachable type that cannot be represented losslessly causes analysis to fail. If an emitter cannot handle an already modeled node, that emitter fails instead of flattening the type or degrading it to `unknown`. + +Each face independently owns a PackageModel and TypeGraph. Direct project references from `tsconfig.host.json` and `tsconfig.client.json` determine a package's face membership, while `package.json#exports` defines its public boundary. Cross-face relationships come only from explicit imports or re-exports in source and remain separate links; external npm types are recorded as External without reading or copying their declarations. + +PackageModel recognizes Cordis services, events, `@typert object` reference objects, and `@typert schema` data roots. Services and objects expose only public instance members, excluding constructors and static, private, and protected members; inheritance edges remain in TypeGraph instead of being copied into flattened members. When a public property, parameter, or return type lacks an annotation, `check` mode reports an error, while `write` mode writes the checker-inferred result, rebuilds the project, and analyzes it again in strict mode. + +[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) provides `ctx.typert` and handles runtime registration only: one contribution atomically carries package-face reflection and an optional Zod schema, and Cordis effect disposal revokes it. The registry neither analyzes TypeScript nor merges the two faces. JSON Schema is an on-demand projection of registered Zod schemas. + +Package artifact publication is explicit opt-in. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`. It neither edits exports nor runs as part of the ordinary root build or typecheck, so those commands do not generate whole-workspace Typert artifacts. Generated declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry. + +At build time, `CordisCatalogProjector` consumes the analyzed `FaceModel` and `TypeGraph` once to generate `docs/cordis-catalog/events.md`, `docs/cordis-catalog/services.md`, and the static `SERVICE_API`, `EVENT_API`, and `TYPE_API` catalog committed for `tool-cordis`. `tool-cordis` reads that static catalog and has no runtime dependency on `ctx.typert`. [`dsh-typert-loader`](../../../../packages/typert/loader/README.md) and the registry remain an independent runtime path: the loader follows Cordis Loader entry lifecycle events, imports an explicitly published `./typert` host artifact, and registers it through `ctx.typert`; neither component supplies the current `cordis_inspect` catalog. + +## Verification contract + +A small two-face project in the repository snapshots the complete type model, including its source declaration index. Batched workspace analysis and direct focused analysis must produce model-equivalent `FaceModel` and `TypeGraph` results for the same faces. Compile-time exhaustive maps and runtime set comparisons ensure that every node, target, declaration, and member discriminant is exercised by source-authored TypeScript syntax; a field-semantics matrix covers every keyword, type operator, and literal value category, plus every state of generics, parameters, tuples, mapped modifiers, import attributes, abstract forms, predicates, and enum initializers. + +For every property in `SyntaxZoo`, the TypeScript printer normalizes the source type, which must exactly match the TypeGraph rendering; TypeScript then recompiles every rendered declaration. This layer checks that each node's internal information is preserved losslessly, including no-substitution template literals, type queries with type arguments, and constrained `infer`, without substituting discriminant coverage or code coverage for structural equivalence. + +Boundary cases pin explicit package imports within and across faces, cross-face named re-exports, exact export aliases, qualified `import()` links, and the External classification of global `@types` declarations; they reject TypeScript diagnostics originating in package-owned files, relative-path boundary crossings, references outside `package.json#exports`, and cross-face namespace re-exports without a model target. Interface declaration merging explicitly preserves every authored part; other merges that cannot be represented losslessly fail. + +For each supported node kind and literal category, Zod emitter tests run both successful and failing parses; for each unsupported kind, they assert an explicit `TypertEmitError`. Emitter fixtures snapshot generated Zod JavaScript and `.d.ts` text, execute the JavaScript, and typecheck the declarations. `dsh-typert-registry` tests pin atomic registration, queries, JSON Schema, and effect disposal; `dsh-typert-loader` tests also prove delayed mounting, unloading, and disposal while a dynamic import remains pending. A real `dsh-tools` vertical slice generates a contribution from the model, loads it through the runtime registry, and compares its service, event, and related-type records with the committed static `SERVICE_API`, `EVENT_API`, and `TYPE_API`. A full-workspace projector test regenerates the two Cordis catalog documents and the `tool-cordis` API catalog and requires all three texts to be byte-for-byte identical to the committed artifacts. + +## Alternatives considered + +**Retain the TypeScript AST directly.** The AST preserves source syntax, but it would make every consumer depend on the compiler lifecycle, node identity, and checker context, preventing a stable architectural boundary. It is therefore used only during extraction. + +**Generate final types from the checker.** A flattened `ts.Type` is easy to traverse directly, but it loses the developer's expression of generics, conditional and mapped types, and alias applications, so it cannot support reflection and later generation needs. + +**Merge the host/client projects or duplicate host types.** Merging would contaminate Cordis declaration merging; duplication would create a second source of truth for types. Independent faces with explicit cross-face links preserve project isolation and actual reference relationships. + +**Make `dsh-typert-registry` responsible for type resolution and cross-package composition.** That would recouple the TypeScript compiler, Cordis lifecycle, and a specific schema policy. The registry remains a lifecycle container for generated artifacts, while the build-time model retains complex analysis. + +## Consequences + +New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities. + +Explicit opt-in keeps artifact publication and package exports under package ownership, while ordinary root builds and typechecks incur no whole-workspace Typert generation phase. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly. diff --git a/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md new file mode 100644 index 0000000000..a88f4dbba5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 编译器无关的 Typert 类型模型 + +Status: implemented + +[English](2026-07-27-compiler-independent-typert-model.md) | 中文 + +## Problem + +直接从 TypeScript AST 拼接 Zod 和反射文本,会把类型分析、业务语义识别与单个生成目标绑在一起。这样的生成器只能回答“这段语法能否生成”,无法提供包、face、公开导出、service、event、对象及其类型关系的标准表示,也无法供静态检查和后续生成目标复用。 + +host 与 client 属于独立 TypeScript project;把两者放进同一个 `ts.Program` 会合并冲突的 Cordis `Context` 与 `Events` 声明。与此同时,client 类型仍需显式引用 host 类型,因此完全隔离或在两边复制类型都不能表达真实依赖。 + +## Decision + +[`dsh-typert-generator`](../../../../packages/typert/generator/README.md) 分别从 host 和 client project 建立 `ts.Program`,只把 compiler node、symbol 和 checker 当作提取工具。分析结束后,所有生成器和扫描器只消费 Typert 自有的 `WorkspaceModel`、`FaceModel` 与 `TypeGraph`,模型中不保留 AST 或 checker 对象。生成器不依赖 `@deepseek-ai/dsh-typert-registry`。 + +TypeGraph 保存开发者写下的计算前类型结构,包括泛型参数与应用、显式继承、conditional、mapped、递归引用和 JSDoc。无法无损表示的可达类型使分析失败;某个 emitter 无法处理已经建模的节点时由该 emitter 失败,而不是把类型展平或降级为 `unknown`。 + +每个 face 独立拥有 PackageModel 和 TypeGraph。`tsconfig.host.json` 与 `tsconfig.client.json` 的直接 project references 决定 package 的 face 归属,`package.json#exports` 决定公开边界。跨 face 关系只来自源码中的显式 import 或 re-export,并作为独立 link 保留;外部 npm 类型记录为 External,不读取或复制其声明。 + +PackageModel 识别 Cordis service、event、`@typert object` 引用对象和 `@typert schema` 数据根。service 与 object 只暴露 public instance member,排除 constructor、static、private 和 protected;继承边保留在 TypeGraph 中,不复制为扁平成员。缺少 public property、parameter 或 return 类型标注时,`check` 模式报错,`write` 模式写入 checker 推断结果后重建 project 并再次以严格模式分析。 + +[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) 提供 `ctx.typert`,且只负责运行时注册:一个 contribution 原子携带 package-face reflection 与可选 Zod schema,并随 Cordis effect 撤销。注册表不分析 TypeScript,也不合并两个 face。JSON Schema 是对已注册 Zod schema 的按需投影。 + +包产物发布采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`。它既不修改 exports,也不作为根目录普通 build 或 typecheck 的一部分运行,因此这些命令不会生成全仓 Typert 产物。生成的声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。 + +构建期的 `CordisCatalogProjector` 一次消费分析后的 `FaceModel` 与 `TypeGraph`,生成 `docs/cordis-catalog/events.md`、`docs/cordis-catalog/services.md`,以及为 `tool-cordis` 提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` catalog。`tool-cordis` 读取该静态 catalog,运行时不依赖 `ctx.typert`。[`dsh-typert-loader`](../../../../packages/typert/loader/README.md) 与注册表仍是独立的运行时路径:loader 监听 Cordis Loader 配置项生命周期事件,导入显式发布的 `./typert` host 产物,并通过 `ctx.typert` 注册;两者都不是当前 `cordis_inspect` catalog 的数据源。 + +## Verification contract + +提交内的小型双 face project 对完整类型模型及其源码声明索引做 snapshot。全仓分批分析与直接聚焦分析必须为相同 face 生成模型等价的 `FaceModel` 与 `TypeGraph`。类型级全集和运行时集合比较保证每种 node、target、declaration 与 member discriminant 都来自真实 TypeScript syntax;字段语义矩阵覆盖所有 keyword、type operator、literal value 类目,以及泛型、参数、tuple、mapped modifier、import attributes、abstract、predicate 和 enum initializer 的各个状态。 + +`SyntaxZoo` 中每个 property 的源码类型经 TypeScript printer 标准化后,必须与 TypeGraph 渲染结果逐项相等,随后所有渲染 declaration 再交给 TypeScript 编译。这一层检查节点内部信息是否无损,包括无插值 template literal、带 type argument 的 type query 和受约束 `infer`,不以 discriminant 覆盖或代码覆盖率代替结构等价。 + +边界用例固定同 face 与跨 face 的显式包导入、跨 face 命名 re-export、精确 export alias、qualified `import()` link 和全局 `@types` External 归属,并拒绝 package 自有 TypeScript 诊断、相对路径越界、`package.json#exports` 之外的引用,以及尚无模型 target 的跨 face namespace re-export。interface declaration merging 显式保留每个 authored part,无法无损表示的其他 merge 失败。 + +Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 parse,对不支持的节点逐类断言明确的 `TypertEmitError`。Emitter fixture 对生成的 Zod JavaScript 与 `.d.ts` 文本做快照,执行 JavaScript,并对声明做类型检查。`dsh-typert-registry` 测试固定原子注册、查询、JSON Schema 和 effect 撤销,`dsh-typert-loader` 测试还证明延迟挂载、卸载及未完成 dynamic import 的释放行为。真实 `dsh-tools` 纵切从模型生成 contribution,经运行时注册表加载后,将其服务、事件与关联类型记录同已提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` 对照。全仓 projector 测试重新生成两份 Cordis catalog 文档与 `tool-cordis` API catalog,并要求三份文本同已提交产物逐字节一致。 + +## Alternatives considered + +**直接保存 TypeScript AST。** AST 能保留源码写法,但会让每个消费者依赖 compiler 生命周期、node identity 和 checker 上下文,无法形成稳定的架构边界,因此只在提取阶段使用。 + +**基于 checker 的最终类型生成。** 展平后的 `ts.Type` 便于直接遍历,却丢失泛型、conditional、mapped 和 alias application 的开发者表达,无法满足反射与后续生成需要。 + +**合并 host/client project 或复制 host 类型。** 合并会污染 Cordis declaration merging;复制会产生第二份类型事实源。独立 face 加显式 cross-face link 保留了 project 隔离与真实引用关系。 + +**让 `dsh-typert-registry` 承担类型解析和跨包合成。** 这会把 TypeScript compiler、Cordis 生命周期和具体 schema 策略重新耦合。注册表保持为生成 artifact 的生命周期容器,复杂分析留在构建期模型。 + +## Consequences + +新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。 + +显式 opt-in 使产物发布与 package exports 由各包自行管理,根目录普通 build 和 typecheck 不会引入全仓 Typert 生成阶段。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml new file mode 100644 index 0000000000..c15af141bd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml @@ -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-07-28-api-browser-trust-boundary.md +2026-07-28-api-browser-trust-boundary.md: e56d0fc2a7bd551899605491f3a0522b62b961b0 +2026-07-28-api-browser-trust-boundary.zh.md: 2958f7e49bfd4a258c63fc96c2e8aee0f98183ee diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md new file mode 100644 index 0000000000..e56d0fc2a7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -0,0 +1,31 @@ +# Agent Note: One carrier-level browser-trust boundary for the whole /api surface + +Status: implemented + +English | [中文](2026-07-28-api-browser-trust-boundary.zh.md) + +## Problem + +The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--host 0.0.0.0` supported), and the surface includes remote-code-execution-grade methods — `session.prompt` drives an agent that runs bash. A browser turns the operator into a confused deputy against such a local API in two classic ways: a malicious page fires a "simple" cross-site POST (`text/plain` — sent without a CORS preflight) whose side effects execute even though the response stays unreadable, and a DNS-rebound origin talks to the socket as if same-origin, making CORS inapplicable entirely, with only the `Host` header betraying the attacker's domain. Before this decision the system's only browser-trust check (`isTrustedNativeDialogRequest`: loopback socket + same-origin + loopback Host) guarded exactly one cosmetic route — `host.pickDirectory`, whose native dialog pops on the host's screen — while every consequential method was unguarded. Guarding per-RPC also could not survive the upcoming in-app directory browser, whose whole point is serving legitimately remote clients that a loopback rule would refuse. + +## Decision + +Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs: + +- **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. +- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence. + +Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover. + +## Alternatives considered + +- **Per-RPC guards (status quo extended).** Rejected: the guard list trails the method list forever, the highest-value methods were already unguarded, and a loopback rule on browse RPCs would break the remote deployments they exist for. +- **CORS headers + credential omission.** Rejected: we never want cross-origin reads at all, so answering preflights only widens the surface; refusing them is strictly stronger and simpler. +- **Auth tokens now.** Rejected for this change: token minting/storage/rotation is real product surface; the fence closes the browser-deputy holes today without pre-deciding the auth design. + +## Consequences + +- Any future `/api` method is covered by construction; there is no per-route trust decision left to forget. +- Non-loopback deployments must have their serving authorities trusted or requests are refused. The dsh CLI keeps its advertised `--host 0.0.0.0` LAN URL working by deriving the machine's LAN IP literals into the connection row (port-less entries — an IP-literal Host cannot be a rebound name, and the bound port may be OS-assigned) and offers `dsh web --trusted-host` for named authorities; compositions the CLI does not boot declare `trustedHosts` themselves. Non-browser automation rides the same fence: loopback, a derived LAN IP, or a declared authority passes; an undeclared DNS alias is refused. +- Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header). +- The trusted-network assumption of an unauthenticated `0.0.0.0` deployment is now documented instead of implicit. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md new file mode 100644 index 0000000000..2958f7e49b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -0,0 +1,31 @@ +# Agent Note:整个 /api 面共用一道载体级浏览器信任边界 + +状态:已实现 + +[English](2026-07-28-api-browser-trust-boundary.md) | 中文 + +## 问题 + +Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--host 0.0.0.0`),而这个面上有远程代码执行级别的方法——`session.prompt` 驱动的 agent 可以运行 bash。浏览器会用两种经典方式把操作者变成攻击此类本地 API 的"混淆代理人":恶意页面发出跨站"简单请求" POST(`text/plain`——不经 CORS 预检即发出),其副作用照常执行、只是响应不可读;以及 DNS rebinding 后的源以"同源"身份直连 socket,CORS 整体失效,只有 `Host` 头会暴露攻击者的域名。在本决策之前,系统里唯一的浏览器信任检查(`isTrustedNativeDialogRequest`:回环 socket + 同源 + 回环 Host)只守着一个装饰性的路由——`host.pickDirectory`,其原生对话框弹在宿主屏幕上——而所有真正要命的方法都在裸奔。按 RPC 逐个设防也活不过即将到来的应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。 + +## 决策 + +在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR: + +- **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是纯的、规范形权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 + +两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 + +## 曾考虑的替代方案 + +- **按 RPC 设防(延续现状)。** 否决:守卫清单永远追着方法清单跑,价值最高的方法本来就没被守住,而 browse RPC 上的回环规则会破坏它们为之存在的远程部署。 +- **CORS 头 + 省略凭据。** 否决:我们根本不想要任何跨源读取,应答预检只会扩大暴露面;拒绝预检严格更强也更简单。 +- **现在就上认证令牌。** 在本变更中否决:令牌的签发/存储/轮换是真实的产品面;栅栏今天就能封死浏览器代理人漏洞,无需预先决定认证设计。 + +## 后果 + +- 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。 +- 非回环部署的服务权威必须获得信任,否则请求会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;CLI 不参与引导的组合自行声明 `trustedHosts`。非浏览器自动化走同一道栅栏:回环地址、推导的 LAN IP 或已声明的权威可通过;未声明的 DNS 别名会被拒绝。 +- 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。 +- 无认证 `0.0.0.0` 部署的"信任网络"假设从隐含变为成文。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml new file mode 100644 index 0000000000..629476cf8e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml @@ -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-07-28-consolidated-tui-presentation.md +2026-07-28-consolidated-tui-presentation.md: f87d543a698d6e77abf9120c6579100df4b60b64 +2026-07-28-consolidated-tui-presentation.zh.md: 005e408f0e75207027315546942f9eab57d595d1 diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md new file mode 100644 index 0000000000..f87d543a69 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md @@ -0,0 +1,63 @@ +# Agent Note: Consolidated TUI presentation and navigation + +Status: implemented + +English | [中文](2026-07-28-consolidated-tui-presentation.zh.md) + +## Problem + +The terminal UI accumulated independent presentation rules that interacted poorly: palette roles aliased one another or inverted emphasis on light terminals; tool-card framing, output, and exit markers repeated or competed; injected context was parsed as XML and could not fold reliably; and `/resume` excluded sessions outside the current workspace even when the launcher could reach them. Each symptom appeared local, but the durable decision is one terminal-reading model: a small inspectable palette, status-led cards with recessed bodies, content-independent transcript folding, and workspace-aware navigation. + +## Decision + +### Palette + +`paletteSpec(scheme)` is the single table of SGR codes, close codes, and purposes. `createPalette` derives every wrapper from it and `/palette` prints the same table in the running terminal. Components do not emit their own SGR sequences except for the fixed startup brand gradient. Every close resets every SGR group its open sets. + +Duplicate roles are merged: `muted` into `dim`, `added` into `success`, `removed` into `error`, and the unused second accent is removed. `dim` uses `2;39` and closes with `22;39` on both schemes so recessed text stays relative to the terminal foreground rather than becoming a fixed heavy gray on light backgrounds. Colors and attributes are branded separately in TypeScript, allowing attribute/color composition while rejecting nested colors whose reset would discard the outer color. + +### Tool cards + +A tool card has one colored `Tool / ` status header over one dim body. Presenter titles, terminal commands and cwd rows, output, XML text, and fold markers use that body tone. Diff colors remain because red and green carry meaning, and signal markers remain errors. + +`renderUnknownXml` receives an explicit body styler for unknown tool results. Terminal presenters parse and remove the model-facing final exit or signal marker before returning `TerminalResultView.output`; the TUI renders the structured status once as its own pill. Truncation, timeout, and sandbox lines remain in the body because the pill does not represent them. + +### Injected context and folding + +Injected context renders as prose in `ContextCardComponent`, not through the XML tree renderer. Exact matched outer `` lines are stripped, but mismatched, unpaired, or inline tag-like text remains verbatim. Model-facing content is unchanged. Folding uses the shared `preview` helper after body assembly, so it depends only on row count, never parser success or payload characters. + +`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. + +### Cross-workspace resume + +The resume picker summarizes all records and owns a current-workspace/all-workspaces scope toggled with Tab. It defaults to the current workspace, adds workspace labels only in the broader scope, and refuses records without a cwd because there is no directory to enter. + +`TuiResumeHost.handoff` receives the selected `SessionId` and the cwd re-read during preflight. The CLI changes directory before disposing the current app, so an unreachable directory fails while the terminal can still recover; `execve` then inherits the selected workspace. The launcher also supplies the exit message rather than asking the TUI to reconstruct launcher syntax. + +## Alternatives considered + +**Keep separate notes and local fixes for each visual symptom.** Rejected: the decisions share one reading hierarchy and repeatedly superseded each other. One owner makes the final palette, card, context, and navigation rules clear without requiring readers to reconstruct chronology. + +**Keep aliases and enforce presentation by convention.** Rejected: aliases imply distinctions that do not exist, and nested color resets or incomplete SGR closes fail silently. A single table plus types makes the contract inspectable and mechanically checked. + +**Retain framing/output color splits inside tool cards.** Rejected: real cards mixed default foreground, cyan commands, dim cwd, unstyled XML, and dim output. The status header already provides the scan anchor; one recessed body removes noise. Diff colors are the narrow semantic exception. + +**Parse or repair injected context as XML.** Rejected: reminder frames are prompting conventions around arbitrary prose containing raw ampersands, comparisons, and placeholder angle brackets. Repairing or escaping it would either guess structure or alter model-visible text. + +**Hide context cards with tool cards.** Rejected: context carries injected instructions, not recoverable execution detail. The hidden phase therefore removes only tool traffic. + +**Keep resume restricted to one workspace or infer cwd after boot.** Rejected: the restriction forces manual relaunch, while restored header cwd does not control filesystem and shell resolution. The target directory must cross the host seam before process replacement. + +**Drop the TUI exit pill or remove model-facing exit markers.** Rejected: the pill is the scannable UI status, while the text marker is the model's status signal. The presenter consumes the marker when constructing the structured view so both audiences receive one representation. + +## Consequences + +The transcript reads as colored status headers over recessed detail, context presentation is stable for arbitrary prose, and one shortcut controls transcript density. The public `TuiTheme.muted` role is removed; extensions use `dim`. The palette and `renderUnknownXml` contracts are stricter, adding small compile-time friction in exchange for preventing silent style loss. + +Cross-workspace resume can move every path-resolving tool to another directory. A missing or inaccessible cwd prevents handoff. The broader picker also makes concurrent access to a shared session store easier to reach; cross-process session locking remains separate work. + +The terminal presenter still treats a final output line exactly matching its exit-marker grammar as structured status, so a command that intentionally prints such a line can lose it from the card body. This residual is documented by `dsh-tool-bash`. + +## Testing + +TUI unit and keyless terminal snapshots cover palette enumeration, light/dark roles, legal and illegal style composition, uniformly dim card bodies, semantic diff colors, marker-free terminal output with one exit pill, prose-preserving context frames, content-independent folding, the three-state Ctrl+O cycle, model filtering, and both resume scopes. CLI handoff tests cover passing the re-read cwd and rejecting directory-entry failure before teardown. Tool-bash tests pin result-marker emission, parse, and stripping as one round trip. diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md new file mode 100644 index 0000000000..005e408f0e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md @@ -0,0 +1,63 @@ +# Agent Note: 统一的 TUI 呈现与导航 + +Status: implemented + +[English](2026-07-28-consolidated-tui-presentation.md) | 中文 + +## Problem + +终端 UI 逐步积累了多套彼此干扰的呈现规则:调色板角色互为别名,或在浅色终端中颠倒强调层级;工具卡片的框架、输出和退出标记重复或争夺注意力;注入上下文被当作 XML 解析,无法可靠折叠;`/resume` 即使能通过启动器访问其他工作区,也会排除不属于当前工作区的会话。每个症状看似局部,但持久决策只有一个终端阅读模型:精简且可检查的调色板、以状态为首且正文内收的卡片、与内容无关的记录折叠,以及感知工作区的导航。 + +## Decision + +### 调色板 + +`paletteSpec(scheme)` 是 SGR 开始码、结束码和用途的唯一表。`createPalette` 从该表派生所有包装器,`/palette` 在运行中的终端打印同一张表。除固定的启动品牌渐变外,组件不自行发出 SGR 序列。每个结束码都重置对应开始码设置的所有 SGR 组。 + +重复角色被合并:`muted` 并入 `dim`,`added` 并入 `success`,`removed` 并入 `error`,未使用的第二强调色被移除。`dim` 在两种配色方案中都使用 `2;39`,并以 `22;39` 结束,使内收文本相对于终端前景色变暗,而不会在浅色背景上变成固定的深灰色。TypeScript 分别标记颜色和属性,允许属性与颜色组合,同时拒绝会因重置而丢失外层颜色的嵌套颜色。 + +### 工具卡片 + +工具卡片由一行带颜色的 `Tool / ` 状态标题和一块统一的 dim 正文组成。呈现器标题、终端命令及 cwd 行、输出、XML 文本和折叠标记都使用正文色调。差异颜色继续保留,因为红绿承载语义;信号标记也继续作为错误显示。 + +`renderUnknownXml` 对未知工具结果显式接收正文样式器。终端呈现器在返回 `TerminalResultView.output` 前解析并移除面向模型的末尾退出或信号标记;TUI 只把结构化状态呈现一次。截断、超时和沙箱信息继续留在正文中,因为状态标记不表达这些事实。 + +### 注入上下文与折叠 + +注入上下文由 `ContextCardComponent` 按普通文本呈现,不经过 XML 树渲染器。仅移除精确配对的外层 `` 行;不匹配、单边或正文内类似标签的文本都原样保留。面向模型的内容不变。折叠在正文组装完成后使用共享 `preview` 辅助函数,因此只取决于行数,不依赖解析是否成功或载荷包含哪些字符。 + +`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。 + +### 跨工作区恢复 + +恢复选择器汇总所有记录,并维护可用 Tab 切换的当前工作区/所有工作区范围。默认范围是当前工作区;只有更宽范围才显示工作区标签。没有 cwd 的记录会被拒绝,因为没有可进入的目录。 + +`TuiResumeHost.handoff` 接收选中的 `SessionId` 和预检时重新读取的 cwd。CLI 在释放当前应用前切换目录,因此无法访问的目录会在终端仍可恢复时失败;随后 `execve` 继承所选工作区。退出提示也由启动器提供,而不是让 TUI 反推启动器命令语法。 + +## Alternatives considered + +**为每个视觉症状保留独立 Agent Note 和局部修复。** 否决:这些决策共享同一阅读层级,而且彼此多次取代。由一份记录统一拥有最终的调色板、卡片、上下文和导航规则,读者无需重建变更顺序。 + +**保留别名,并依靠约定执行呈现规则。** 否决:别名暗示并不存在的差异;嵌套颜色重置或不完整的 SGR 结束会静默失败。单一表格加类型约束使契约可检查且可机械验证。 + +**保留工具卡片内部的框架/输出颜色分层。** 否决:真实卡片会混用默认前景、青色命令、dim cwd、无样式 XML 和 dim 输出。状态标题已经提供扫描锚点;统一内收正文能消除噪声。差异颜色是狭窄的语义例外。 + +**把注入上下文继续解析或修复成 XML。** 否决:提醒框架只是包裹任意普通文本的提示约定,其中会包含原始 `&`、比较表达式和尖括号占位符。修复或转义要么猜测结构,要么改变模型可见文本。 + +**随工具卡片一起隐藏上下文卡片。** 否决:上下文承载注入指令,不是可恢复的执行细节。因此隐藏阶段只移除工具流量。 + +**把恢复限制在一个工作区,或在启动后推断 cwd。** 否决:前者迫使用户手动重启;后者恢复的会话头 cwd 并不控制文件系统和 shell 的路径解析。目标目录必须在进程替换前跨过主机接口。 + +**移除 TUI 退出状态标记,或移除面向模型的退出标记。** 否决:前者是便于扫描的 UI 状态,后者是模型的状态信号。呈现器在构造结构化视图时消费文本标记,使两类受众各看到一种表示。 + +## Consequences + +记录现在表现为带颜色的状态标题和内收细节;上下文对任意普通文本都稳定呈现;一个快捷键控制记录密度。公共 `TuiTheme.muted` 角色被移除,扩展改用 `dim`。调色板和 `renderUnknownXml` 契约更严格,以少量编译期摩擦换取对静默样式丢失的防护。 + +跨工作区恢复会把所有依赖路径解析的工具移动到另一个目录。cwd 缺失或不可访问时不能交接。更宽的选择范围也使共享会话存储的并发访问更容易触达;跨进程会话锁仍是独立后续工作。 + +终端呈现器仍会把与退出标记语法完全一致的最后一行输出视为结构化状态,因此命令有意打印这种行时,卡片正文可能丢失该行。`dsh-tool-bash` 已记录这一残余限制。 + +## Testing + +TUI 单元测试和无密钥终端快照覆盖调色板枚举、浅色/深色角色、合法与非法样式组合、统一 dim 卡片正文、保留语义的差异颜色、仅有一个退出状态且正文无标记、普通文本上下文框架、与内容无关的折叠、Ctrl+O 三态循环、模型过滤和两种恢复范围。CLI 交接测试覆盖传递重新读取的 cwd,并在释放前拒绝目录切换失败。tool-bash 测试把结果标记的生成、解析和移除固定为同一轮往返契约。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml new file mode 100644 index 0000000000..bb9425fa64 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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-07-28-directory-picker-capability-seam.md +2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38 +2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md new file mode 100644 index 0000000000..7c8f8cb676 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -0,0 +1,39 @@ +# Agent Note: A capability-discriminated directory-picker seam for the web-GUI host + +Status: implemented + +English | [中文](2026-07-28-directory-picker-capability-seam.zh.md) + +## Problem + +The web GUI's "Open local folder" flow was hardwired to one interaction: `host.pickDirectory` invoked a native OS chooser compiled into `dsh-host-apiproxy` (private module, test-only injection seam). That shape cannot serve remote deployments — no OS dialog reaches a browser on another machine — and the planned in-app directory browser (Figma `Harness` 802-56979) needs listing/creation primitives, which are a different interaction contract, not a different implementation of the same one. Swapping interactions required editing gateway source, against the repo's everything-is-a-plugin stance. + +## Decision + +A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. + +**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. + +Placement and policy rulings folded into this decision: + +- **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. +- **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. +- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. +- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. +- **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. +- **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. + +## Alternatives considered + +- **Extend `ctx.fs` with browse methods.** Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam. +- **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. +- **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. +- **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. + +## Consequences + +- `cordis.yml` chooses the interaction; `apps/cli` mounts `-browse` (the shipped default — remote-capable picking out of the box), one row having swapped backend and UI together; `-native` remains the host-display alternative. +- The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests. +- A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. +- `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md new file mode 100644 index 0000000000..05545fc3cd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -0,0 +1,39 @@ +# Agent Note:web GUI 宿主的能力可辨识目录选择 seam + +状态:已实现 + +[English](2026-07-28-directory-picker-capability-seam.md) | 中文 + +## 问题 + +web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pickDirectory` 调用编译进 `dsh-host-apiproxy` 的原生 OS 选择器(私有模块,仅测试注入缝)。这个形态服务不了远程部署——没有任何 OS 对话框能弹到另一台机器的浏览器里——而计划中的应用内目录浏览器(Figma `Harness` 802-56979)需要列举/创建原语,那是**另一种交互契约**,不是同一契约的另一种实现。想换交互只能改网关源码,违背仓库"一切皆插件"的立场。 + +## 决策 + +在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 + +**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是**双面包**:browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 + +并入本决策的位置与策略裁决: + +- **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 +- **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 +- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 +- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 +- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 +- **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 + +## 曾考虑的替代方案 + +- **给 `ctx.fs` 增加浏览方法。** 否决:上述权限域耦合;且面向展示的列举契约(hidden 标志、面包屑、home 锚点)不属于存储 seam。 +- **统一方法集的 seam(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。 +- **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture/测试后端,与促成这项工作的插件教义相悖。 +- **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 + +## 后果 + +- `cordis.yml` 决定交互形态;`apps/cli` 挂 `-browse`(随附默认——开箱即得可远程的选取),一行同时切换了后端与 UI;`-native` 仍是宿主屏幕方案。 +- 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。 +- 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。 +- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml new file mode 100644 index 0000000000..f3a3226c11 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -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-07-28-dsh-native-typescript-source-launch.md +2026-07-28-dsh-native-typescript-source-launch.md: 1ba1dd2663038ad7c49af71f8b428245f7fa3e2b +2026-07-28-dsh-native-typescript-source-launch.zh.md: 02f84f34820469ad9e810ae17d79d3fe12b0cd4c diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md new file mode 100644 index 0000000000..1ba1dd2663 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md @@ -0,0 +1,47 @@ +# Agent Note: Native TypeScript source launch for dsh + +Status: implemented + +English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md) + +> The Node-native launch vector is superseded by [dsh source launch through the tsx ESM hook](2026-07-29-dsh-source-launch-tsx-esm.md): Node 26.0.0 removed `--experimental-transform-types`, and the paths loader described here is deleted. The Cordis-config declaration gate (`verify-cordis-config`), the app-boot fail-loud plugin diagnostic, and the vendored `import type` marks remain current. + +## Problem + +The `dsh` source entry point originally used `tsx` to run `apps/cli/src/bin.ts`, with the same third-party loader implicitly handling both TypeScript transformation and the root tsconfig's `paths` resolution. With Node handling TypeScript natively, it does not apply tsconfig path mappings; resolving through package exports would instead mix potentially stale or nonexistent `lib/` artifacts into the source launch. + +Node's transform also does not perform type analysis. A type imported through an ordinary value import remains a runtime ESM request, and TypeScript's `export =` becomes a CommonJS assignment rather than an ESM default export. The source graph therefore has to use explicit type-only imports and native ESM exports; a resolve hook cannot repair incompatible source syntax. + +Cordis configuration introduces a separate resolution boundary. Bare plugins in `cordis.yml` do not pass through TypeScript import analysis, so their resolver manifest may omit the required dependencies. The Cordis Loader logs plugin import errors and leaves an entry without a fiber, but does not fail startup itself; a typo in the configuration can therefore produce an incomplete application with exit code 0. + +## Decision + +The `dsh` TUI, Web, and headless source launches use `node --experimental-transform-types`; Node performs TypeScript transformation without loading `tsx` or esbuild. `bin/dsh`, the root-level `dsh`/TUI/Web demos, and Code Mode TUI enter the same `apps/cli/src/bin.ts` launch chain. Test and E2E launchers retain their existing strategies, and the built `lib/bin.js` continues to run under ordinary Node. + +`scripts/tspath-loader.ts` registers only a module resolve hook. It uses `TSX_TSCONFIG_PATH` when set (resolving relative values from the invoking cwd) and otherwise reads the root `tsconfig.json`; `TsconfigPathsResolver` follows that config's `extends` chain through the repository's existing TypeScript development tool, selects exact or wildcard `paths` entries according to tsconfig rules, and maps matching workspace bare specifiers to `.ts`/`.mts`/`.cts` source files or directory index files. Node remains solely responsible for code transformation. The source-only loader is not part of the built CLI and `apps/cli` does not declare `typescript` as a runtime dependency. + +Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/cordis.yml` lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. + +`verify-cordis-config` performs a one-way completeness check on both resolver manifests: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule. + +After the Loader settles, the shared `dsh-app-boot` checks every enabled entry that has no fiber and rejects startup with `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved`, listing all failed plugins. This diagnostic lives at the app layer and does not change the vendored Loader's startup behavior. + +Node-compatible TypeScript is part of this source-launch contract. Vendored Cordis, Loader, Include, HMR, and Schemastery mark erased imports with `import type`. Schemastery uses a native ESM default export and declares `type: module`; its `.mjs` and `.cjs` build outputs retain the existing ESM-default and callable-`require()` behavior. These divergences are recorded in `vendor/README.md`; no runtime behavior is added to the vendored frameworks. + +## Alternatives considered + +**Continue using `tsx`.** Rejected because `tsx`/esbuild would continue to own TypeScript transformation, so this launch chain could not prove that Node's native transformation works. + +**Load the built `lib/` through package exports from the source entry point.** Rejected because this would mix the source plane with the artifact plane; a zero-build development launch could read stale artifacts or fail outright. + +**Apply the root tsconfig `paths` unconditionally.** Rejected because this would allow undeclared cross-package imports and Cordis plugins to keep resolving, hiding mismatches between the manifest and the actual runtime graph. + +**Transform imports inside the custom loader.** Rejected because type-aware source rewriting would reintroduce a compiler-style transform and make the loader, rather than Node, responsible for TypeScript execution. Making the checked-in source Node-compatible keeps the launch boundary explicit. + +## Consequences + +- TUI/headless retain a zero-build source loop, while Web still builds its frontend artifacts before starting the CLI source entry. TypeScript syntax passes only through Node's native transform; the URL-only loader uses the checkout's root development dependencies and adds no CLI runtime dependency. +- Workspace package imports and Cordis configuration dependencies must both be declared explicitly in the resolver manifest; the static gate prevents configuration from landing before its dependencies, while extra dependencies are not errors. +- Plugin import failures no longer leave an incomplete application with exit code 0; the final error identifies both the Cordis startup failure and the specific plugin names, while the Loader's original error remains earlier in the logs. +- Vendored source in the CLI graph must remain compatible with Node's transform-types module semantics; the local-modification log makes the upstream sync obligation explicit. +- CI's `lib` mode, test/E2E launchers, and other example launchers retain their existing strategies; this native source loader covers only the `dsh` CLI application chain. diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md new file mode 100644 index 0000000000..02f84f3482 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -0,0 +1,47 @@ +# Agent Note: dsh 原生 TypeScript 源码启动 + +Status: implemented + +[English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文 + +> Node 原生启动向量已被 [dsh 通过 tsx ESM hook 源码启动](2026-07-29-dsh-source-launch-tsx-esm.md) 取代:Node 26.0.0 移除了 `--experimental-transform-types`,本文描述的 paths loader 已删除。Cordis 配置声明门禁(`verify-cordis-config`)、app-boot 的 fail-loud 插件诊断以及 vendor 中的 `import type` 标注仍然有效。 + +## 问题 + +`dsh` 源码入口原本使用 `tsx` 运行 `apps/cli/src/bin.ts`,TypeScript 转换和根 tsconfig 的 `paths` 解析都由同一个第三方 loader 隐式处理。改由 Node 原生处理 TypeScript 后,Node 不会应用 tsconfig 路径映射;如果改为通过包导出解析,源码启动会混入可能陈旧或不存在的 `lib/` 产物。 + +Node 的转换也不执行类型分析。通过普通值 import 导入的类型会保留为运行时 ESM 请求,而 TypeScript 的 `export =` 会变成 CommonJS 赋值,而不是 ESM default export。因此,源码图必须显式使用仅类型导入和原生 ESM 导出;resolve hook 无法修复不兼容的源码语法。 + +Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin 不经过 TypeScript import 分析,其解析方 manifest 可能漏掉所需依赖。Cordis Loader 会记录插件 import 错误,并留下没有 fiber 的 entry,但不会让启动本身失败;配置中的拼写错误因此可能得到退出码为 0 的残缺应用。 + +## 决策 + +`dsh` 的 TUI、Web 和无头源码启动使用 `node --experimental-transform-types`,由 Node 完成 TypeScript 转换,不加载 `tsx` 或 esbuild。`bin/dsh`、根级 `dsh`/TUI/Web demo 以及 Code Mode TUI 都进入同一条 `apps/cli/src/bin.ts` 启动链路。测试与 e2e 启动器保留各自现有策略,构建后的 `lib/bin.js` 继续由普通 Node 运行。 + +`scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。 + +只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/cordis.yml` 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 + +`verify-cordis-config` 对这两个解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。 + +Loader 完成结算后,共享的 `dsh-app-boot` 会检查每个已启用但没有 fiber 的 entry,并以 `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved` 拒绝启动,同时列出全部加载失败的插件。该诊断位于应用层,不改变 vendor 中 Loader 的启动行为。 + +Node-compatible TypeScript 是这项源码启动契约的一部分。vendor 中的 Cordis、Loader、Include、HMR(热模块替换)和 Schemastery 使用 `import type` 标记会被擦除的导入。Schemastery 使用原生 ESM default export 并声明 `type: module`;其 `.mjs` 和 `.cjs` 构建产物分别保留现有的 ESM default export 行为和 `require()` 返回可调用值的行为。这些差异记录在 `vendor/README.md` 中;没有为 vendor 中的框架新增运行时行为。 + +## 曾考虑的替代方案 + +**继续使用 `tsx`。** 不采用,因为 `tsx`/esbuild 会继续负责 TypeScript 转换,本启动链路无法因此证明 Node 原生转换可用。 + +**让源码入口通过包导出加载构建后的 `lib/`。** 不采用,因为这会混合 source plane 与 artifact plane;零构建开发启动可能读取陈旧产物或直接失败。 + +**无条件应用根 tsconfig `paths`。** 不采用,因为这会让未声明的跨包 import 和 Cordis 插件继续成功解析,从而掩盖 manifest 与实际运行图之间的不一致。 + +**在自定义 loader 内转换 import。** 不采用,因为感知类型的源码改写会重新引入编译器式转换,并让 loader 而非 Node 负责执行 TypeScript。使签入仓库的源码兼容 Node,可以让启动边界保持显式。 + +## 后果 + +- TUI/无头界面保留零构建源码回路,Web 仍会在启动 CLI 源码入口前构建前端产物。TypeScript 语法只经过 Node 原生转换;仅处理 URL 的 loader 使用 checkout 根目录的开发依赖,不增加 CLI 运行时依赖。 +- workspace package import 和 Cordis 配置依赖都必须在解析方 manifest 中明确声明;静态门禁防止配置先于依赖落地,额外依赖不构成错误。 +- 插件 import 失败不再留下退出码为 0 的残缺应用;最终错误同时说明 Cordis 启动失败及具体插件名,Loader 的原始错误仍会保留在更早的日志中。 +- CLI 源码图中的 vendor 源码必须与 Node 的 transform-types 模块语义兼容;本地修改记录明确了上游同步义务。 +- CI 的 `lib` 模式、测试/e2e 启动器和其他示例启动器保留各自现有策略;该原生源码 loader 只覆盖 `dsh` CLI 应用链路。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml new file mode 100644 index 0000000000..ab618103c2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml @@ -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-07-29-dsh-source-launch-tsx-esm.md +2026-07-29-dsh-source-launch-tsx-esm.md: 93fbb248b45efde37d5fbdb1ec4b812ab3332088 +2026-07-29-dsh-source-launch-tsx-esm.zh.md: 48f410bd846e5808cc95180279348a0ac5ba1c95 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md new file mode 100644 index 0000000000..93fbb248b4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -0,0 +1,38 @@ +# Agent Note: dsh source launch through the tsx ESM hook + +Status: implemented + +English | [中文](2026-07-29-dsh-source-launch-tsx-esm.zh.md) + +> Supersedes [native TypeScript source launch](2026-07-28-dsh-native-typescript-source-launch.md): Node removed the capability that decision was built on. + +## Problem + +The [native source-launch decision](2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently. + +Startup latency also mattered: the off-thread `module.register()` hooks worker serialized every resolution across threads (~440ms of `makeSyncRequest` wait during TUI boot), and the full tsx default (`--import tsx`) pays ~0.4s in its CJS hook's resolution amplification. + +## Decision + +The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. `bin/dsh`, the root `dsh`/`demo:tui`/`demo:web` scripts, and the Code Mode TUI overlay use the same vector; `bin/dsh` references the hook and tsconfig by absolute checkout paths (bare `tsx/esm` does not resolve from an arbitrary cwd) and pins `TSX_TSCONFIG_PATH` to the root tsconfig. The CJS hook stays off because the CLI source graph is ESM-only; measured TUI time-to-banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain. + +`scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-tasks` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; fixed alongside this change.) + +The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`. + +## Alternatives considered + +**Keep the native chain on Node ≤25 and branch by version.** Rejected: two transformation semantics (amaro versus esbuild) diverge on edge syntax, the launcher grows version probing, and the node-compat matrix must cover both paths — heavy maintenance for an experimental flag that already changed under us. amaro also rejects the `@Inject` decorators `vendor/hmr` uses, so the native path could not boot the shipped default TUI config anyway. + +**Make the source graph erasable-only so Node 26 strip mode accepts it.** Rejected: parameter properties and value namespaces pervade vendored Cordis/cosmokit/loader/schemastery; rewriting them is unbounded churn re-applied on every vendor sync. + +**A repo-owned in-thread loader (`module.registerHooks()` + esbuild or `@swc/core` transform).** Rejected for now: prototypes measured ~0.45s (esbuild path untested end-to-end; SWC breaks on `vendor/hmr`'s decorator + namespace merge in both decorator modes), but it means owning transform correctness and a resolve hook that tsx already provides. Revisit only if the ~0.3s gap becomes a real cost; the profiling evidence lives in the PR discussion. + +**Run built `lib/` for Node 26 and keep native for 24.** Rejected: loses the zero-build development loop on the newest Node line and mixes source and artifact planes. + +## Consequences + +- One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line. +- TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode. +- The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures. +- Startup improves ~0.4s over the full tsx default (`demo:headless` and ACP keep `--import tsx`; their graphs were not audited for CJS-hook dependence and their launch latency is not on the interactive path). diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md new file mode 100644 index 0000000000..48f410bd84 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -0,0 +1,38 @@ +# Agent Note: dsh 通过 tsx ESM hook 源码启动 + +Status: implemented + +[English](2026-07-29-dsh-source-launch-tsx-esm.md) | 中文 + +> 取代[原生 TypeScript 源码启动](2026-07-28-dsh-native-typescript-source-launch.md):Node 移除了该决策所依赖的能力。 + +## 问题 + +[原生源码启动决策](2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。 + +启动延迟同样是问题:off-thread 的 `module.register()` hooks worker 把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整 tsx 默认形态(`--import tsx`)的 CJS hook 解析放大要多付约 0.4s。 + +## 决策 + +`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only hook 同时负责 TypeScript 转换与 tsconfig `paths` 投影。`bin/dsh`、根目录的 `dsh`/`demo:tui`/`demo:web` 脚本以及 Code Mode TUI overlay 使用同一向量;`bin/dsh` 以 checkout 的绝对路径引用 hook 与 tsconfig(裸的 `tsx/esm` 无法从任意 cwd 解析),并将 `TSX_TSCONFIG_PATH` 固定到根 tsconfig。CJS hook 保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 + +`scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader "仅为已声明运行时依赖映射 workspace import" 的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest 走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;已随本变更修复。) + +node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块 hook 或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。 + +## 备选方案 + +**在 Node ≤25 保留原生链并按版本分叉。** 拒绝:两套转换语义(amaro 与 esbuild)在边缘语法上会分歧,启动器要加版本探测,node-compat 矩阵要覆盖两条路径——为一个已经变动过的 experimental flag 付出沉重维护。而且 amaro 也不支持 `vendor/hmr` 使用的 `@Inject` 装饰器,原生路径本来就无法启动随附的默认 TUI 配置。 + +**把源码图改成 erasable-only 以适配 Node 26 strip 模式。** 拒绝:参数属性与值 namespace 遍布 vendor 的 Cordis/cosmokit/loader/schemastery;改写是无界 churn,且每次 vendor sync 都要重做。 + +**仓库自有的同线程 loader(`module.registerHooks()` + esbuild 或 `@swc/core` 转换)。** 暂拒:原型实测约 0.45s(esbuild 路径未端到端验证;SWC 在 `vendor/hmr` 的装饰器 + namespace 合并上两种装饰器模式都会崩),但意味着自行负责转换正确性和一个 tsx 已经提供的 resolve hook。仅当约 0.3s 的差距成为真实成本时再重启;profiling 证据在 PR 讨论中。 + +**Node 26 运行构建产物 `lib/`,24 保留原生。** 拒绝:在最新 Node 版本线上失去零构建开发循环,且混淆源码面与产物面。 + +## 结果 + +- 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。 +- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 note "证明 Node 原生转换可用" 的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。 +- 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。 +- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 与 ACP 保持 `--import tsx`:其依赖图未就 CJS hook 依赖性做审计,且其启动延迟不在交互路径上)。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml new file mode 100644 index 0000000000..56832b37e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml @@ -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-28-load-pre-identity-session-messages.md +2026-07-28-load-pre-identity-session-messages.md: 2901527658421b37576bdf5b49e66829104a3b41 +2026-07-28-load-pre-identity-session-messages.zh.md: 61d57ac9f3318299b63faa659b6d155e8e89fae3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md new file mode 100644 index 0000000000..2901527658 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md @@ -0,0 +1,38 @@ +# Agent Note: Load sessions persisted before message identity + +Status: implemented + +English | [中文](2026-07-28-load-pre-identity-session-messages.zh.md) + +## Problem + +The identified immutable message change replaced four durable event payloads with complete message values. Existing v0 JSONL and SQLite sessions still held the immediately preceding shapes: direct `content`/`source` on user and steering events, `content`/`provenance` on assistant events, and `callId`/`content`/`isError` on tool results. Their headers still matched `SESSION_FORMAT_VERSION`, but current-shape validation rejected them before resume could construct a live `Session`. + +Changing the message representation without a version bump made those logs indistinguishable at the header level from current v0 logs. The runtime needs a narrow import rule that restores data created by the supported first-party backends without weakening validation for unrelated obsolete or malformed events. + +## Decision + +`PersistenceCoordinator` normalizes the four exact pre-identity message payloads after backend decoding and before current message validation. It wraps their existing semantic fields in the current role-specific message shape and assigns `legacy-message::` as the deterministic imported `MessageId`. A legacy `tool/result` content replacement inherits the imported id of its replacement target, preserving the current content-only rewrite invariant. + +The same normalization runs for `load`, `inspect`, an ownerless loaded state claiming its live session, and HMR prefix adoption. Prefix comparisons therefore compare the live current-shape seed with the same normalized stored view. Current-looking wrappers with missing or invalid fields are not repaired, and unsupported event vocabulary, request headers, versions, and surface relations retain their existing rejection paths. + +The upgrade is read-only. Stored legacy records remain unchanged; a resumed session appends only current-shape events after them. Deterministic identities make repeated loads and a mixed legacy/current log reproduce the same message ids without a backend-specific rewrite transaction. + +## Alternatives considered + +**Reject the logs under the pre-release compatibility stance.** This is the default for unrelated v0 churn, but it strands real first-party sessions even though every old field maps unambiguously to the current message representation. + +**Rewrite the complete stored log in place.** This would canonicalize the artifact but violate the append-only storage contract, require separate atomic replacement mechanisms for JSONL and SQLite, and expand a read compatibility fix into a migration system. + +**Mint random ids on each load.** The messages would satisfy the type shape but lose stable identity across inspect, resume, restart, and mixed legacy/current appends. + +## Consequences + +Pre-identity JSONL and SQLite sessions resume with their original message content, sources, provider provenance, tool correlation, errors, metadata, and surface replacements. The returned events are otherwise indistinguishable from current imported message snapshots and remain deeply frozen. + +This is one explicit same-version import exception, not a general v0 compatibility layer. Adding another exception requires another complete, unambiguous mapping at the persistence boundary; malformed current data continues to fail rather than being guessed into validity. The shared coordinator contract exercises the upgrade against the in-memory reference, JSONL, and SQLite backends, including deterministic reload and tool-result replacement identity. + +## Related + +- [Create every message as an identified immutable value](../architecture/2026-07-28-identified-immutable-message-values.md) — owns the current message identity and immutability contract. +- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns the append-only backend and resume boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md new file mode 100644 index 0000000000..61d57ac9f3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 加载消息标识机制引入前持久化的会话 + +Status: implemented + +[English](2026-07-28-load-pre-identity-session-messages.md) | 中文 + +## 问题 + +带标识的不可变消息变更将四种持久事件载荷替换为完整消息值。现有的 v0 JSONL 和 SQLite 会话仍保留紧邻该变更之前的形状:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些会话的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前形状验证会拒绝它们,导致恢复流程无法构造实时 `Session`。 + +消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前的 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的第一方后端所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。 + +## 决策 + +`PersistenceCoordinator` 会在后端解码之后、当前消息验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息形状,并为其分配确定性的导入 `MessageId`:`legacy-message::`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id,从而保持当前仅改写内容的不变量。 + +同一项规范化也用于 `load`、`inspect`、无 owner 的已加载状态认领其实时会话,以及 HMR(热模块替换)前缀接管。因此,前缀比较会将实时的当前形状 seed 与同一份规范化存储视图进行比较。看似当前形状、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。 + +这项升级只发生在读取时。存储中的旧版记录保持不变;会话恢复后,只会在其后追加当前形状的事件。确定性标识使重复加载以及新旧形状混合的日志无需执行后端专用的重写事务,也能复现相同的消息 id。 + +## 考虑过的替代方案 + +**按照预发布兼容性立场拒绝这些日志。** 这是处理其他 v0 形状变动的默认方式,但即使每个旧字段都能明确映射到当前消息表示,它仍会导致真实的第一方会话无法恢复。 + +**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储契约,还需要为 JSONL 和 SQLite 分别实现原子替换机制,并将一次读取兼容性修复扩大为迁移系统。 + +**每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。 + +## 后果 + +消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、提供方溯源信息、工具关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。 + +这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享协调器契约会针对内存参考实现、JSONL 和 SQLite 后端验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。 + +## 相关 + +- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.md):该记录负责当前的消息标识与不可变性契约。 +- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.md):该记录负责仅追加后端与恢复边界。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml new file mode 100644 index 0000000000..fb5ee5debc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml @@ -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-29-pnpm-setup-runner-isolation.md +2026-07-29-pnpm-setup-runner-isolation.md: 743535d0394cbea0374c412ba6968910ce858de4 +2026-07-29-pnpm-setup-runner-isolation.zh.md: 1e51070f88dead17b9d3f5625e337c558786aba2 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md new file mode 100644 index 0000000000..743535d039 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -0,0 +1,27 @@ +# Agent Note: Isolate pnpm setup per GitHub Actions runner + +Status: implemented + +English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) + +## Problem + +`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773), three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression. + +## Decision + +Every `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). + +[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. + +## Alternatives considered + +**Serialize failover jobs.** Rejected because it discards the six-runner pool's intended parallelism and turns an action-local directory collision into queueing across otherwise independent jobs. + +**Assign a separate Unix user to every runner service.** This would also separate `HOME`, but it moves the invariant into external VM provisioning and complicates ownership of the deliberately shared persistent pnpm store. The workflow already receives a runner-private temporary directory. + +**Retry failed setup steps.** Rejected because retries only reduce the observed collision rate; another concurrent setup can remove the same shared directory again. + +## Consequences + +pnpm's executable installation is ephemeral and isolated per runner, while package downloads still use the configured persistent or cached store. Hosted jobs use the same explicit destination without changing cache policy. The workflow carries three extra configuration lines per setup step, and the regression test must be updated only if pnpm provisioning intentionally moves to a different isolation mechanism. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md new file mode 100644 index 0000000000..1e51070f88 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 按 GitHub Actions runner 隔离 pnpm 设置 + +Status: implemented + +[English](2026-07-29-pnpm-setup-runner-isolation.md) | 中文 + +## 问题 + +`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在 [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773) 中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。 + +## 决策 + +[主 CI 工作流](../../../../.github/workflows/ci.yml)中的每个 `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 提供机制决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md)。 + +[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 + +## 曾考虑的替代方案 + +**串行执行故障切换作业。** 否决:这会牺牲由六个 runner 组成的池所具备的预期并行能力,并把 action 内部的目录冲突变成原本相互独立作业之间的队列等待。 + +**为每个 runner 服务分配独立的 Unix 用户。** 这同样能够隔离 `HOME`,但会把该不变量转移到外部 VM 配置中,并使刻意共享的持久 pnpm store 的所有权变得复杂。工作流已经获得 runner 专属临时目录。 + +**重试失败的设置步骤。** 否决:重试只能降低观测到的冲突发生率;另一个并发设置过程仍可能再次删除同一个共享目录。 + +## 后果 + +pnpm 可执行文件采用临时安装,并按 runner 隔离;包下载仍使用已配置的持久或缓存 store。托管作业使用相同的显式目标目录,不改变缓存政策。工作流中的每个设置步骤因此增加三行配置;只有在 pnpm 提供机制有意迁移到另一种隔离机制时,才需要更新回归测试。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml new file mode 100644 index 0000000000..5689b73bf8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml @@ -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-29-sticky-composer-conversation-scroll.md +2026-07-29-sticky-composer-conversation-scroll.md: 7ceae95dafffdb756ef49bb5612cd4e711eb59ca +2026-07-29-sticky-composer-conversation-scroll.zh.md: d925d82f94635b5fe67b0be119c041d003def393 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md new file mode 100644 index 0000000000..7ceae95daf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md @@ -0,0 +1,29 @@ +# Agent Note: Fixed header, sticky composer inside the transcript scrollport + +Status: implemented + +English | [中文](2026-07-29-sticky-composer-conversation-scroll.zh.md) + +## Problem + +The active conversation column split scrolling: the chat (and trajectory) view owned `overflow-y: auto`, while the composer stack sat as a sibling below that scrollport. A wheel gesture over the stats line or input therefore hit a non-scrolling region and did nothing — the transcript only moved when the pointer was over the message list. Long drafts made it worse: the textarea is itself a scrollport, so wheel over the composer could be trapped there. The session header must occupy the top of the column as ordinary chrome (not `position: sticky` inside the scrollport), while the composer must stick to the bottom of the same scrollport as the transcript so wheel over the footer moves the flow. + +## Decision + +While a session exists, `ConversationRoot` always supplies a `wrapActiveBody` owner callback that wraps the view ring in a `data-conversation-scroll` body and places a `data-composer-seat` around the whole `'conversation.composer'` chain output (fallback + elected overlay siblings from `overlay: true`). Active CSS sticks that seat with `position: sticky; bottom: 0` so Question/Approval takeovers stay visible when the user is not pinned to the floor; hero CSS centers the fallback stack inside the scroll body. `ConversationSession` keeps a chrome-hidden header + body shell while blank so that tree seat does not change on the first send. The session header remains `flex: none` column chrome above the scrollport when visible. ChatView and Trajectory/Waterfall keep a local scroller only when mounted outside that host (unit tests); under the host they set `overflow: visible` and resolve bottom-follow / prepend anchoring through `closest('[data-conversation-scroll]')`. + +Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host. + +## Alternatives considered + +**Sticky header and sticky composer inside one column scrollport.** Rejected for the header: it must occupy the top as fixed layout chrome, not participate in the scrollport's sticky layer. + +**Fixed flex-none composer below the scrollport with wheel forwarding.** Rejected: the product requires the composer to stick inside the transcript scrollport so the footer is part of that scroll hit-testing surface, not a sibling that only forwards deltas. + +**Portal the composer into ChatView's scroller.** Rejected: the composer is shared across view tabs; the wrap target is the Session body owned by the resident shell. + +**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned. + +## Consequences + +Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Hero → active keeps the same textarea DOM node (assembled slash-flow snapshot) and the InputHub draft. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md new file mode 100644 index 0000000000..d925d82f94 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 固定标题栏,sticky 编辑器位于 transcript 滚动容器内 + +Status: implemented + +[English](2026-07-29-sticky-composer-conversation-scroll.md) | 中文 + +## Problem + +活跃会话列把滚动拆成两段:聊天(以及 trajectory)视图自有 `overflow-y: auto`,编辑器栈则作为该滚动容器的兄弟节点坐在下方。指针落在统计行或输入区上时,滚轮打在不可滚动区域上因而毫无效果——只有指针在消息列表上时 transcript 才会移动。草稿变长时更糟:textarea 本身也是滚动容器,编辑器上的滚轮可能被截在那里。会话标题栏必须以普通 chrome 占据列顶(不能在滚动容器内 `position: sticky`),而编辑器必须与 transcript 贴在同一滚动容器底部,使页脚上的滚轮能带动内容流动。 + +## Decision + +只要存在会话,`ConversationRoot` 就会始终提供 `wrapActiveBody` owner 回调,将视图环包进 `data-conversation-scroll` 主体,并用 `data-composer-seat` 包住整条 `'conversation.composer'` chain 输出(`overlay: true` 下的 fallback 与选举出的 overlay 兄弟节点)。活跃阶段 CSS 以 `position: sticky; bottom: 0` 钉住该 seat,使用户未贴底时 Question/Approval 接管仍可见;hero CSS 在滚动主体内居中 fallback 栈。`ConversationSession` 在 blank 时保留隐藏 chrome 的 header + body 壳,使首次发送时树座位不变。可见时会话标题栏仍是滚动容器之上的 `flex: none` 列 chrome。ChatView 与 Trajectory/Waterfall 仅在宿主之外挂载时(单元测试)保留本地 scroller;位于宿主下时设为 `overflow: visible`,并通过 `closest('[data-conversation-scroll]')` 解析贴底跟随与前置锚定。 + +会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。 + +## Alternatives considered + +**标题栏与编辑器都在同一列滚动容器内 sticky。** 标题栏否决:它必须作为固定布局 chrome 占据顶部,而不是参与滚动容器的 sticky 层。 + +**滚动容器下方 flex-none 固定编辑器并转发滚轮。** 否决:产品要求编辑器 sticky 在 transcript 滚动容器内,使页脚成为该滚动命中面的一部分,而不是仅转发增量的兄弟节点。 + +**把编辑器 portal 进 ChatView 的 scroller。** 否决:编辑器跨视图标签共享;包装目标是常驻壳拥有的 Session 主体。 + +**把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。 + +## Consequences + +在页脚上滚轮会滚动 transcript;可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。hero → active 保持同一 textarea DOM 节点(assembled slash-flow 快照)以及 InputHub 草稿。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml new file mode 100644 index 0000000000..060de23ba3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml @@ -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-29-web-details-session-lifecycle.md +2026-07-29-web-details-session-lifecycle.md: d9e0255768f165bed0631b9324e971b57ec7dcae +2026-07-29-web-details-session-lifecycle.zh.md: 09452ba80ff240ddca76df239b40ea661566f8e2 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md new file mode 100644 index 0000000000..d9e0255768 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md @@ -0,0 +1,29 @@ +# Agent Note: Web details follow the current Session lifecycle + +Status: implemented + +English | [中文](2026-07-29-web-details-session-lifecycle.zh.md) + +## Problem + +The details entry is Session-scoped, but its preferred grid width is root-scoped. Selecting a different Session replaced the details content without closing that root preference, so the new owner inherited stale viewing geometry. Hero and other unselected states render no Session-scoped details; they need a derived zero track without becoming false owners in the comparison. + +## Decision + +`AppFrame` reads the current Session id and its `blank` summary flag from the authoritative Session projection. It records the last non-blank selected id only when that Session can own details, so hero and other unselected states neither trigger closure nor replace the last Session owner; their rendered details track derives as zero without changing the stored preference. The first Session keeps the default details width; returning to the same Session restores its current width; selecting a different Session closes the root-scoped details preference through the layout store before paint. The per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md). + +The layout store is transient and starts details at its default width. It neither reads nor writes `localStorage`, so reload resets both panel widths and needs no Session-baseline exception. Manual close and reopen inside one unchanged Session retain their existing behavior. The lifecycle effect changes neither the [Workspace-owned New Session flow](../feature/2026-07-25-workspace-ui-product-flow.md), composer drafts, Session navigation, nor concession-chain resizing. + +## Alternatives considered + +**Close details in the New Session click handler.** Rejected because an unselected surface has no Session-scoped details and must not mutate geometry. Closure belongs to the later comparison between two defined Session owners. + +**Persist panel geometry per Session.** Rejected because the product contract needs stale context removed, not a new map of remembered widths. Per-Session geometry would also reopen details when users return, contrary to the chosen close-on-leave behavior. + +**Preserve persisted layout after the Session baseline is ready.** Rejected because it duplicates startup lifecycle in a presentation component solely to validate stale viewing state. Transient defaults make reload deterministic without a readiness flag. + +**Treat every current-projection change as a Session switch.** Rejected because startup materialization, hero, clearing selection, and invalidation are not transitions between two Session owners. + +## Consequences + +Details is open by default, including when the first Session materializes. Switching to a different Session forgets the dragged details width because close writes zero and reopen uses the contract default. Unselected states derive a zero rendered track while leaving the preferred geometry unchanged; returning to the same Session through one of those states restores its width. Reload forgets sidebar and details geometry. The layout behavior test covers initial defaults, first materialization, direct and hero-mediated Session switches, same-Session return, and the absence of layout storage; the keyless browser e2e drives the same owner transitions through the shipped composition while checking the full grid track and browser errors. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md new file mode 100644 index 0000000000..09452ba80f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 详情栏遵循当前会话生命周期 + +Status: implemented + +[English](2026-07-29-web-details-session-lifecycle.md) | 中文 + +## 问题 + +详情入口由会话作用域拥有,而其首选网格宽度由根作用域拥有。选择不同会话时,系统会替换详情内容,却不会关闭根作用域的该首选宽度,因此新 owner 会继承陈旧的查看几何信息。hero 和其他未选中状态不会渲染会话作用域的详情;其轨道需派生为零宽度,但不能因此在比较中成为伪 owner。 + +## 决策 + +`AppFrame` 从权威会话投影读取当前会话 id 及其摘要中的 `blank` 标志。它只在该会话能够拥有详情时记录最后一个选中的非 blank 会话 id,因此 hero 和其他未选中状态既不会触发关闭,也不会替换最后一个会话 owner;这些状态下,详情栏轨道的渲染宽度派生为零,但存储的首选宽度不变。首个会话保留详情栏的默认宽度;返回同一会话时恢复其当前宽度;选择不同会话时,系统会先通过布局 store 关闭根作用域存储的详情栏首选宽度,再进行绘制。逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。 + +布局 store 是瞬时状态,详情栏以默认宽度启动。它既不读取也不写入 `localStorage`,因此重新加载会重置两个面板的宽度,无需会话基线例外。在同一个未变化的会话内手动关闭和重新打开详情栏,仍保持原有行为。该生命周期 effect 不改变 [Workspace 拥有的 New Session 动线](../feature/2026-07-25-workspace-ui-product-flow.md)、composer 草稿、会话导航或让步链缩放。 + +## 考虑过的替代方案 + +**在 New Session 点击处理器中关闭详情栏。** 之所以否决:未选中表面没有会话作用域的详情,不得修改几何信息。详情栏是否关闭,应由随后对两个已定义会话 owner 的比较决定。 + +**按会话持久化面板几何信息。** 之所以否决:产品契约需要移除陈旧上下文,而不是新增一张保存各宽度的映射。按会话保存几何信息还会在用户返回时重新打开详情栏,与选定的离开即关闭行为相悖。 + +**在会话基线就绪后保留持久化布局。** 之所以否决:这会仅为验证陈旧的查看状态,在呈现组件中重复实现启动生命周期。瞬时默认值无需就绪标志即可使重新加载具有确定性。 + +**将当前投影的每次变化都视为会话切换。** 之所以否决:启动时的物化、hero、清除选中项和选中状态失效都不是两个会话 owner 之间的过渡。 + +## 后果 + +详情栏默认打开,首次会话物化时亦然。切换到不同会话会忘记拖动后的详情宽度,因为关闭操作会写入零值,重新打开时则使用契约默认值。未选中状态会将轨道的渲染宽度派生为零,同时保持首选几何信息不变;经由这些状态返回同一会话时,会恢复其宽度。重新加载会忘记侧边栏与详情栏的几何信息。布局行为测试覆盖初始默认值、首次物化、直接及经 hero 中转的会话切换、返回同一会话,以及不存在布局存储的情况;无密钥浏览器 e2e 则通过已交付的组合驱动相同的 owner 过渡,同时检查完整网格轨道和浏览器错误。 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml index 64d1f57f51..774b9cc52e 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml @@ -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-06-22-acp-subagent-backend.md: a45ce5e34873249969bbe4dabb87a89d10246b3d -2026-06-22-acp-subagent-backend.zh.md: 3b6a11efc1ae0427eb5ce3b30029b77b00d6801a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +2026-06-22-acp-subagent-backend.md: 5f12aa1c08d4f4cfaa35f2f7f4b09ad341c3eae8 +2026-06-22-acp-subagent-backend.zh.md: 61359246b0c552f6126cd82ae843d489de809a38 diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md index a45ce5e348..5f12aa1c08 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -34,7 +34,7 @@ ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `ma ### Security: scrubbed child environment -The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error. +The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|PASSWORD|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error. ## Testing diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md index 3b6a11efc1..61359246b0 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md @@ -34,7 +34,7 @@ ACP `StopReason` → harness `SubagentStopReason`:`end_turn`→`completed`、` ### 安全:清洗子进程环境 -子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|SECRET|TOKEN/i`)默认不转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 `config.env` 显式提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。 +子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|PASSWORD|SECRET|TOKEN/i`)默认不转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 `config.env` 显式提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 `error` 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml index cb3d84e8a9..ca5cc25241 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml @@ -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-05-skill-system.md -2026-07-05-skill-system.md: 4fc621a9fdfa8042ebf3eb1975f0930cb1bf116c -2026-07-05-skill-system.zh.md: 0dbebd211a1fa9e434d3f0a189c936f2b1c76574 +2026-07-05-skill-system.md: 242650ec8ba64fd0801a958711d5790fae07b259 +2026-07-05-skill-system.zh.md: da5f4af4b2f8bc0144be0a7ed608de7edd9a9947 diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.md index 4fc621a9fd..242650ec8b 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.md @@ -18,13 +18,13 @@ Provider plugins register synchronously during `apply()`. Provider membership is The local provider scans cwd-sensitive project roots, custom roots, and user roots in first-wins rank order: project `.dsh`, project `.agents`, `customSkillDirs`, user `.dsh`, then user `.agents`. The user `.dsh/skills` scan skips `.system` so a system-owned directory is not treated as normal user content. DeepSeek Harness does not ship built-in system skills; embedded or remote providers supply additional skills when configured. -Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names are kebab-case. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset. +Each skill is either `/SKILL.md` or `.md` with YAML frontmatter. `name` and `description` are required; `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable` are optional. Names are kebab-case. The invocation fields project into a typed nested policy as defined by the [independent model and user invocation decision](2026-07-28-skill-invocation-policy.md); the parser rejects the old camel-case spellings. YAML frontmatter is parsed with the `yaml` package instead of `js-yaml` or a hand-written parser: `yaml` is the already-declared modern parser for this package's limited frontmatter needs, and a narrow parser would either reject valid YAML users expect to work or grow into an unreviewed YAML subset. Local skill filesystem I/O goes through `ctx.fs` when a filesystem service is loaded: project-root lookup probes `.git` with `resolve` and `stat`, root discovery uses `listDir`, and skill reads use `readText`. The Node filesystem remains a fallback for minimal contexts that mount `dsh-skill-local` without the fs seam. Missing roots, unreadable or malformed skill files, and transient provider `list()` failures degrade to warn-and-skip so one bad source does not make every agent request fail; malformed candidates still fail fast because they are provider contract violations. `dsh-tool-skill` injects one durable user-role `` catalog as a sourced `user/message` at the session's first `agent/step`, and only when that agent's tool view resolves this plugin's exact `skill` registration. The catalog contains sorted skill name and description only; it excludes bodies, paths, sources, providers, and routing hints. Descriptions are whitespace-normalized, XML-escaped, and capped by `catalogDescriptionMaxLength`, whose default is `500` and minimum is `3`. Full skill bodies are never included in the catalog. (The catalog originally rode the request-only [session-prefix seam](../../archived/feature/2026-07-07-session-prefix.md), archived; the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) moved it into durable history.) -The `skill({ name })` tool loads one full skill for the current agent cwd and returns a tool result containing ``, ``, and ``. `resourceBase` supplies a directory, URL, or opaque provider-managed base for explicitly referenced scripts, references, and assets; resources load only as needed, without directory enumeration. An unresolved name reports that the skill is unknown or no longer available; invalid names and skills marked `disableModelInvocation` retain distinct tool errors. The tool result is the model-visible disclosure path. +The registry's `list()` returns every winning summary, while model and user consumers apply the invocation predicates owned by the [independent invocation-policy decision](2026-07-28-skill-invocation-policy.md). The `skill({ name })` tool loads one model-invocable skill for the current agent cwd and returns a tool result containing ``, ``, and ``. `resourceBase` supplies a directory, URL, or opaque provider-managed base for explicitly referenced scripts, references, and assets; resources load only as needed, without directory enumeration. An unresolved name reports that the skill is unknown or no longer available; invalid names and skills with `invocation.modelInvocable: false` retain distinct tool errors. The tool result is the model-visible disclosure path. The data structures and catalog/tool contract are documented in [skills.md](../../../../docs/core-data-structures/skills.md), with service signatures in the generated [services catalog](../../../../docs/cordis-catalog/services.md). @@ -52,4 +52,4 @@ The catalog is deterministic for a fixed root set and runtime registration revis ## Deferred -Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields, and the `user-invocable` frontmatter field is likewise unparsed. Direct user invocation itself ships as a consumer-side affordance instead: the TUI front door offers a manual `/skill:` command over the registry's existing `list()` and `get()` methods, without a registry, provider, or tool contract change — see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md). +Forked skill contexts (`context: fork`), parameter declarations and hints (`arguments` and `argument-hint`), and per-skill tool constraints (`allowed-tools` and `disallowed-tools`) are outside the shipped contract. The registry, local provider, and model-facing tool do not parse, advertise, or enforce these fields. Direct user invocation ships as a TUI affordance over the shared invocation policy and trusted `get()` primitive; see [the TUI skill slash command](2026-07-21-tui-skill-slash-command.md). diff --git a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md index 0dbebd211a..da5f4af4b2 100644 --- a/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md @@ -18,13 +18,13 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和 本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。DeepSeek Harness 不随附内置系统 skill;嵌入式或远程提供方在配置后提供额外 skill。 -每个 skill 是 `/SKILL.md` 或带 YAML frontmatter 的 `.md`。`name` 和 `description` 为必填;`whenToUse`、`disableModelInvocation` 和 `metadata` 为可选。名称采用 kebab-case。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 +每个 skill 是 `/SKILL.md` 或带 YAML frontmatter 的 `.md`。`name` 和 `description` 为必填;`whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable` 为可选。名称采用 kebab-case。调用字段会投影到类型化的嵌套策略中,具体由[模型与用户独立调用决策](2026-07-28-skill-invocation-policy.md)定义;解析器会拒绝旧的驼峰拼写。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。 本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件、以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使一个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。 `dsh-tool-skill` 在会话的第一个 `agent/step` 注入一个持久化的 user-role `` 目录,作为带来源的 `user/message`,且仅当该 agent 的工具视图解析到本插件精确的 `skill` 注册时才注入。该目录仅包含排序后的 skill 名称与描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。完整的 skill 正文从不包含在目录中。(目录最初通过仅请求的[会话前缀 seam](../../archived/feature/2026-07-07-session-prefix.md)(已归档)传递;[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)将其移入持久化历史。) -`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill,返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。 +注册表的 `list()` 返回全部胜出摘要,而模型与用户消费方应用[独立调用策略决策](2026-07-28-skill-invocation-policy.md)定义的调用判定。`skill({ name })` 工具为当前 agent cwd 加载一个模型可调用的 skill,返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。 数据结构与目录/工具契约记录在 [skills.md](../../../../docs/core-data-structures/skills.md) 中,服务签名见生成的[服务目录](../../../../docs/cordis-catalog/services.md)。 @@ -52,4 +52,4 @@ agent-core 主干包含一个目录贡献者、一个本地提供方和一个面 ## 延后 -Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段,`user-invocable` frontmatter 字段同样不会被解析。直接用户调用本身则作为消费方层面的能力交付:TUI 前门基于注册表现有的 `list()` 与 `get()` 方法提供手动 `/skill:` 命令,无需变更注册表、提供方或工具契约——见 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)。 +Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段。直接用户调用作为 TUI 功能交付,基于共享调用策略和受信的 `get()` 原语;见 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)。 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index d225ddf2d5..51ddb7e70f 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml @@ -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-06-approval-seam.md: 70ccd4d486ad6e0126fa2eb638a064e9fc89bba6 -2026-07-06-approval-seam.zh.md: d218f79888957735305db14cd97cc74480297d29 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-approval-seam.md +2026-07-06-approval-seam.md: efb4159d736779af28edc1ae6091de4669c92f31 +2026-07-06-approval-seam.zh.md: 9a4656f30a43473fe90cde9c56d45f48943e5d10 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index 70ccd4d486..efb4159d73 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -123,7 +123,7 @@ Costs and accepted limits: - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. - **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. -- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). +- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. A `'never'` parent seeds that override into each in-process child's log ([decision](2026-07-25-subagent-policy-inheritance.md)), so the child is told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). - **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when an answerer unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does a client get approval context?** The request carries the exact `callId` and the asker's human-readable `reason`; channel adapters may correlate richer tool-call state without duplicating arguments in the approval seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index d218f79888..9a4656f30a 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -123,7 +123,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 - **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。 - **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。 - **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。 -- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 +- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`'never'` 父级会把该覆盖项预置到每个进程内子 agent 的日志中([决策](2026-07-25-subagent-policy-inheritance.md)),因此子 agent 一开始就会得知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 - **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次成功的自动拒绝都会记录审计对。 - **热重载或应答者在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。 - **客户端从哪里获得审批上下文?** 请求携带精确的 `callId` 和发起方的人类可读 `reason`;通道适配器可自行关联更丰富的工具调用状态,而无需在审批 seam 中重复携带参数。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 952027b6e3..9311c6cac7 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -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-06-sandbox.md -2026-07-06-sandbox.md: c3c61ed4539bcbca359f84f3dcc020e0bd41ae79 -2026-07-06-sandbox.zh.md: 39bf92aa20c697d2ad5f4c0926caeff0a0b60a1d +2026-07-06-sandbox.md: 42b78ad8341dd52c4dd146a2207a5ae909d28f1e +2026-07-06-sandbox.zh.md: dfa3349e4d74d6f2c4944414c25fe3726d4a9b5a diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index c3c61ed453..42b78ad834 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -89,10 +89,10 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode — #### Per-session modes: the session log as the store ``` -effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default +effective(session) = findLast(the session's knob events)?.value ?? the composition-config default ``` -The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation both fall out by construction, and no external config store exists anywhere. +The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's log. Restart immunity and multi-session isolation follow from replay, with no external config store. The in-process subagent driver snapshots a parent's explicit override at delegation and seeds a source-tagged event after the child's optional fork prefix, so delegation cannot fall back to a wider default ([decision](2026-07-25-subagent-policy-inheritance.md)). **One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages): diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 39bf92aa20..dfa3349e4d 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -89,10 +89,10 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes #### 按会话模式:会话日志即存储 ``` -effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default +effective(session) = findLast(the session's knob events)?.value ?? the composition-config default ``` -默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离都是构造性的自然结果,且不存在任何外部配置存储。 +默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中。重启免疫与多会话隔离由回放自然保证,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.md))。 **每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`): diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 940908457f..7e9f4d52e6 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -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-07-mcp-client-plugin.md: 2a8ad62e3bad1f2ae47100294f5dba25ceb47d12 -2026-07-07-mcp-client-plugin.zh.md: 9860d5ec805b394eb0c5f59f54349264593a5599 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +2026-07-07-mcp-client-plugin.md: 756a5c4dc9f1152ecb1955d93b8dc47fcf07c661 +2026-07-07-mcp-client-plugin.zh.md: d2ff04d68033402fbb2718f8624e44d35d860db3 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index 2a8ad62e3b..756a5c4dc9 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -149,7 +149,7 @@ A unified `execute` handler for all tools from one MCP server: ### Subprocess environment (stdio transport) -Replicate the `buildChildEnv` + `SENSITIVE_ENV_PATTERN` scrub from `dsh-subagent-acp`: filter ambient env (strip credential-shaped vars matching `/KEY|SECRET|TOKEN/i`), then merge `config.env` on top. Explicit env overrides survive the scrub. +Build the child environment from the subprocess seam's shared `scrubbedParentEnv()` base, which removes ambient names matching `/KEY|PASSWORD|SECRET|TOKEN/i` and ambient `DSH_*` names, then merge `config.env` on top. Explicit env overrides survive the scrub. ### Disconnection / crash diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 9860d5ec80..d2ff04d680 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -149,7 +149,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp ### 子进程环境(stdio 传输) -复用 `dsh-subagent-acp` 的 `buildChildEnv` + `SENSITIVE_ENV_PATTERN` 清洗逻辑:过滤环境变量(剥离匹配 `/KEY|SECRET|TOKEN/i` 的凭证形变量),然后将 `config.env` 覆盖合并到顶层。显式配置的 env 不受清洗影响。 +以子进程服务边界共享的 `scrubbedParentEnv()` 为基础构建子进程环境;该基础环境会移除环境中匹配 `/KEY|PASSWORD|SECRET|TOKEN/i` 的名称以及 `DSH_*` 名称,然后在其上合并 `config.env`。显式配置的 env 覆盖在清洗后仍会保留。 ### 断连 / 崩溃 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml index 0c7363827e..28ca5e0ae4 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml @@ -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-10-parallel-tool-call-execution.md: c67ae61939a3e7974f9bf729058a57f5576308a1 -2026-07-10-parallel-tool-call-execution.zh.md: a80317aa951cbf3a9cae0651348c99712a4193d5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +2026-07-10-parallel-tool-call-execution.md: 19f5dc189821433052edfa72613980a2e94e2cae +2026-07-10-parallel-tool-call-execution.zh.md: e90e357180ae3684500adf7cba41c5fc1dac5743 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index c67ae61939..19f5dc1898 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -10,7 +10,7 @@ An assistant message may contain several sibling `tool-call` blocks. Running the Concurrency is a host scheduling concern, not model-facing tool metadata. The loop needs to decide which calls may overlap without hardcoding tool names or exposing scheduler policy in the JSON schema. -The session log remains authoritative: every started call has an audit event, every started call receives a result, and model history observes results in the original call order regardless of completion order. +The session log remains authoritative: every started call has an audit event, ordinary completion and cancellation pair calls with results, and model history observes committed results in the original call order regardless of completion order. ## Decision @@ -46,7 +46,7 @@ Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-exe Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered. -An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. +An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls. Code Mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler. @@ -66,7 +66,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati ## Verification -Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration. +Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration. Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior. @@ -84,6 +84,8 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over **Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy seam. +**Convert scheduler failures into tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRegistry owns body-invocation state and typed tool outcomes. Internal scheduler failures therefore remain terminal instead of being reclassified as `ABORTED` results. + **Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete. **Use fixed-size windows.** Waiting for every call in one window before starting the next leaves capacity idle behind a slow call. The rolling pool preserves the cap without that delay. @@ -101,3 +103,5 @@ Ordered commits may hold a fast result behind a slow earlier sibling. This prese Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step. Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool. + +A terminal scheduler failure may leave recorded calls without results before the failed step closes. Waiting for live dispatches preserves quiescence without misreporting those internal failures as tool outcomes. diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md index a80317aa95..e90e357180 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -10,7 +10,7 @@ Status: implemented 并发属于宿主调度范畴,不是面向模型的工具元数据。循环需要在不硬编码工具名称、不向 JSON Schema 暴露调度策略的前提下,判断哪些调用可以重叠执行。 -会话日志仍是权威记录:每个已启动的调用都有审计事件,都会获得结果;无论完成顺序如何,模型历史都按原始调用顺序观察结果。 +会话日志仍是权威记录:每个已启动的调用都有审计事件,正常完成和取消都会使调用与结果配对;无论完成顺序如何,模型历史都按原始调用顺序观察已提交的结果。 ## 决策 @@ -46,7 +46,7 @@ Status: implemented 每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。 -如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。 +如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。 Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。 @@ -66,7 +66,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c ## 验证 -单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文,以及中止排空。第一方测试固定每项并行声明。 +单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定每项并行声明。 快照覆盖固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 覆盖固定其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 @@ -84,6 +84,8 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c **公开分阶段方法或调度 waterfall。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在出现另一个消费方之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略 seam 保留了插入点。 +**将调度器故障转换为工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRegistry 负责工具主体调用状态和类型化工具结果。因此,内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。 + **在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。 **使用固定大小的窗口。** 如果在启动下一个窗口前等待当前窗口的每个调用,一个缓慢调用就会使容量闲置。滚动池在保持上限的同时避免了这项延迟。 @@ -101,3 +103,5 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c 并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。 工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。 + +终态调度器故障可能会在故障步骤关闭前留下已记录但没有结果的调用。等待仍在运行的派发可确保完全停稳,而不会将这些内部故障误报为工具结果。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index be4fc8f19f..2de2288506 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -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-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 691ccd4341837d63bb48d27c2a3fb007657fe7ba -2026-07-16-persistent-pty-sessions.zh.md: 86d57f79ee74429542730ebcad60bc9f3c9f15ca +2026-07-16-persistent-pty-sessions.md: d7d06dc8517780a37889e4f94dd0b99475dc5432 +2026-07-16-persistent-pty-sessions.zh.md: 7783fa4b4f3056ab3b3088a4e9618f45d030ccbf diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 691ccd4341..d7d06dc851 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -40,7 +40,7 @@ Agent-scope disposal closes registrations first, then awaits quiescent teardown A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-pty-local` therefore applies two protections before spawning: -- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them. +- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*PASSWORD*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them. - It requires `ctx.sandbox` and the shared `ctx.sandboxPolicy`. At spawn, the backend resolves the owner's effective session mode over the deployment default and wraps the shell argv once; that mode and workspace root remain the process boundary for the PTY lifetime. A write that would change the effective `sandbox/mode` is rejected before commit while the owner has any open PTY or unpublished spawn, with an instruction to wait for creation to settle and close those sessions first; same-effective-mode writes remain valid. The pending reservation spans backend setup through publication, so there is no race in which a wider terminal appears after a downgrade. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass. Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 86d57f79ee..7783fa4b4f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -40,7 +40,7 @@ agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出 注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护: -- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。 +- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*PASSWORD*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。 - 它要求 `ctx.sandbox` 和共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode,并只包装一次 shell argv;该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。只要 owner 有任何已打开的 PTY 或尚未发布的 spawn,任何会改变生效 `sandbox/mode` 的写入都会在提交前被拒绝,并提示先等待创建操作结算,再关闭这些会话;不会改变生效模式的写入仍然有效。这项进行中的预留从后端 setup 持续到发布完成,因此不存在降级后又出现权限更宽的终端这一竞态。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。 沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 76dd488060..401d02aeac 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -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-20-dsh-cli-personal-config.md: 9525aa811d792a918f03a52c21bc273e92fb8be7 -2026-07-20-dsh-cli-personal-config.zh.md: f21d4b1f22b3a3807b6b4155969282343f6048f5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +2026-07-20-dsh-cli-personal-config.md: cc965438214b68078647596af5a28fd666e7bd95 +2026-07-20-dsh-cli-personal-config.zh.md: 02f9c578061e1c25044c377c3ec8f80594275ae7 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 9525aa811d..cc96543821 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -12,7 +12,7 @@ A developer's own preferences — which provider and model the TUI uses, persona Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443): -**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** via the repo's tsx, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry. +**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** through Node's native TypeScript transform plus the app-owned tsconfig-paths loader, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry. **Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim: diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index f21d4b1f22..02f9c57806 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -12,7 +12,7 @@ Status: implemented 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: -**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,用仓库的 tsx **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。 +**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,通过 Node 的原生 TypeScript 转换和应用自身持有的 tsconfig-paths loader **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。 **个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index b09dcbf17d..029cc4e19c 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml @@ -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-21-log-backed-session-titles.md -2026-07-21-log-backed-session-titles.md: 1bd58e35ec625fb0b04c0c119ce425ff30a64881 -2026-07-21-log-backed-session-titles.zh.md: 37ec95efbca334f71d19d2bc3e18c22d50d9b5fb +2026-07-21-log-backed-session-titles.md: 81ac687c6f55dd0ca1eaeb9d84c811edcfe17b5c +2026-07-21-log-backed-session-titles.zh.md: b0c7e9d76a1b9365fa16dcb223b390b5aec3e174 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index 1bd58e35ec..81ac687c6f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -36,9 +36,13 @@ Model providers require explicit word, CJK-character, input-byte, output-token, Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before log acceptance. +### Explicit rename + +`rename(session, title)` accepts a user title synchronously: it normalizes the text under the accepted-title byte limit, supersedes in-flight automatic work, and appends a `session/title` event with the third source kind, `user`. A user-sourced latest title pins the session: `onUserMessage` schedules no automatic revision while it stands, under either cadence. An explicit `refresh()` remains the deliberate unpin — it appends a provider or fallback event over the pinned one whenever a replacement title is derivable (an underivable fallback, e.g. under a tiny byte cap, leaves the pin standing). The Web host exposes this as the `session.rename` unary method (resuming cold sessions first) and returns the normalized title plus its event seq so the client settles its `title` projection cell before the push frame arrives. + ### Forks and consumers -A fork inherits seed title events unchanged, like the rest of its source log. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages. +A fork inherits seed title events unchanged, like the rest of its source log — a pinned (user-sourced) title stays pinned in the child until an explicit refresh. The first-message provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages. `ctx.sessionQuery.readTitle()` folds one live-preferred or persisted log without loading titles during `listSessions()`. The TUI uses the latest title as its header subtitle and sets the terminal window title to `` after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. `session.list` remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses `` only for a selected titled session and otherwise preserves the product title. Consumers reporting agent completion use the core `findLastMessageTurnEnd()` fold, so a later between-turn title record cannot replace the preceding message-triggered outcome. @@ -59,4 +63,4 @@ A fork inherits seed title events unchanged, like the rest of its source log. Th - A fallback appears immediately. Each fresh Web session adds one first-message auxiliary call; other compositions choose whether better titles justify model cost and whether later prompts should retitle a session. - Auxiliary request records and late accepted titles consume event seqs without consuming turn numbers, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change. - One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider. -- Manual rename, deletion, generated-versus-user precedence, search, and list indexing remain outside the capability. +- Deletion (unpinning without an explicit refresh), search, and list indexing remain outside the capability. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index 37ec95efbc..b0c7e9d76a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -36,9 +36,13 @@ Status: implemented 自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq;服务会在日志接受前对其进行规范化并施加字节限制。 +### 显式重命名 + +`rename(session, title)` 同步接受用户标题:按已接受标题的字节上限规范化文本、取代在途自动工作,并追加一条第三种来源 `user` 的 `session/title` 事件。最新标题来源为 user 即钉住该会话:只要它还在,`onUserMessage` 在任一节奏下都不再安排自动修订。显式 `refresh()` 仍是有意的解钉手段——只要能推导出替代标题,它就在被钉住的标题之上追加提供方或回退事件(推导不出回退标题时,例如字节上限过小,钉住状态保持不变)。Web host 将其暴露为 `session.rename` unary 方法(冷会话先恢复),并返回规范化后的标题及其事件 seq,使 client 在推送帧到达前就结算自己的 `title` 投影格。 + ### Fork 与消费方 -与源日志的其他部分相同,fork 会原样继承作为种子的标题事件。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。 +与源日志的其他部分相同,fork 会原样继承作为种子的标题事件——被钉住(user 来源)的标题在子会话中保持钉住,直到显式 refresh。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。 `ctx.sessionQuery.readTitle()` 会折叠一份实时优先或已持久化的日志,而不会在 `listSessions()` 期间加载标题。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 ``。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。`session.list` 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 ``,否则保留产品标题。报告 agent 完成情况的消费方使用核心的 `findLastMessageTurnEnd()` 折叠逻辑,因此后续的轮次间标题记录无法取代此前由消息触发的结果。 @@ -59,4 +63,4 @@ Status: implemented - 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。 - 辅助请求记录和延迟接受的标题会占用事件 seq,但不会占用轮次编号,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV 缓存标识保持不变。 - 单个提供方和每会话单调递增的修订号让释放、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。 -- 手动重命名、删除、生成标题与用户标题的优先级、搜索和列表索引不在此功能范围内。 +- 删除(不经显式 refresh 的解钉)、搜索和列表索引不在此功能范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml index 6d9112d663..4574ce7c75 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml @@ -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-21-tui-skill-slash-command.md: 8370ab61f552a6a60177b6da0b598dd142d21960 -2026-07-21-tui-skill-slash-command.zh.md: 66edec6ecd5c2a974b56e08bd9e924729302cc4a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +2026-07-21-tui-skill-slash-command.md: 872e1f109728731e0d55e81a538c81e794724856 +2026-07-21-tui-skill-slash-command.zh.md: 772e25745ea7ab25f715208a9c6b1d10cf0c6e65 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md index 8370ab61f5..872e1f1097 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md @@ -10,17 +10,17 @@ The [skill system](2026-07-05-skill-system.md) shipped with model-initiated load ## Decision -The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill: [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool and changes no skill-system package contract. +The [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) front door owns a `/skill: [instructions]` command. On submit it loads the named skill and delivers one text block as a user turn — sent with `agent.send()` while idle and `agent.steer()` while running, the same rule as ordinary editor input. The block is `renderSkillInvocation(skill, instructions)`: a `` element wrapping the skill body, preceded by one resource-base line when the provider exposes one, with the user's trailing text appended after a blank line. The command is a TUI-only affordance; it adds no model-facing tool. Its visibility and loading policy comes from the shared [independent model and user skill invocation policy](2026-07-28-skill-invocation-policy.md). The TUI reads the skill service through `ctx.get('skills')`, not a declared injection, because skills mount conditionally: a deployment without the registry keeps a working front door, and `/skill:` there reports that skills are unavailable rather than failing to mount. `createTuiChat` is synchronous while `ctx.skills.list()` is async, so autocomplete seeds the static slash commands immediately and rebuilds the provider with `skill:` entries once the catalog resolves; a resolution that arrives after disposal is dropped, and a rejected lookup keeps the base commands. -Autocomplete lists only model-invocable skills — it is built from `list()`, which omits `disableModelInvocation` skills — while manual submission resolves through `get()`, which the skill registry documents as the trusted-caller path that returns disabled skills too. So a person can load any skill by typing its exact name, but the completion menu never advertises a skill the model is meant not to see. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, and a lookup failure each surface as a transcript notice without sending anything. +Autocomplete filters the invocation-neutral `list()` result with `isUserInvocable`, and manual submission applies the same predicate after trusted `get()` resolves the definition. A user-only skill can therefore appear and load even when model invocation is disabled, while a user-disabled skill is neither advertised nor loadable by exact name. Each completion entry is labeled with its winning source's scope — `(project)` for the `project-` sources, `(user)` for every other source — in the slash-command argument-hint slot, which the menu shows but selection never inserts, so trailing instructions still follow the completed name. An unknown name, an empty name after the prefix, a user-disabled name, and a lookup failure each surface as a transcript notice without sending anything. `renderSkillInvocation` and the resource-base line are the TUI's own, deliberately not reused from `dsh-tool-skill`'s `skill` tool result. The tool wraps a body in ``/``/`` for a *tool result*; a manual invocation is a *user turn*, and coupling the two renderers would force one model-facing shape to serve both surfaces. The cost is two renderers that both format a skill body; the benefit is that each surface's model-facing text evolves independently, and each is pinned where it is produced. ## Alternatives considered -**Add a `user-invocable` frontmatter field and enforce it in the registry.** Rejected for this change. The skill-system note defers that field, and manual invocation does not need it: the TUI is a trusted local caller, so `get()` already authorizes loading any skill, and autocomplete visibility keys off the existing `disableModelInvocation`. A new per-skill field would add a contract to the registry, local provider, and tool with no current consumer beyond visibility, which `disableModelInvocation` already covers. +**Add a `user-invocable` frontmatter field only inside the original TUI change.** Rejected there because a TUI-only field would have changed the registry, provider, and tool contract without a shared invocation model. The later [independent invocation-policy decision](2026-07-28-skill-invocation-policy.md) adds it across every relevant consumer and preserves `get()` as a trusted primitive. **Declare `skills` as a TUI injection.** Rejected because skills mount conditionally; a declared injection would make the front door require the registry and refuse to mount without it, contradicting the package's optional-service stance. `ctx.get('skills')` reads the global store and tolerates absence. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md index 66edec6ecd..772e25745e 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md @@ -10,17 +10,17 @@ Status: implemented ## Decision -[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill: [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的能力;它不新增任何面向模型的工具,也不改动任何 skill 系统包的契约。 +[`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 前门拥有一条 `/skill: [instructions]` 命令。提交时它加载指定的 skill,并投递一个文本块作为用户轮次——空闲时用 `agent.send()` 发送、运行中用 `agent.steer()` 中途引导,与普通编辑器输入遵循同一规则。该文本块由 `renderSkillInvocation(skill, instructions)` 生成:一个包裹 skill 正文的 `` 元素,当提供方暴露资源基址时在其前加一行资源基址行,用户尾随的文本在空行之后追加。该命令是 TUI 独有的功能;它不新增任何面向模型的工具。其可见性和加载策略来自共享的[模型与用户独立 skill 调用策略](2026-07-28-skill-invocation-policy.md)。 TUI 通过 `ctx.get('skills')` 读取 skill 服务,而非声明式注入,因为 skill 是条件挂载的:没有注册表的部署仍保有可用的前门,此时 `/skill:` 会报告 skill 不可用,而不是挂载失败。`createTuiChat` 是同步的,而 `ctx.skills.list()` 是异步的,所以自动补全先立即种入静态斜杠命令,待目录解析完成后再用 `skill:` 条目重建 provider(提供方);在 dispose(资源释放)之后才到达的解析结果会被丢弃,而被拒绝的查找会保留基础命令。 -自动补全只列出模型可调用的 skill——它基于 `list()` 构建,而 `list()` 会略去 `disableModelInvocation` 的 skill——手动提交则通过 `get()` 解析,skill 注册表将其记录为返回被禁用 skill 的可信调用方路径。因此用户可以通过键入 skill 的确切名称加载任意 skill,但补全菜单绝不会宣传一个本不该让模型看见的 skill。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 +自动补全使用 `isUserInvocable` 过滤与调用策略无关的 `list()` 结果;手动提交则在受信的 `get()` 解析定义后应用相同判定。因此,即使模型调用已禁用,仅供用户调用的 skill 仍会显示并可加载;用户禁用的 skill 既不会展示,也无法按精确名称加载。每个补全条目都以其胜出来源的作用域为标签——`project-` 来源标为 `(project)`,其他一切来源标为 `(user)`——标签置于斜杠命令的参数提示位,菜单会显示它,但选中时绝不会插入,因此尾随指令仍然跟在补全后的名称之后。未知名称、前缀之后为空的名称、用户禁用的名称以及查找失败,都会各自呈现为 transcript(文本记录)中的一条通知,且不发送任何内容。 `renderSkillInvocation` 及资源基址行是 TUI 自有的,刻意不复用 `dsh-tool-skill` 的 `skill` 工具结果。该工具把正文包进 ``/``/`` 是为了一个*工具结果*;而手动调用是一个*用户轮次*,把两个渲染器耦合起来会迫使一种面向模型的形态同时服务两个界面。代价是两个都在格式化 skill 正文的渲染器;收益是各界面面向模型的文本可以独立演进,且各自在其产出处被固定。 ## Alternatives considered -**新增 `user-invocable` frontmatter 字段并在注册表中强制执行。** 本次改动否决。skill 系统 note 把该字段列为待办,而手动调用并不需要它:TUI 是可信的本地调用方,`get()` 已经授权加载任意 skill,自动补全的可见性以既有的 `disableModelInvocation` 为准。新增一个逐 skill 字段会给注册表、本地提供方和工具都加上一条契约,而除了可见性之外没有任何现有消费方,可见性又已由 `disableModelInvocation` 覆盖。 +**仅在最初的 TUI 变更内新增 `user-invocable` frontmatter 字段。** 当时未采纳,因为 TUI 独有的字段会在没有共享调用模型的情况下改变注册表、提供方和工具契约。后续的[独立调用策略决策](2026-07-28-skill-invocation-policy.md)将其扩展到每个相关消费方,并保留 `get()` 作为受信原语。 **把 `skills` 声明为 TUI 注入。** 否决,因为 skill 是条件挂载的;声明式注入会使前门必须依赖注册表,缺少它就拒绝挂载,与本包可选服务的立场相悖。`ctx.get('skills')` 读取全局存储并容忍其缺失。 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml new file mode 100644 index 0000000000..187c4dfe94 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md +2026-07-22-docked-web-goal-bar.md: 110aea299a260896b0098f10b337734e0c0aebcf +2026-07-22-docked-web-goal-bar.zh.md: cc0a5eda6815e6c02e97fd02197659764d4f2d69 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md new file mode 100644 index 0000000000..110aea299a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md @@ -0,0 +1,39 @@ +# Agent Note: Docked web goal bar + +Status: implemented + +English | [中文](2026-07-22-docked-web-goal-bar.zh.md) + +## Problem + +The web UI had no goal surface at all: the goal stack shipped with model tools, the TUI/ACP adapters, and the `/goal` command, but the browser client exposed none of it — no runtime verbs, no indicator. This change introduces the client goal verbs (runtime session methods over RPC) and the first goal UI together. Placement follows the redesign's premise that goal presence belongs to the composer's context: the goal is a property of the work the user is about to prompt, so its indicator docks directly above the message composer as a rounded-top strip tucked under the composer card's top edge. The mock keeps only a sparkle, a phase word ("Ongoing/Paused/Blocked Goal"), the truncated objective, and edit/clear icon actions, with resume appearing only on a paused goal. + +## Decision + +`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a new props-driven, self-contained component; `ConversationRoot` mounts it immediately before the composer `InputBar`. The strip's CSS mirrors the composer's horizontal geometry (32px side padding, 776px centered cap) plus the mock's 12px inset, and a -10px bottom margin eats InputBar's 8px top padding and tucks its square bottom edge 2px under the composer card's top edge. All strip states share one fixed 38px height so switching between them never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. + +Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it. + +`GoalBarActions` lives in ui-goal's slot contract (`packages/client/ui-goal/src/client/slots.ts`) and carries exactly the rendered verbs: `onEdit`/`onPause`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref. + +The runtime session gains the goal surface the strip (and future UI) needs: `fetchGoal` populates the snapshot on open, and a live `context/message` carrying `goal/change` meta triggers a coalesced refetch — concurrent triggers share the in-flight `goal.get`, while a trigger received during that read schedules one coalesced trailing read so independently ordered notifications and GET responses cannot leave stale state. Window replays never refetch, and matching the meta kind (rather than a goal key) also catches clear tombstones written by other clients. The six mutation verbs fold transport failures into `{ ok: false }` results like every sibling session method, and a get result older than a mutation response that landed mid-flight is dropped. + +The strip's background is `--dsw-alias-interactive-bg-hover` rather than the mock's literal `#F5F6F7`: the translucent hover gray resolves to that value over the white light-theme base and lifts the strip off the composer card in dark mode, where a static light token would sink. All colors are `--dsw-*` tokens. + +## Testing + +`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions. + +## Alternatives considered + +- **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip cannot dock into the composer card. +- **Render a "Loading goal…" placeholder for `undefined`** — rejected: the strip would flash and collapse on every session open, chrome noise for a sub-second state. +- **Include an inline create affordance when no goal is set** — rejected after implementation review: goal creation lives on the `/goal` command, matching the pattern where the model creates goals on request; the bar is a status indicator, not a creation surface. +- **Carry the full verb set (`onComplete` included) in `GoalBarActions`** — rejected as speculative generality: the interface carries only the rendered verbs (`onPause` joined it when the active strip gained its pause action). + +## Consequences + +- Goal presence in the web UI is a composer-docked strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface. +- The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads). +- Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools). +- `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job. diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md new file mode 100644 index 0000000000..cc0a5eda68 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 停靠式 Web 目标条 + +Status: implemented + +[English](2026-07-22-docked-web-goal-bar.md) | 中文 + +## 问题 + +Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、TUI/ACP 适配器和 `/goal` 命令交付,但浏览器客户端完全不接触它——既没有运行时动词,也没有指示器。本变更同时引入客户端目标动词(基于 RPC 的运行时会话方法)和第一个目标 UI。摆放位置遵循重新设计的前提:目标的存在感属于输入框的上下文——目标是用户即将提交的工作的属性,因此它的指示器停靠在消息输入框正上方,呈现为一条圆角顶部的横条,收进输入框卡片顶边之下。设计稿只保留一个闪光图标、一个阶段词("Ongoing/Paused/Blocked Goal")、截断后的目标内容,以及编辑/清除图标操作,恢复按钮仅在目标暂停时出现。 + +## 决策 + +`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个新的、由 props 驱动的自包含组件;`ConversationRoot` 将它挂载在输入框 `InputBar` 紧上方。横条的 CSS 对齐输入框的水平几何(两侧 32px 内边距、776px 居中上限),再加上设计稿的 12px 内缩,并用 -10px 的下外边距吃掉 InputBar 的 8px 上内边距,使它方形的底边收进输入框卡片顶边之下 2px。横条的所有状态共享固定的 38px 高度,状态切换不会引起尺寸变化。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 + +可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供暂停/编辑/清除;paused 状态显示 "Paused Goal",把暂停换成一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。 + +`GoalBarActions` 位于 ui-goal 的槽位契约(`packages/client/ui-goal/src/client/slots.ts`),只携带实际渲染的动词:`onEdit`/`onPause`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref,因此 UI 不传 ref。 + +运行时会话获得了横条(以及未来 UI)所需的目标表面:`fetchGoal` 在打开时填充快照;携带 `goal/change` 元数据的 live `context/message` 触发合并重新拉取——并发触发器共享正在执行的 `goal.get`,读取期间收到的触发器会安排一次合并后的尾随读取,避免彼此独立排序的通知和 GET 响应留下陈旧状态。窗口重放绝不触发重新拉取,且匹配元数据 kind(而不是 goal 键)还能捕获其他客户端写入的清除墓碑。六个变更动词与所有同类会话方法一样,把传输层失败折叠为 `{ ok: false }` 结果;比在拉取途中落地的变更响应更旧的 get 结果会被丢弃。 + +横条的背景色用 `--dsw-alias-interactive-bg-hover`,而不是设计稿里的字面值 `#F5F6F7`:这个半透明的悬浮灰在浅色主题的白色底上正好解析为该值,而在深色模式下能把横条从输入框卡片上衬托出来,静态的浅色 token 在深色模式下会沉进去。所有颜色都是 `--dsw-*` token。 + +## 测试 + +`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;active 横条触发暂停;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。 + +## 考虑过的替代方案 + +- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;放在头部的横条无法停靠进输入框卡片。 +- **为 `undefined` 渲染 "Loading goal…" 占位**:不予采纳,每次打开会话横条都会闪现再坍缩,对一个不到一秒的状态来说只是界面噪音。 +- **未设置目标时在横条内提供内联创建入口**:实现评审后不予采纳,创建目标的职责在 `/goal` 命令上,与模型按请求创建目标的模式一致;横条是状态指示器,不是创建入口。 +- **在 `GoalBarActions` 中携带完整动词集合(含 `onComplete`)**:作为投机性泛化不予采纳,接口只携带实际渲染的动词(active 横条获得暂停操作后,`onPause` 随之加入)。 + +## 后果 + +- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。 +- 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。 +- 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;完成对其他界面(`/goal`、模型工具)照常可用。 +- `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml new file mode 100644 index 0000000000..4d686731fe --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml @@ -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 +2026-07-23-web-permission-and-approval.md: cd402a039e55e7a24a038055dab5793aa0d08438 +2026-07-23-web-permission-and-approval.zh.md: ce4964789bc94a0962796bb2f5fbf1a94e8f5145 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md new file mode 100644 index 0000000000..cd402a039e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md @@ -0,0 +1,33 @@ +# Agent Note: Web UI permission presets and approval answering + +Status: implemented + +English | [中文](2026-07-23-web-permission-and-approval.zh.md) + +## Problem + +The web host booted an unconfined agent: `bootHost` composed `dsh-bash-local` and `dsh-fs-local`, so every web session ran with full file access, no approval channel, and no permission control — while the ACP composition had shipped the complete sandboxed product path (sandbox provider + policy home + confined bash/fs + approval + presets) for months. The web wire contract had already reserved the seats — `approval/requested`/`approval/resolved` mux frames, `POST /api/respond` with `ApprovalResponsePayload`, client-side `pendingBuffers` — but the host `respond` was a stub, no answerer bridged `ctx.approval` to the stream, no RPC exposed the permission select, and the PendingCard rendered approvals as visible-but-unanswerable. + +## Decision + +The web host composes the same sandboxed product path as the acp-agent composition: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission`, with `BootHostOptions.sandbox` supplying the deployment defaults (`mode`, default `workspace-write`; `approvalPolicy`, default `ask`). + +`createApiProxy` owns the approval pending registry. Its `approval/request` waterfall answerer reads the approval id from the session's just-appended `approval/asked` audit event (an ask with no audit event is a foreign channel and delegates), mints one stable rpcId per question, broadcasts the answerable `approval/requested` frame to every open mux stream, and replays still-pending frames verbatim on each mux open — the refresh-recovery baseline the contract already promised. `respond` routes by the echoed rpcId, validates `ApprovalResponsePayload` with the existing zod schema, cross-checks the payload's audit correlation against the routed entry, resolves the answerer, and broadcasts `approval/resolved`; the ask's abort signal withdraws the question as `cancelled`. + +The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. + +Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Question placeholders stay in the message flow. The sidebar mirrors the blocked state with an amber warning dot that outranks the running ring: the manager tracks per-session outstanding approvalIds (idempotent under mux-open replays, cleared per connection generation so the reopen replay is authoritative) rather than reading Session instances, so the dot lights for sessions never instantiated. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. + +## Alternatives considered + +**Reuse the ACP `session/set_config_option` shape on the web wire.** Rejected: the web contract's unary method registry (`RpcMethodMap` + per-method zod schemas) is its own dialect; a generic config-option surface would bypass the compiler-locked schema table for one select. A dedicated method pair keeps both sides derivable from the signature. + +**A session event for pending approvals instead of a proxy-side registry.** Rejected: approval requests are transient interaction state, not durable session data — the `approval/asked`/`decided` audit pair already logs the durable half. Persisting requested frames would re-ask dead questions on replay. + +**Registering the answerer only when a mux subscriber exists.** Rejected: the pending entry must survive client disconnects (refresh recovery is the point), so the registry outlives any one stream; a subscriber-gated answerer would fail asks closed during a reload window. + +**Optimistic card removal on click.** Rejected: the broadcast resolved frame is the truth; removing on click would hide a question that a rejected receipt or transport failure left standing. The panel disables its buttons locally and re-arms them on failure instead. + +## Consequences + +Web sessions now start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering shipped separately through the same registry pattern (ui-question over the question pending table). The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, and the keyless web smoke exercises the fixture-mode approval answer and preset switch in a real browser. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md new file mode 100644 index 0000000000..ce4964789b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web UI 权限预设与审批应答 + +Status: implemented + +[English](2026-07-23-web-permission-and-approval.md) | 中文 + +## 问题 + +Web 承载层启动的是一个不受限的 agent(智能体):`bootHost` 组合了 `dsh-bash-local` 与 `dsh-fs-local`,因此每个 Web 会话都以完整文件访问权限运行,既无审批通道,也无权限管控——而 ACP 组合早在数月前就已交付完整的沙箱化产品路径(沙箱提供方 + 策略归属 + 受限的 bash/fs + 审批 + 预设)。Web 协议契约其实早已预留了对应位置——`approval/requested`/`approval/resolved` 的 mux 帧、携带 `ApprovalResponsePayload` 的 `POST /api/respond`、client 侧的 `pendingBuffers`——但 host 的 `respond` 只是一个 stub,没有应答者把 `ctx.approval` 桥接到流上,没有 RPC 暴露权限选择,PendingCard 把审批渲染成可见却无法应答的样子。 + +## 决策 + +Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 与 `dsh-permission`,由 `BootHostOptions.sandbox` 提供部署默认值(`mode`,默认 `workspace-write`;`approvalPolicy`,默认 `ask`)。 + +`createApiProxy` 拥有审批 pending 注册表。它的 `approval/request` waterfall(瀑布式事件)应答者从会话刚追加的 `approval/asked` 审计事件中读取审批 id(没有审计事件的 ask 属于外部通道,予以委托),为每个问题 mint 一个稳定的 rpcId,向每个打开的 mux 流广播可应答的 `approval/requested` 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是契约早已承诺的刷新恢复基线。`respond` 按回显的 rpcId 路由,用既有的 zod schema 校验 `ApprovalResponsePayload`,将载荷的审计关联与所路由的条目交叉核对,解析应答者,并广播 `approval/resolved`;ask 的中断信号会以 `cancelled` 撤回该问题。 + +权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/prompt-submit` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 + +在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。问题占位符仍留在消息流中。侧边栏用一枚琥珀色警示圆点同步呈现这一阻塞状态,且其优先级高于表示运行中的圆环:manager 跟踪每个会话尚未解决的 approvalId(对 mux 打开时的回放幂等,并按连接代次清除,以保证重开后的回放才是权威依据),而非读取 Session 实例,因此从未实例化过的会话也能点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 + +## 曾考虑的替代方案 + +**在 Web 协议上复用 ACP 的 `session/set_config_option` 形状。** 不予采纳:Web 契约的一元方法注册表(`RpcMethodMap` + 逐方法的 zod schema)是它自成一体的方言;一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。 + +**用一个会话事件承载 pending 审批,而非 proxy 侧注册表。** 不予采纳:审批请求是瞬态的交互状态,而非持久的会话数据——`approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。 + +**仅在存在 mux 订阅者时才注册应答者。** 不予采纳:pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会让在重载窗口期间关闭的 ask 落空。 + +**点击即乐观移除卡片。** 不予采纳:广播的 resolved 帧才是真相;点击即移除会隐藏一个因拒绝回执或传输失败而仍然悬置的问题。面板改为在本地禁用其按钮,并在失败时重新启用。 + +## 后果 + +Web 会话现在从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答已通过同一注册表模式单独交付(ui-question 基于问题 pending 表)。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖情况:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件,以及无密钥 Web 冒烟测试在真实浏览器中演练 fixture 模式的审批应答与预设切换。 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml new file mode 100644 index 0000000000..ab957282ba --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +2026-07-25-subagent-policy-inheritance.md: aeff83795eedead9c75de6bbb74c1da1945092ca +2026-07-25-subagent-policy-inheritance.zh.md: 7a09fd972f53b46655df93ddf9625455f2077460 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md new file mode 100644 index 0000000000..aeff83795e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -0,0 +1,36 @@ +# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox and approval overrides + +Status: implemented + +English | [中文](2026-07-25-subagent-policy-inheritance.zh.md) + +## Problem + +Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`, or turn a parent's unattended `'never'` approval stance back into prompting behavior. + +## Decision + +The shared in-process driver snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants. + +Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` at the fork-prefix length, so the inherited facts follow fork history, reach telemetry when the child is announced, and leave `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism. + +Ordinary session appends validate the inherited events before publication, and persistence captures the complete unpublished log when the session is announced. Any materialized child log therefore stores the inherited events with its first batch; there is no second policy store, schema field, or query index. The `source: 'delegation'` marker lets approval narration distinguish inheritance from a child-side user switch. + +### What a blocked child experiences + +A confined child gets the ordinary denial marker. No answerer currently owns an in-process child, so an escalation request fails closed and the child reports upward; a controller-owned parent may widen its own session and delegate again. An inherited `'never'` policy tells the child not to request escalation in its first system prompt. + +## Alternatives considered + +- **Generic `SessionHeader` policy fields** — rejected: they duplicate an event-sourced fact in metadata and require propagation through core session types, persistence backends, query indexes, collision identity, and every policy consumer. Unpublished setup events have the required ordering and reuse the existing durable store. +- **Combining new policy facts with constructor history** — rejected: `Session.firstLiveSeq` classifies the complete constructor seed as replayed history, so telemetry would skip child-only facts. Unpublished setup keeps history and new facts on their existing sides of that boundary without another session option. +- **A first-prompt listener** — rejected: it introduces listener ordering and a later timing boundary even though the creation transaction already permits log appends before publication. +- **Copying deployment defaults** — rejected: defaults remain operator-owned and may change; an unswitched parent stamps nothing, so its child follows the current deployment. +- **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening. +- **Forcing `'never'` or routing asks to the root controller** — rejected as inheritance behavior. A forced value forecloses a future child answerer; parent routing needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md). + +## Consequences + +- Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. +- The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed. +- Each delegation adds at most two log-only events. `dsh-subagent-inprocess` has optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md new file mode 100644 index 0000000000..7a09fd972f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱与审批覆盖项下启动 + +Status: implemented + +[English](2026-07-25-subagent-policy-inheritance.md) | 中文 + +## 问题 + +沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent(智能体)过去会回退到部署默认值,fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级,或让父级无人值守的 `'never'` 审批立场重新变成会发起提示的行为。 + +## 决策 + +共享的进程内驱动器在第一次 await 之前对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。 + +每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已将 `Session.firstLiveSeq` 固定为 fork 前缀的长度,因此继承事实会排在 fork 历史之后,在子 agent 公布时进入遥测,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。 + +普通的会话追加会在发布前校验继承事件,持久化层则在会话公布时捕获完整的未发布日志。因此,任何已物化的子 agent 日志都会在首批数据中存下继承事件;不存在第二套策略存储、schema 字段或查询索引。`source: 'delegation'` 标记让审批叙述能够区分继承与子 agent 侧的用户切换。 + +### 被拦住的子 agent 会经历什么 + +受限子 agent 会得到普通拒绝标记。目前没有应答器认领进程内子 agent,因此升级请求会失败关闭,由子 agent 向上汇报;由控制器持有的父 agent 可以放宽自己的会话后重新委派。继承的 `'never'` 策略会在第一份系统提示词中告知子 agent 不要请求升级。 + +## 考虑过的替代方案 + +- **通用的 `SessionHeader` 策略字段**:不予采纳。它们会在元数据中复制一项事件溯源事实,并要求贯穿核心会话类型、持久化后端、查询索引、碰撞标识与每个策略消费方进行传播。未发布设置阶段的事件具备所需顺序,并复用现有持久化存储。 +- **将新策略事实与构造历史合并**:不予采纳。`Session.firstLiveSeq` 会把完整的构造种子归类为回放历史,因此遥测会跳过仅属于子 agent 的事实。未发布设置让历史与新事实留在该边界各自原有的一侧,无需再增加会话选项。 +- **首个提示词监听器**:不予采纳。尽管创建事务已经允许在发布前追加日志,它仍会引入监听器顺序与更晚的时序边界。 +- **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会盖章写入任何内容,因此其子 agent 跟随当前部署。 +- **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。 +- **强制使用 `'never'` 或把 ask 路由到根控制器**:不作为继承行为采纳。强制值会排除未来的子 agent 应答器;父级路由需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。 + +## 后果 + +- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 +- 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。 +- 每次委派最多增加两条仅日志事件。`dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml new file mode 100644 index 0000000000..9a3df405be --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md +2026-07-27-skill-catalog-hot-refresh.md: 8e70fb10e7da4292325b72f3a0392bef2271738c +2026-07-27-skill-catalog-hot-refresh.zh.md: 9a6b6d944baa4a9cc4dddb5158fdcbac2b05f5db diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md new file mode 100644 index 0000000000..8e70fb10e7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.md @@ -0,0 +1,48 @@ +# Agent Note: Skill catalog hot refresh + +Status: implemented + +English | [中文](2026-07-27-skill-catalog-hot-refresh.zh.md) + +## Problem + +Skill summaries are model routing input, but local skills can appear, disappear, or be renamed after a session starts. IDEs, Git operations, shell commands, and other processes can all mutate `.agents/skills` without going through the harness filesystem tools. A startup-only catalog leaves the model unaware of new skills and able to call deleted names. Treating every instruction-body edit as a catalog revision would instead couple progressive loading to unnecessary prompt churn. + +Filesystem updates are also non-atomic from the observer's perspective. An editor or Git operation may briefly remove a file, a watched root may not exist at startup, and discovery may fail transiently. Publishing those intermediate observations as authoritative empty catalogs would be worse than retaining the last complete view. + +## Decision + +The skill capability separates catalog membership from instruction-body loading. `ctx.skills.snapshot()` returns summaries plus a completeness bit. `ctx.skills.registerProvider(factory)` gives the synchronous factory one registration-scoped `{ signal, invalidate }` control: `invalidate()` dirties only that exact active registration and discards completed catalog caches, while the signal aborts when registration fails or is disposed. Provider arrays are complete-discovery shorthand; an explicit incomplete observation can retain readable candidates for direct loads without becoming cacheable or authoritative for model-facing consumers. A provider or runtime generation change during discovery retries once; if the retry is also superseded, the latest candidates return as an incomplete, uncached observation. A late invalidation after disposal or replacement is a no-op because the capability has been revoked. + +`@deepseek-ai/dsh-skill-local` directly depends on Chokidar and observes catalog-relevant host paths. Existing roots watch direct skill bundle directories, flat Markdown entries, and direct `SKILL.md` entry files. Additions, removals, and directory changes invalidate membership; file changes support frontmatter `name` and `description` refresh. Resource files below a bundle are ignored. Events in one microtask batch coalesce to one invalidation. Project watchers use a bounded least-recently-observed set. + +A missing root is followed from its nearest existing ancestor one absent segment at a time with `fs.watchFile`, then handed to Chokidar once the real root exists. Before scanning, each discovery re-probes the retained root/ancestor mode. That independent probe re-establishes ancestor observation after deletion even when child removals invalidate and publish an authoritative empty catalog before, or without, a root `unlinkDir` event. Chokidar configuration exposes native-versus-polling mode, write stability, polling interval, symlink following, and project watcher capacity. First-party `write` and `edit` tool observations synchronously invalidate a relevant provider, so the next model step sees its own mutation without waiting for host delivery. Watch startup/runtime failures are logged and retried; discovery still returns readable candidates for direct loads but reports an incomplete observation. Teardown closes watchers and ignores late callbacks. + +`@deepseek-ai/dsh-tool-skill` injects the first non-empty complete catalog as a durable sourced `user/message` on the first complete `agent/step` that observes one. At every `agent/step` it applies exact `skill` tool visibility, hashes the exact rendered text between the `` tags, and scans the read-only session events backwards without copying them for the newest recognizable visible catalog from this plugin. A changed digest appends a durable, complete replacement through `agent.inject()`, including an explicit empty catalog when all skills disappear. If no catalog remains visible but a recognizable one exists in historical events, compaction hid it and the next complete observation re-establishes the current catalog, including an empty tombstone. A current empty catalog with no historical publication emits nothing, while an incomplete snapshot preserves the last-good model view. The backward scan normally stops at the newest visible catalog; when compaction hides every catalog it pays an O(session-events) scan to recover that fact. + +The TUI consumes the same invalidation as presentation state, not session history. `skills/change` carries no diff; the TUI refetches `snapshot()` for the active session cwd, applies only the latest complete result, and retains the previous commands across incomplete observations. A complete empty result clears stale completions. Because pi-tui closes autocomplete when its provider is replaced, a catalog that arrives while the user is typing a slash-command name also triggers a suggestion-only re-query of the current draft. + +Instruction bodies keep progressive disclosure. Every `skill(name)` call asks the provider to reread and parse the current file; there is no body cache, hash, revision, or proactive notification. Previously logged tool results remain unchanged. If the loaded frontmatter name no longer matches the selected candidate, the registry rejects the stale name and invalidates that provider so a later catalog observation can publish the new name. + +## Verification + +Registry tests pin registration-scoped invalidation, revocation, signal abort, contained observer failures, incomplete candidates, bounded generation retries, and stale-name rejection. Local-provider tests cover bundle and flat-file creation, removal, rename, root creation/deletion/recreation including an unobserved root `unlinkDir`, description changes, body-only edits, first-party observation, symlinks, polling options, persistent watcher failures with loadable candidates, event coalescing, bounded projects, teardown, and transient reads. Tool tests pin full replacement messages, empty tombstones, digest stability for body-only edits, incomplete-state retention, visibility, and resume metadata. TUI tests pin last-complete retention, authoritative empty removal, latest-wins refresh, teardown, and the already-open slash-draft race; a real Loader/PTY smoke adds a local skill after startup and observes its completion without restarting. A keyless assembled agent-spine snapshot creates a project skill through model-facing filesystem tools, observes its replacement catalog on the next request, and loads its current body with the real `skill` tool. + +## Alternatives considered + +- **Put the live catalog in World State** — rejected because catalog replacements are model-visible session inputs and must be reconstructable from the event log. Durable injected history already provides replay, resume, fork, and compaction semantics without another mutable state plane. +- **Rely only on `fs/observed`** — rejected because IDEs, Git, shell commands, and external processes do not cross that seam. The event remains a latency fast path for first-party tools, while host watching supplies coverage. +- **Hash or version every `SKILL.md` body** — rejected because the model initially sees only names and descriptions, and the provider already rereads the body on each tool call. Body revisions would create catalog traffic without changing routing and would not justify rewriting historical tool results. +- **Watch every bundle resource** — rejected because references, scripts, and assets are loaded on demand and do not affect the category list. Broad recursive watching would add invalidations, descriptor pressure, and platform variability without improving routing. +- **Publish partial or failed discovery as the new catalog** — rejected because a transient read failure is not evidence of deletion. The completeness bit lets the model-facing consumer preserve its last-good catalog until a full observation succeeds. +- **Keep `invalidateProvider(provider)` public** — rejected because it exposes a registry mutation method and makes callers resupply an identity the registry already owns. The factory-issued closure binds invalidation to one registration and becomes inert on disposal, so observers need neither registry access nor provider identity. +- **Extract a generic Cordis file-watching service now** — deferred until another consumer establishes the reusable service contract. The local provider marks its Chokidar and missing-root observation boundary for that extraction; skill-path filtering and the call to the provider's invalidation closure remain skill-specific. + +## Consequences + +- New, deleted, and renamed local skills become visible at model-step boundaries without restarting the agent, including when the skills root did not exist at startup. +- The TUI's `/skill:` completions converge on the same complete catalog without blocking each keystroke on discovery; an open slash-name draft refreshes when the catalog arrives. +- Catalog messages are append-only, logged, whole-list snapshots. They preserve earlier reusable tokens; replacements retire stale names explicitly, at token cost proportional to the current catalog on each actual digest change. +- Body-only edits produce no catalog message. A subsequent tool call sees current content, while prior tool results remain an accurate record of what the model previously loaded. +- Missing-root polling and Chokidar add one maintained runtime dependency, host watcher resources, bounded detection latency, and deployment tunables. The bounded project set and teardown contract contain those costs. +- Remote or future mutable providers retain their own registration-scoped invalidation closure and call it from their observation mechanism; the registry does not impose a universal watcher or TTL. diff --git a/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md new file mode 100644 index 0000000000..9a6b6d944b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-skill-catalog-hot-refresh.zh.md @@ -0,0 +1,48 @@ +# Agent Note: Skill 目录热刷新 + +Status: implemented + +[English](2026-07-27-skill-catalog-hot-refresh.md) | 中文 + +## 问题 + +skill(技能)摘要是模型的路由输入,但本地 skill 可在会话启动后新增、消失或重命名。IDE、Git 操作、shell 命令和其他进程都可以修改 `.agents/skills`,而不经过 harness 文件系统工具。仅在启动时构建目录,会让模型无法获知新 skill,并且仍能调用已删除的名称。反之,如果把每次指令正文编辑都视为目录修订,就会让渐进式加载与不必要的提示词频繁变化耦合。 + +从观察方来看,文件系统更新也不是原子完成的。编辑器或 Git 操作可能会短暂移除文件,受监视的根目录在启动时可能不存在,发现也可能暂时失败。把这些中间观察结果发布为权威空目录,比保留最后一个完整视图更糟。 + +## 决策 + +skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或释放时,信号会中止。提供方返回的数组是完整发现的简写形式;显式的不完整观测可以保留可读候选项供直接加载,但不能缓存,也不能作为面向模型消费方的权威结果。在发现期间,如果提供方或运行时 generation 发生变化,系统会重试一次;如果这次重试也被后续修订取代,则最新候选项会作为不完整且不缓存的观测返回。资源释放或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。 + +`@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。 + +系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会被记录并触发重试;发现过程仍会返回可读候选项供直接加载,但会报告不完整观测。资源销毁会关闭 watcher,并忽略延迟回调。 + +`@deepseek-ai/dsh-tool-skill` 在 `agent/step` 首次观察到非空完整目录时,将该目录注入为一条持久且带来源的 `user/message`。每次 `agent/step`,它都会应用 `skill` 工具的精确可见性,对 `` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件通过 `agent.inject()` 追加一份持久的完整替换目录;所有 skill 消失时,也会追加显式空目录。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。 + +TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。 + +指令正文继续采用渐进式披露。每次调用 `skill(name)` 时,系统都会要求提供方重新读取并解析当前文件;不存在正文缓存、哈希、修订或主动通知。先前记录的工具结果保持不变。如果加载后的 frontmatter 名称不再匹配所选候选项,注册表会拒绝这个陈旧名称,并使该提供方失效,以便后续目录观察发布新名称。 + +## 验证 + +注册表测试固定了注册作用域内的失效、能力撤销、信号中止、监听器失败隔离、不完整候选项、有界 generation 重试和陈旧名称拒绝。local-provider 测试覆盖 bundle 与平铺文件的创建、移除和重命名,以及根目录创建/删除/重建(包括未观测到根目录 `unlinkDir` 事件的情形)、描述变更、仅正文编辑、第一方观察、符号链接、轮询选项、候选项仍可加载的持续 watcher 失败、事件合并、项目 watcher 容量上限、资源销毁和暂时读取。工具测试固定了完整替换消息、空 tombstone、仅修改正文时 digest 稳定、不完整状态保留、可见性和恢复元数据。TUI 测试固定了上一份完整结果保留、权威空结果清除、刷新时以最新结果为准、资源销毁和已打开斜杠草稿的竞态;一项使用真实 Loader/PTY 的 smoke 测试会在启动后添加本地 skill,并观察其补全项出现,而无需重启。一个无密钥、装配完成的 agent-spine 快照测试通过面向模型的文件系统工具创建项目 skill,观察下一次请求中的替换目录,并使用真实 `skill` 工具加载当前正文。 + +## 考虑过的替代方案 + +- **将实时目录放入 World State**:不予采纳,因为目录替换是模型可见的会话输入,必须能够从事件日志重建。持久注入历史已经提供回放、恢复、fork 和压缩语义,无需再引入一套可变状态层。 +- **只依赖 `fs/observed`**:不予采纳,因为 IDE、Git、shell 命令和外部进程都不会经过该 seam。该事件仍作为第一方工具的低延迟快速路径,宿主监视则补齐覆盖。 +- **为每个 `SKILL.md` 正文计算哈希或版本**:不予采纳,因为模型最初只看到名称和描述,提供方已经在每次工具调用时重新读取正文。正文修订会产生目录流量,却不会改变路由,也不足以成为改写历史工具结果的理由。 +- **监视每个 bundle 资源**:不予采纳,因为参考资料、脚本和产物都是按需加载的,不影响类别列表。宽泛的递归监视会增加失效、描述符压力和平台差异,却不能改善路由。 +- **将部分发现或失败发现发布为新目录**:不予采纳,因为暂时读取失败不能证明文件已删除。完整性位让面向模型的消费方保留最后一次完整目录,直到完整观察成功。 +- **保留公开的 `invalidateProvider(provider)`**:不予采纳,因为这会公开一项注册表变更方法,并要求调用方重复提供注册表已经持有的身份。发给工厂的闭包会将失效绑定到单个注册,并在释放后失去作用,因此观察方既不需要访问注册表,也不需要提供方身份。 +- **现在提取通用 Cordis 文件监视服务**:暂缓,直到另一个消费方确立可复用的服务契约。本地提供方标出了其 Chokidar 和缺失根目录观测边界,以便后续提取;skill 路径过滤以及对提供方失效闭包的调用仍属于 skill 专用逻辑。 + +## 影响 + +- 新增、删除和重命名的本地 skill 会在模型步骤边界变得可见,无需重启 agent(智能体),即使 skill 根目录在启动时不存在也一样。 +- TUI 的 `/skill:` 补全会收敛到同一份完整目录,而不会让每次按键都阻塞于发现;打开的斜杠命令名称草稿会在目录到达时刷新。 +- 目录消息采用仅追加、日志记录和全量列表快照。它们会保留较早的可重用 token;替换目录会显式停用陈旧名称,每次 digest 实际变化时,token 成本与当前目录大小成正比。 +- 仅修改正文不会产生目录消息。后续工具调用会看到当前内容,而先前工具结果仍准确记录模型之前加载的内容。 +- 缺失根目录轮询和 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟和部署可调参数。有界项目集合与资源销毁契约会限制这些成本。 +- 远程或未来的可变提供方会保留各自注册作用域内的失效闭包,并通过自身观察机制调用它;注册表不会强制采用通用 watcher 或 TTL。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml new file mode 100644 index 0000000000..b5fa2609a4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +2026-07-27-tmux-location-context.md: bac5861f7f55c259de04d153115f164d90c415ad +2026-07-27-tmux-location-context.zh.md: 03cd722381c45604f7aae8f3d0a9f9fbb8b12bb5 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md new file mode 100644 index 0000000000..bac5861f7f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md @@ -0,0 +1,61 @@ +# Agent Note: tmux-location context + +Status: implemented + +English | [中文](2026-07-27-tmux-location-context.zh.md) + +## Problem + +An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed. + +tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and `tmux display-message -t "$TMUX_PANE" -p ''` prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state. + +## Decision + +`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy. + +**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. + +**Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider. + +**Detect a real pane by tty, not by `$TMUX_PANE` alone.** `$TMUX_PANE` is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries `$TMUX`/`$TMUX_PANE` from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with `ps -o tty= -p ` (the agent's own pid, passed in-process) and compares it to the pane's `#{pane_tty}`; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking `$TMUX` instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators. + +**Own location and layout only.** The queried fields are session name, window index/name, pane index/id, window/pane active flags, and `window_layout`. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (`capture-pane`), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output. + +**Inject only on change, with optional interval floor.** When due, the plugin calls `agent.inject()` for one `user/message` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional `refreshIntervalMs` (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one. + +### Text + +```text +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position. + +### Durability and request reconstruction + +Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback. + +The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests. + +## Consequences + +An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one `tmux display-message` subprocess (through the sandboxed bash seam) on the first step of each turn that is due. The optional interval floor is checked before the query and so suppresses both; an unchanged location is detected only by comparing the returned state, so it suppresses the injection while still paying for the query. + +## Testing + +Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal, and a contained executor rejection from either `resolve()` or `run()` that warns instead of failing the turn); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. + +## Alternatives considered + +- **Push from a tmux hook / background watcher** — rejected: requires installing hooks in the user's tmux and a background process with teardown, to gain mid-step freshness that per-turn context does not need. +- **Run every step** — rejected: location is stable within a turn; re-querying adds token cost without new information. Gating on `step === 1` yields per-turn readings. +- **Raw `child_process`** — rejected: bypasses the sandbox/policy seam and hand-rolls subprocess code the `ctx.bash` executor already owns. +- **Include pane/window pixel sizes** — rejected: sizes churn on every resize and add noise; the layout tree already conveys structure. +- **Scrape sibling panes with `capture-pane`** — rejected: large, noisy, and privacy-sensitive; out of scope for "own location". +- **Dynamic system-prompt section** — rejected: replacing a value erases the earlier readings behind prior reasoning and is not reconstructable; one durable attributed message records each location where it became visible. +- **Trust `$TMUX_PANE` (or `$TMUX`) presence** — rejected: both are inherited by terminals launched from a tmux shell (VS Code integrated terminal), so a non-pane process injects a stale location. The pane `#{pane_tty}` vs. this process's controlling tty is the definitive check. +- **Denylist known terminal emulators (e.g. `TERM_PROGRAM=vscode`)** — rejected: a partial, ever-growing list that still misses other launchers; the tty match is exact and launcher-agnostic. +- **A runtime invariant validating each reading's turn, position, and format** — shipped initially, then removed: it re-derived the producer's own scheduling from the log and asserted a regex over text the same package had just rendered, so it restated `apply()` rather than checking an independent relation. Every failure it could report required an edit to this package, which its pipeline tests already catch. Reintroduce a companion check only for a relation the plugin does not itself compute — for example if readings gain cross-turn ordering or enclosure obligations that another package can violate. diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md new file mode 100644 index 0000000000..03cd722381 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md @@ -0,0 +1,61 @@ +# Agent Note:tmux 位置上下文 + +Status: implemented + +[English](2026-07-27-tmux-location-context.md) | 中文 + +## 问题 + +运行在 tmux 内的 agent 无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让"下方的 pane""这个 window"之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。 + +tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所在 pane,`tmux display-message -t "$TMUX_PANE" -p ''` 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。 + +## 决策 + +`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。 + +**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 + +**通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。 + +**以 tty 判定真实 pane,而非仅凭 `$TMUX_PANE`。** `$TMUX_PANE` 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 `$TMUX`/`$TMUX_PANE`,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 `ps -o tty= -p `(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 `#{pane_tty}` 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作"不在 tmux 中"。改为检查 `$TMUX` 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。 + +**仅自身位置与布局。** 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 `window_layout`。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(`capture-pane`),使读数保持小巧,并避免抓取无关、可能敏感的输出。 + +**仅在变化时注入,并可选间隔下限。** 需要时,插件调用 `agent.inject()` 注入一条来源为 `{ kind: 'plugin', plugin: 'tmux-context' }` 的 `user/message`。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩与进程恢复存续,无需进程内缓存。可选的 `refreshIntervalMs`(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。 + +### 文本 + +```text +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。 + +### 持久性与请求重建 + +每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 + +发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。 + +## 后果 + +启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 `tmux display-message` 子进程(经沙箱化的 bash seam)。可选的间隔下限在查询之前检查,因此同时抑制查询与注入;而位置未变化只能通过比较查询返回的状态得知,因此它只抑制注入,查询开销仍会付出。 + +## 测试 + +单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消,以及 `resolve()` 或 `run()` 抛出的执行器拒绝被兜住并记录警告而非使该轮失败);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 + +## 考虑过的替代方案 + +- **由 tmux hook / 后台监视器推送**——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。 +- **每个 step 都运行**——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 `step === 1` 为门槛得到按轮次读数。 +- **裸 `child_process`**——否决:绕过沙箱/策略 seam,并手写 `ctx.bash` 执行器已拥有的子进程代码。 +- **包含 pane/window 像素尺寸**——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。 +- **用 `capture-pane` 抓取相邻 pane**——否决:庞大、嘈杂且涉及隐私;超出"自身位置"范围。 +- **动态系统提示区块**——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。 +- **信任 `$TMUX_PANE`(或 `$TMUX`)存在即可**——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 `#{pane_tty}` 与本进程控制终端的比对才是决定性检查。 +- **对已知终端模拟器设黑名单(如 `TERM_PROGRAM=vscode`)**——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。 +- **用运行时 invariant 校验每条读数的轮次、位置与格式**——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 `apply()`,而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的管线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml new file mode 100644 index 0000000000..6e9974fdce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +2026-07-27-trajectory-inspection-ledger.md: 8c2a7c42b7898776de5d42459b09c0fb1737ec0b +2026-07-27-trajectory-inspection-ledger.zh.md: 6c5733046dc2cfd3fd2bd005f4cf5c2d2bd110af diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md new file mode 100644 index 0000000000..8c2a7c42b7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -0,0 +1,43 @@ +# Agent Note: Trajectory inspection ledger + +Status: implemented + +English | [中文](2026-07-27-trajectory-inspection-ledger.zh.md) + +## Problem + +Trajectory has to make prose, machine payloads, token usage, timing, and nested tool activity readable in the same viewport. The earlier stacked Turn and Step cards preserved hierarchy but spent too much vertical space on repeated chrome, while a completely flat table would erase the causal structure that makes a trajectory useful. Role colors also risked borrowing success and warning semantics, which made visual decoration indistinguishable from runtime state. + +## Decision + +**Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** + +- The ledger keeps session events in sequence within rewind-delimited branches. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests. +- Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector. +- Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack. +- Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. +- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory subscribes to that source, exhausts its paging only while mounted, and lazily derives its event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer. +- Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Complete history makes global Request numbering and cumulative usage session-wide rather than tail-window-relative. +- Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. +- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. +- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. +- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. + +## Alternatives considered + +**Copy Vite DevTools fonts, colors, glass surfaces, or component shapes.** Rejected: those choices express a different product identity. The implementation only adopts the transferable method: neutral structure, semantic accents, machine-data typography, dense scanning, and shadows reserved for floating layers. + +**Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. + +**Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows. + +**Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. + +**Keep timing in a separate Waterfall tab.** Rejected: the placeholder summarized node counts rather than record timing and forced users to switch away from the rows they wanted to focus. A full-domain Overview keeps timing and filtered records in one visual context. + +**Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces. + +## Consequences + +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin projection, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md new file mode 100644 index 0000000000..6c5733046d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -0,0 +1,43 @@ +# Agent Note:轨迹检查记录表 + +Status: implemented + +[English](2026-07-27-trajectory-inspection-ledger.md) | 中文 + +## 问题 + +轨迹视图需要在同一视口内清晰呈现正文、机器载荷、token 用量、计时数据和嵌套工具活动。此前堆叠式的轮次与步骤卡片虽然保留了层级,却在重复界面框架上耗费了太多垂直空间;完全扁平化的表格又会抹去因果结构,而这种结构正是轨迹视图的价值所在。角色配色还可能借用成功与警告语义,使视觉装饰与运行时状态无法区分。 + +## 决策 + +**使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** + +- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 +- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 +- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 +- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 +- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 订阅该数据源,仅在挂载期间补齐全部历史,并按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。 +- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。完整历史使全局请求编号和累计用量以整个会话为范围,而不是相对于末尾窗口。 +- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 +- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 +- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 + +## 曾考虑的替代方案 + +**照搬 Vite DevTools 的字体、颜色、玻璃表面或组件形状。** 不予采纳:这些选择表达的是另一种产品身份。实现仅吸收可迁移的方法,即中性结构、语义强调色、机器数据排版、紧凑扫读,以及只为浮层保留阴影。 + +**每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 + +**不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构。 + +**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 + +**将计时保留在独立的 waterfall 标签页中。** 不予采纳:占位实现汇总的是节点数而非记录计时,并迫使用户离开想要聚焦的记录。保留完整时间范围的 Overview 区域让计时和筛选后的记录处于同一视觉上下文中。 + +**修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。 + +## 后果 + +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间与耗时数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定投影、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 区域与检查器。 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml new file mode 100644 index 0000000000..f2b2b5b22e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md +2026-07-28-skill-invocation-policy.md: f74b0bcfddb1699c48279b4d8b153cabf764b140 +2026-07-28-skill-invocation-policy.zh.md: 1a7117a382be224c5371964dd4ad3e916d4e0917 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md new file mode 100644 index 0000000000..f74b0bcfdd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md @@ -0,0 +1,50 @@ +# Agent Note: Independent model and user skill invocation policy + +Status: implemented + +English | [中文](2026-07-28-skill-invocation-policy.zh.md) + +## Problem + +The skill registry originally treated discovery as a model catalog: `ctx.skills.list()` removed model-disabled skills, while `ctx.skills.get()` remained an unfiltered trusted loader. That was enough for model-initiated loading, but it could not represent Claude-compatible skills that are advertised only to a person, only to a model, to both, or to neither. The TUI compounded the mismatch by deriving user autocomplete from the model-filtered list and allowing every exact name through `get()`. + +The local parser also exposed an internal camel-case spelling as frontmatter. Supporting the established negative `disable-model-invocation` and positive `user-invocable` fields requires a durable, symmetric domain representation without turning every possible YAML key into an untyped cross-package contract. + +## Decision + +`SkillSummary` carries a required typed `invocation: SkillInvocationPolicy` object whose `modelInvocable: boolean` and `userInvocable: boolean` fields are positive and symmetric. Omission exists only at explicit input seams: a runtime `SkillRegistration` without a policy and local frontmatter without either invocation key resolve to `{ modelInvocable: true, userInvocable: true }` before producing candidates or definitions. Future frontmatter keys remain outside the domain model until a consumer and enforcement contract exist; the local provider still parses frontmatter as an open `Record`, then projects only recognized fields and their defaults into the normalized typed policy. + +`ctx.skills.list()` returns every winning summary and no longer chooses an invocation surface. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the matching positive field directly. `ctx.skills.get()` remains policy-neutral because trusted internal callers may need any definition, while a public consumer must enforce its own predicate before advertising or loading a skill. The model tool and TUI check the invocation-neutral summary before calling `get()`, then recheck the loaded definition so a denied name never reaches definition loading and a policy change between discovery and load cannot expose its body. + +The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation. + +The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. + +These rules permit all four combinations: + +| Policy | Model surface | User surface | +|---|---|---| +| `{ modelInvocable: true, userInvocable: true }` | included | included | +| `{ modelInvocable: true, userInvocable: false }` | included | excluded | +| `{ modelInvocable: false, userInvocable: true }` | excluded | included | +| `{ modelInvocable: false, userInvocable: false }` | excluded | excluded | + +This decision extends the [skill system](2026-07-05-skill-system.md) and supersedes the invocation-policy limitation recorded by the [TUI skill slash command](2026-07-21-tui-skill-slash-command.md). + +## Alternatives considered + +**Store all frontmatter in a generic `Map` and read string keys in `isModelInvocable` / `isUserInvocable`.** Rejected because misspelled keys, non-boolean values, and consumer-specific coercion would cross package seams without type checking. The parser boundary remains open; the domain model is deliberately typed and narrow. + +**Keep `ctx.skills.list()` model-filtered and add a second user list.** Rejected because discovery, duplicate resolution, caching, and ordering are surface-neutral work. One complete catalog plus explicit predicates prevents those mechanisms from drifting while making each consumer's policy visible at its boundary. + +**Enforce invocation policy inside `ctx.skills.get()`.** Rejected because `get()` cannot know whether its caller is a model tool, a human command, or trusted orchestration. Filtering there would also make the both-disabled quadrant impossible to inspect or administer. + +**Treat camel-case frontmatter as an alias.** Rejected because the external format is the kebab-case Claude skills contract and the repository has no released compatibility obligation. Failing loud avoids silently preserving a nonstandard spelling. + +**Add a browser-side direct skill invocation RPC.** Rejected for this change because the existing browser flow inserts a model reference rather than a loaded instruction body. Its correct policy is therefore the intersection; a direct user-loading surface needs its own wire and logging design. + +## Consequences + +Providers and runtime registrations expose a small typed invocation contract, while local YAML remains extensible. Every new discovery consumer must consciously choose the model predicate, the user predicate, their intersection, or trusted unfiltered access; forgetting that choice is now review-visible rather than hidden in registry behavior. + +The changed model catalog is pinned by the keyless ACP snapshot, which includes a model-only skill and excludes a user-only skill. The assembled keyless TUI snapshot discovers and loads a user-only skill by exact name, then rejects a model-only skill before loading its body; the real Loader/PTY smoke proves the same user-only path through the shipped terminal process. The real-host Chromium snapshot pins the browser intersection across all four policy quadrants. TUI unit coverage exercises those quadrants plus disposal races, while registry, local-parser, model-tool, and API-proxy tests cover defaults, supported boolean forms, malformed values, legacy-key rejection, exact-load enforcement, and the browser intersection. diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md new file mode 100644 index 0000000000..1a7117a382 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 模型与用户彼此独立的 skill(技能)调用策略 + +Status: implemented + +[English](2026-07-28-skill-invocation-policy.md) | 中文 + +## 问题 + +skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会移除禁止模型调用的 skill,而 `ctx.skills.get()` 仍是不过滤内容的可信 loader。该设计足以支持由模型发起的加载,却无法表示与 Claude 兼容的四类 skill:仅向用户公开、仅向模型公开、同时向两者公开,或者两者均不公开。TUI 从面向模型过滤后的列表中生成用户自动补全,并允许通过 `get()` 加载任意精确名称,这进一步放大了两类调用策略不匹配的问题。 + +本地解析器还将一种内部驼峰式拼写暴露为 frontmatter。若要支持既有的负向字段 `disable-model-invocation` 和正向字段 `user-invocable`,需要建立持久且对称的领域表示,同时避免把所有可能出现的 YAML 键都变成跨包的无类型契约。 + +## 决策 + +`SkillSummary` 包含一个必填且类型明确的 `invocation: SkillInvocationPolicy` 对象,其 `modelInvocable: boolean` 和 `userInvocable: boolean` 字段为正向且对称。只有显式输入 seam 可以省略它:未提供策略的运行时 `SkillRegistration`,以及两个调用键均未提供的本地 frontmatter,都会在生成候选项或定义前解析为 `{ modelInvocable: true, userInvocable: true }`。未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型;本地提供方仍将 frontmatter 解析为开放的 `Record`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。 + +`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。模型工具和 TUI 会在调用 `get()` 前检查与调用策略无关的摘要,随后再次检查已加载的定义:被拒绝的名称绝不会进入定义加载流程,发现与加载之间发生策略变更也无法暴露该 skill 的正文。 + +本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse` 和 `metadata` 值会被省略,因为它们不参与调用判定。 + +面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 + +这些规则允许以下四种组合: + +| 策略 | 模型侧接口 | 用户侧接口 | +|---|---|---| +| `{ modelInvocable: true, userInvocable: true }` | 包含 | 包含 | +| `{ modelInvocable: true, userInvocable: false }` | 包含 | 排除 | +| `{ modelInvocable: false, userInvocable: true }` | 排除 | 包含 | +| `{ modelInvocable: false, userInvocable: false }` | 排除 | 排除 | + +该决策扩展了 [skill 系统](2026-07-05-skill-system.md),并取代 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)中记录的调用策略限制。 + +## 曾考虑的替代方案 + +**将所有 frontmatter 存入通用 `Map`,并在 `isModelInvocable` / `isUserInvocable` 中读取字符串键。** 不予采纳,因为拼写错误的键、非布尔值以及各消费方自行采用的类型转换都会越过包边界,且无法获得类型检查。解析器边界仍保持开放;领域模型则有意采用类型明确的窄接口。 + +**保持 `ctx.skills.list()` 仅返回允许模型调用的 skill,并另增一份用户列表。** 不予采纳,因为发现、重复项解析、缓存和排序都是与调用接口无关的工作。采用一份完整目录和显式判定函数,可以避免这些机制逐渐分化,并在各消费方边界清楚呈现其策略。 + +**在 `ctx.skills.get()` 内执行调用策略。** 不予采纳,因为 `get()` 无法判断调用方是模型工具、人类命令还是可信编排逻辑。在此处过滤还会使两个接口均禁止调用的组合无法被检查或管理。 + +**将驼峰式 frontmatter 作为别名处理。** 不予采纳,因为外部格式遵循采用 kebab-case 的 Claude skills 契约,而本仓库尚未发布,无需承担兼容义务。快速失败可以避免暗中保留不符合标准的拼写。 + +**增加由浏览器端直接调用 skill 的 RPC。** 本次改动不予采纳,因为现有浏览器流程插入的是模型引用,而非已经加载的指令正文。因此,该流程应当取模型与用户调用策略的交集;直接由用户加载的接口需要单独设计协议与日志记录方式。 + +## 后果 + +提供方与运行时注册对外提供小而类型明确的调用契约,同时本地 YAML 仍可扩展。每个新的发现消费方都必须明确选择模型判定函数、用户判定函数、两者的交集,或可信且不过滤的访问方式;如果遗漏这项选择,评审时可以直接看出问题,而不会再被注册表行为掩盖。 + +无密钥 ACP(Agent Client Protocol)快照固定了模型目录的变更:其中包含仅允许模型调用的 skill,并排除仅允许用户调用的 skill。组装后的无密钥 TUI 快照按精确名称发现并加载一个仅允许用户调用的 skill,随后在加载正文前拒绝一个仅允许模型调用的 skill;真实 Loader/PTY 冒烟测试通过随产品交付的终端进程证明了同一条仅允许用户调用的路径。真实宿主上的 Chromium 快照固定了浏览器在全部四种策略组合下的交集行为。TUI 单元测试覆盖这些组合以及资源释放竞态;注册表、本地解析器、模型工具和 API 代理测试则覆盖默认值、支持的布尔写法、格式错误的值、旧键拒绝、精确名称加载时的策略执行,以及浏览器侧的策略交集。 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml new file mode 100644 index 0000000000..d9ed8345dc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +2026-07-28-web-terminal-card.md: 14896b1d88e5cfd2e4c58830c7a1bca1e54ed823 +2026-07-28-web-terminal-card.zh.md: 16c9004f8f80b720b25b76ba5c04f308b0fccbaf diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md new file mode 100644 index 0000000000..14896b1d88 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -0,0 +1,70 @@ +# Agent Note: Web terminal card — the bash render intent reaches the browser + +Status: implemented + +English | [中文](2026-07-28-web-terminal-card.zh.md) + +## Problem + +The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as a `$`-prompt card with an exit line and a head/tail height cap. + +The Web client ignored it. `packages/client/ui-conversation/src/client/contract/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `
` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
+
+## Decision
+
+`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means.
+
+The component's contract:
+
+- **Prompt lines, one per command line.** Each line of the command gets its own row: label, then that line verbatim. A `command` carrying two shell commands on two lines therefore reads as the two commands it is, instead of collapsing into one ellipsized row. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. A trailing newline is a terminator, not an empty final command. Only the FIRST row carries the label: the view knows one working directory — where the call started — and a later line may run somewhere else entirely, since a `cd` in the command is enough to move it. Repeating the label down the rows would state a directory per line that nothing here knows, which is the same reason the run-state dot appears once. Later rows keep a bare `$` so they still read as prompts.
+- **One run-state dot for the call, on the first row.** `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter the card reserves as its OWN left padding, so it neither indents its command nor depends on the command's text metrics to line up. The reservation is padding rather than margin because every render site rewrites `margin` wholesale to set its own indent, which silently cancelled a margin-based gutter and let a container clip the dot. Exactly one dot, whatever the line count: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per line would assert of a line that succeeded inside a failing call that the line itself failed. The single visually hidden text label carries the same scope, since `StateDot` is `aria-hidden` and one label per row would read to assistive technology as several distinct outcomes.
+- **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding.
+- **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends.
+- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Cursor movements resolve before that strip, into a per-line column buffer rather than by string surgery, because carriage return and backspace only MOVE the cursor — neither erases anything, so what a reader sees is whatever each column last had written to it. `100%` then a carriage return and `OK` shows `OK0%`, since the redraw is shorter than the frame beneath it; a trailing `abc` plus a backspace still shows `abc`, since nothing overwrote the `c`; `abc` plus two backspaces and `XY` shows `aXY`. Each of these was checked against a real terminal, because the earlier truncate-and-delete approximations looked right and were not. SGR state is stamped per column as a terminal stores it per cell, so a partial overwrite keeps each surviving character's own color: red `bad`, three backspaces, then `ok` shows `okd` with the `d` still red. A CSI sequence occupies no column and changes only the state later writes are stamped with, which is also why a carriage return does not reset color, and why SGR state threads from one line to the next rather than closing at each newline. Erase-in-line is part of the same replay, because `\r\x1b[K` is the single idiom every spinner and progress bar writes — modelling the `\r` alone left the previous frame's tail standing, which is text the terminal never showed. Only `m` accumulates into a cell's style; a cursor or erase sequence must not, or the state string grows per redraw and emits boundaries anser has to discard. SGR is held per cell as a NORMALIZED record (foreground, background, attribute set), not as the sequence history: accumulating raw sequences made every state boundary re-emit the whole chain, so output that switches color without a full reset emitted O(n^2) characters — 3200 such cells produced 25 MB and a `RangeError` well under bash's own output cap. The record also lets the attribute closers every chalk-based tool writes (`39`, `49`, `22`, `24`, …) actually close their attribute, and each boundary emits one canonical sequence for the state it opens. A run also has to CLOSE: the replay converges to the state the scan ended in, not the last written cell's, because a reset after the final write changes no cell yet ends the run — without that a line finishing in `\x1b[0m` leaked its color onto every later line. The cursor advances by terminal columns, so a tab reaches the next 8-column stop, a wide character takes two (its spacer blanking rather than closing the gap once the lead cell is overwritten), and a combining mark takes none. Width follows emoji PRESENTATION rather than the U+2600-U+27BF block: `\u2713`, the check every progress line writes, is one column, so treating the block as wide misaligned exactly the output this card exists for. Writing over either half of a wide pair blanks the other, since a terminal cannot leave one cell of a two-cell glyph standing: `a\tb` then a redraw of `XY` shows `XY      b`, since a two-character redraw cannot reach column 8.
+- **Exit status and copy.** A non-zero exit code or a signal renders a status pill, matching the exit-status distinction the bash tool's own renderer draws; a clean exit renders none, and settled empty output renders a dimmed placeholder — judged on the parsed lines the card renders, not on the raw text, since output that is only escapes or control bytes survives a `trim()` yet parses to nothing visible and would otherwise draw blank rows plus a copy control for invisible bytes. The copy control copies the raw output text, not the rendered tree, so the prompt line and the pill stay out of the clipboard.
+
+Geometry, radius, and fonts mirror `CodeBlock`, so a terminal card and a fenced code block match visually; `white-space: pre` plus horizontal scroll is the deliberate divergence. The clipboard write both components need moved out of `CodeBlock` into a package-internal `src/clipboard.ts`, unexported so it stays an implementation detail of the two blocks.
+
+### Inline output in the chat row reverses a stated convention
+
+`chat/ToolRow.tsx` and `contract/tool-call-model.ts` asserted "no inline output ever — full results live in the details panel". Showing the terminal block in the row reverses that, on the owner's explicit decision.
+
+The reason the reversal holds: for a shell command the output *is* the result the user is reading, so routing it exclusively to a panel makes the common case a two-step interaction. A bounded, height-capped, non-wrapping terminal block in the row is what makes a bash-heavy transcript readable in one pass. The old rule's actual concern was a row whose height was unbounded by the length of the output, and the height cap plus expand control is what keeps that from returning.
+
+The remaining bound: the row caps at `CHAT_TERMINAL_MAX_LINES` (8), half the primitive's default, which the panel keeps — the message flow is a summary surface read across many calls, the panel is the single-call reading surface. Only the terminal intent renders inline; a generic tool's content is still panel-only.
+
+One premise of that split has since weakened: [tool rows stopped being details-panel click targets](2026-07-28-tool-call-file-open-in-os.md) and nothing replaced the gesture, so the panel is currently unreachable in the assembled application. The in-row cap is therefore the only surface a reader actually has for a long output, which the expand control covers. Restoring a panel entry point is that change's follow-up, not this one's — but until it lands, "the panel stays the place for the full output" is not true, and the row's expand control carries that load alone.
+
+## Alternatives considered
+
+**Render the terminal block only in the details panel.** This keeps the stated no-inline-output convention and needs no reversal to record. Rejected by the owner's explicit decision: a shell command's output is what the user came to read, and putting it one click away costs more than the convention buys. Recorded here as the owner's call, not as a conclusion derived from the codebase.
+
+**Reuse `CodeBlock` with a `console` language instead of a new primitive.** Rejected: `CodeBlock` soft-wraps, which is the defect being fixed, and it has no exit status, no cwd prompt line, no height cap, and no ANSI handling. Adding four terminal-specific concerns to the shared code-fence component would impose them on every markdown fence. The two components share their geometry and font tokens instead, which is the only part where one implementation is correct for both.
+
+**Hand-roll the SGR parser.** Rejected: an SGR parser is exactly the surface [prefer maintained dependencies over hand-rolling](../process/2026-07-26-dependencies-over-hand-rolling.md) says not to own — its edge cases (256-palette and truecolor forms, `reverse`, multi-parameter runs, unterminated sequences) each fail on output nobody produces in a test, so a hand-rolled version stays subtly wrong for a long time. Stated honestly against that policy's bar: `anser` does **not** delete existing owned code. It is a capability addition, which that note distinguishes from a net-deletion simplification; the health and boundary-fit halves of the bar are what it clears. What stays hand-rolled is the part `anser` does not cover: the theme-token color mapping, the non-CSI sanitizing, the carriage-return redraw, and the per-line span folding the height cap slices.
+
+## Consequences
+
+`anser` is a new runtime dependency of `packages/client/ui-primitives`, so every consumer of that package pays for it once. A bash row in the Web chat carries output, which is a deliberate density increase over a summary-only row; the cap is what keeps it bounded, and a tighter cap is a props change, not a redesign.
+
+`TerminalBlock` reads only the terminal view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the terminal capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed.
+
+A `run_code` sub-dispatch does not reach a terminal card on the shipped wire: `session.ts` folds `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and the host's `viewFor` presents only top-level `tool/call`/`tool/result`, so a nested bash call keeps the generic flattened form. Both arms are pinned — the resolution path with views injected, and the no-view shape the wire actually delivers — so the gap is recorded rather than implied. Carrying presenter views through the code-dispatch wire is that seam's own change.
+
+Inline rendering is licensed for the terminal intent alone. A future intent that wants it needs its own bound and its own decision, argued against the reason recorded here rather than against the panel-only convention on its own.
+
+## Testing
+
+`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly.
+
+`packages/client/ui-conversation/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-conversation/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files.
+
+`apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 65 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes, and turn 60's command was made two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`). That terminal turn is ordered BEFORE the todo turn on purpose: the standing plan retires at the next `turn/start`, so appending it after would have emptied the dock's plan strip and taken the todo surfaces' own coverage with it; that turn also carries what turn 60's two prompt rows cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit authored beside the sample. The sample's body deliberately carries NO `[exit code: N]` line: the real bash presenter consumes that marker out of the body precisely because the card shows the exit as its own pill, so leaving it in would pin a frame showing the exit twice — one the product path cannot produce.
+
+`apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, the run-state dot resolves to the green success token rather than to a literal color (a `--dsw-*` var has no computed value at all without the real theme stylesheet) and sits inside the card box yet left of the prompt label, which is the invariant the gutter padding owns, and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `terminal-card.expected.md` golden records the resolved workspace in the prompt row, which is what a bash call with no `workdir` must show instead of a bare `$`.
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a full consumer of the `terminal` arm rather than of args alone.
+- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md) — owns `CodeBlock` and its shiki arm, and records why tool output deliberately stays unhighlighted; ANSI color here is authored color, not guessed grammar.
+- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.
diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
new file mode 100644
index 0000000000..16c9004f8f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
@@ -0,0 +1,70 @@
+# Agent Note: Web terminal card — the bash render intent reaches the browser
+
+Status: implemented
+
+[English](2026-07-28-web-terminal-card.md) | 中文
+
+## Problem
+
+bash 工具的调用与结果都声明 `card: 'terminal'`([渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md)):调用视图携带命令、一段可选的模型撰写描述以及工作目录,结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 `ConversationSnapshot` 的 `callView`/`resultView` 上——TUI 也早已把它渲染为带 `$` 提示符的卡片,附退出行与首尾高度上限。
+
+Web client 却对它视而不见。`packages/client/ui-conversation/src/client/contract/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `
`,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
+
+## Decision
+
+`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
+
+该组件的契约:
+
+- **提示符行,每条命令行一行。** 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的 `command` 就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。末尾换行是终止符,不是一条空的末命令。只有**第一行**携带该标签:视图只知道一个工作目录——调用开始处的那个——而后面的行完全可能在别处运行,命令里一个 `cd` 就足以改变它。把标签在各行重复,等于陈述一个此处无人知晓的逐行目录,这与运行状态点只出现一次是同一个理由。其余行保留一个裸 `$`,因此它们仍读作提示符。
+- **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片以**自身左内边距**预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。该预留用 padding 而非 margin,是因为每个渲染点都会整条重写 `margin` 来设定自己的缩进——那会静默取消基于 margin 的落区,并让容器把状态点裁掉。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。
+- **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。
+- **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。
+- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。光标移动在该剥除之前先行结算,且落在逐行的列缓冲里而不是靠字符串手术,因为回车与退格**只移动**光标——两者都不擦除任何东西,所以读者看到的就是每一列最后被写入的内容。`100%` 后接回车再接 `OK` 显示为 `OK0%`,因为这次重绘比它下面的帧更短;末尾 `abc` 加一个退格仍显示 `abc`,因为没有任何东西覆盖过那个 `c`;`abc` 加两个退格再接 `XY` 显示 `aXY`。这些用例都对照真实终端核实过,因为先前「截断加删除」的近似看起来是对的,实际并不对。SGR 状态按列打戳,与终端按单元格存储颜色的方式一致,因此部分覆盖会保留每个存活字符自身的颜色:红色 `bad`、三个退格、再写 `ok`,显示为 `okd` 且那个 `d` 仍是红的。CSI 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因,以及 SGR 状态会从一行延续到下一行、而不是在每个换行处关闭的原因。行内擦除属于同一次重放,因为 `\r\x1b[K` 是每个 spinner 与进度条都会写的同一个惯用法——只建模 `\r` 会让上一帧的尾巴留在原处,那是终端从未显示过的文本。只有 `m` 会累加进单元格样式;光标或擦除序列不能累加,否则状态串会随每次重绘线性增长,并发出 anser 只能丢弃的边界。SGR 按单元格以**归一化记录**保存(前景、背景、属性集合),而不是序列历史:累积原始序列会让每个状态边界重新发射整条链,因此不做完整 reset 的换色输出会发射 O(n^2) 个字符——3200 个这样的单元格产生 25 MB 并最终 `RangeError`,远低于 bash 自身的输出上限。该记录也让所有 chalk 系工具写出的属性闭合码(`39`、`49`、`22`、`24` 等)真正闭合其属性,且每个边界只为它开启的状态发射一条规范序列。一个分段也必须**收束**:重放收敛到扫描结束时的状态,而不是最后一个被写入单元格的状态——因为最后一次写入之后的 reset 不改变任何单元格,却结束了该分段;没有这一步,以 `\x1b[0m` 结尾的行会把颜色泄漏到其后所有行。光标按终端列推进,因此制表符前进到下一个 8 列制表位、宽字符占两列(其续列在首列被覆盖后变为空白而非合拢),组合标记不占列。宽度依据 emoji **presentation** 而非 U+2600–U+27BF 整个区块:`\u2713`——每条进度行都会写的对勾——只占一列,把该区块整体当作双宽恰好会错位这张卡片赖以存在的那类输出。写入宽字符对的任一半都会把另一半清成空白,因为终端无法让一个双格字形只留下一格:`a\tb` 之后用 `XY` 重绘显示为 `XY      b`,因为两个字符的重绘到不了第 8 列。
+- **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字——该判定读的是卡片实际渲染的解析行,而非原始文本,因为只含转义或控制字节的输出能通过 `trim()` 却解析不出任何可见内容,否则就会画出一片空行外加一个把不可见字节写进剪贴板的复制控件。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。
+
+几何尺寸、圆角与字体沿用 `CodeBlock`,因此终端卡片与围栏代码块在视觉上一致;`white-space: pre` 加横向滚动是有意的分歧。两个组件都需要的剪贴板写入从 `CodeBlock` 中提取到包内部的 `src/clipboard.ts`,不对外导出,因此它仍是这两个块的实现细节。
+
+### 聊天行内嵌输出推翻了一条既有约定
+
+`chat/ToolRow.tsx` 与 `contract/tool-call-model.ts` 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
+
+这次推翻成立的理由:对 shell 命令而言,输出**就是**用户要读的结果,把它专门收进面板会让最常见的情形变成两步交互。行内一个有界、限高、不换行的终端块,正是让 bash 密集的 transcript 一遍读完的条件。旧规则真正担心的是行高不受输出长度约束,而高度上限加展开控件正是防止其复现的机制。
+
+余下的约束:行内上限为 `CHAT_TERMINAL_MAX_LINES`(8),是组件默认值的一半,而面板沿用默认值——消息流是跨多次调用阅读的摘要表面,面板才是单次调用的阅读表面。只有 terminal 意图内嵌渲染;generic 工具的内容依旧只在面板中。
+
+这一划分的一个前提此后被削弱了:[工具行已不再是详情面板的点击目标](2026-07-28-tool-call-file-open-in-os.md),且没有任何手势接替它,因此该面板在组装后的应用中当前不可达。于是行内上限成为读者实际拥有的唯一长输出表面,由展开控件承担。恢复面板入口属于那次改动的后续,而非本次改动——但在它落地之前,「面板仍是查看完整输出的地方」并不成立,行内的展开控件独自承担了这一职责。
+
+## Alternatives considered
+
+**只在详情面板渲染终端块。** 这样保留既有的「不内嵌输出」约定,也不需要记录任何推翻。已被 owner 的明确决定否决:shell 命令的输出正是用户来读的东西,把它挪到一次点击之外,代价高于该约定带来的收益。此处记录的是 owner 的裁决,而非从代码库推导出的结论。
+
+**复用 `CodeBlock` 并传入 `console` 语言,而不新建组件。** 已否决:`CodeBlock` 会软换行,而软换行正是本次要修的缺陷,且它没有退出状态、没有 cwd 提示符行、没有高度上限、也不处理 ANSI。把四项终端专属关注点加进共享的代码围栏组件,等于把它们强加给每一个 markdown 围栏。两个组件改为共享几何与字体 token,那是唯一一处「一套实现对两者都正确」的部分。
+
+**手写 SGR 解析器。** 已否决:SGR 解析器恰是[优先采用维护良好的依赖而非手写](../process/2026-07-26-dependencies-over-hand-rolling.md)所指明不该自持的那类实现——它的边界情形(256 色板与 truecolor 形式、`reverse`、多参数分段、未终止的序列)各自只在没人会写进测试的输出上失效,因此手写版本会在很长时间内一直微妙地出错。对照那条策略的门槛如实陈述:`anser` **并未**删除任何既有自持代码。它是一次能力增补,而那条 Agent Note 把这与净删除式的简化区分开来;它清过的是健康度与边界契合这两半门槛。`anser` 未覆盖而仍由我们手写的部分是:主题 token 的颜色映射、非 CSI 序列的剥除、回车重绘,以及供高度上限切片的逐行 span 折叠。
+
+## Consequences
+
+`anser` 成为 `packages/client/ui-primitives` 的一项新运行时依赖,因此该包的每个消费方都为它支付一次。Web 聊天中的 bash 行现在承载输出,相比只有摘要的行,这是有意提高的信息密度;上限是维持其有界的机制,而调紧上限是改一个 prop,不是重新设计。
+
+`TerminalBlock` 只读取 terminal 视图携带的字段,因此它始终是渲染意图内容的纯函数——不查会话状态,与产出该视图的 presenter 一样可安全回放。不具备终端能力的 UI 仍从桥接层拿到围栏式回退;工具的结果形态未作任何改动。
+
+在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 code-dispatch wire 属于那道接缝自身的改动。
+
+内嵌渲染的许可仅授予 terminal 意图。将来想要内嵌的意图需要有自己的边界与自己的决定,且需针对此处记录的理由来论证,而不是仅针对「只在面板」这条约定本身。
+
+## Testing
+
+`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。
+
+`packages/client/ui-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。
+
+`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮有意排在 todo 轮**之前**:站立计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮两个提示行无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及在样本旁另行标注的非零退出码。样本正文有意**不含** `[exit code: N]` 行:真实的 bash presenter 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。
+
+`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,`--dsw-*` 变量根本不产生计算值),且它位于卡片盒之内、提示标签之左——这正是那道 gutter 内边距所拥有的不变量;复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `terminal-card.expected.md` 基准记录了提示行中已解析的 workspace——这正是不带 `workdir` 的 bash 调用应当显示的内容,而非一个裸 `$`。
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md)——本次消费的 `card` 标签词汇;Web client 现在是 `terminal` 分支的完整消费方,而不再只消费参数。
+- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md)——它拥有 `CodeBlock` 及其 shiki 分支,并记录了工具输出为何有意不做语法高亮;这里的 ANSI 颜色是作者指定的颜色,不是猜出来的语法。
+- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md)——两个渲染点所处的 slot 与快照分层。
diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml
new file mode 100644
index 0000000000..e0fc203ce6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
+2026-07-29-web-message-icon-actions-and-clock.md: e0072458e4c0d3e37998b5564ad14ce17aa41515
+2026-07-29-web-message-icon-actions-and-clock.zh.md: 1cc25a9656e7a100d78dd3b6b3675ca490f455f9
diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
new file mode 100644
index 0000000000..e0072458e4
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
@@ -0,0 +1,27 @@
+# Agent Note: Web message IconActions and clocks
+
+Status: implemented
+
+English | [中文](2026-07-29-web-message-icon-actions-and-clock.zh.md)
+
+## Problem
+
+The web chat user bubble already had copy / branch / edit IconActions but no clock. Finalized assistant narration had no under-body action chrome at all, even though the Harness design shows a copy / branch / clock row after the answer settles. Streaming replies must not flash that chrome mid-token. Memoized rows also keep stable props across midnight, so a one-shot `Date.now()` would leave yesterday's messages stuck on `HH:mm`.
+
+## Decision
+
+**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`; both seats re-format at the next local midnight.**
+
+Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) and only when `streaming` is false with a known event time; the streaming tail omits the row. Copy writes joined text blocks. Branch stays a chrome stub. Hover-capable pointers keep both footers opacity-hidden until hover/focus-within. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`.
+
+## Alternatives considered
+
+**Show assistant IconActions during streaming.** Rejected: the request is to reveal the row only after output completes; mid-stream chrome would flicker and invite copying a partial answer.
+
+**Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat.
+
+**Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source.
+
+## Consequences
+
+Settled assistant answers expose copy and the event clock immediately; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes and the midnight widen; the web e2e scenario pins the assembled IconActions chrome.
diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md
new file mode 100644
index 0000000000..1cc25a9656
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md
@@ -0,0 +1,27 @@
+# Agent Note: Web 消息 IconActions 与时钟
+
+Status: implemented
+
+[English](2026-07-29-web-message-icon-actions-and-clock.md) | 中文
+
+## 问题
+
+Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有时钟。已定稿的 assistant 叙述下方完全没有操作栏,尽管 Harness 设计稿在回答结束后展示复制/分支/时钟。流式回复不得在 token 中途闪出该栏。经 memo 的行在跨午夜时仍保持稳定 props,因此一次性的 `Date.now()` 会让昨日消息一直卡在 `HH:mm`。
+
+## 决策
+
+**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边都在下一个本地午夜重新格式化。**
+
+两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。
+
+## 曾考虑的方案
+
+**在流式过程中展示 assistant IconActions。** 否决:需求是输出完成后才展示该行;中途 chrome 会闪烁,并诱使复制半截回答。
+
+**把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。
+
+**通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。
+
+## 后果
+
+已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态与午夜加宽;Web e2e 场景钉住组装后的 IconActions chrome。
diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml
index 754b592530..6703e7bc9d 100644
--- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md
-2026-06-16-pnpm-over-yarn.md: 918da1d056dfec73f78d849e08ad06d397e654c0
-2026-06-16-pnpm-over-yarn.zh.md: 4fbb357f689b4a960d0662cbefa5bec30f79bcc1
+2026-06-16-pnpm-over-yarn.md: 30b34c65fdea94b20dec4d627a0fca40de760fd1
+2026-06-16-pnpm-over-yarn.zh.md: eb13890b7e7051301874b9273966771328b065a3
diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md
index 918da1d056..30b34c65fd 100644
--- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md
+++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md
@@ -8,7 +8,7 @@ English | [中文](2026-06-16-pnpm-over-yarn.zh.md)
 
 The repo shipped on **Yarn 4** with the `node-modules` linker — a deliberately conservative choice that behaves like npm's flat layout while giving us Yarn's workspaces and `yarn constraints`. It worked. But Yarn 4's Plug'n'Play heritage makes the `node-modules` linker the off-the-beaten-path mode, and the broader JS ecosystem — tooling defaults, CI actions, Corepack examples, contributor familiarity — increasingly centers on pnpm. For a repo that is built primarily by agents and read by occasional human contributors, "the package manager most tools and people expect" has real value: fewer surprises, better-trodden failure paths, more copy-pasteable answers.
 
-The switching cost is at its lowest right now. Nothing publishes from this repo yet (every package is `private: true`); dev/test/demo all run **unbuilt** via tsx, so the package manager only has to (a) resolve and link `node_modules`, (b) run the workspace scripts, and (c) enforce the workspace constraints. The one Yarn-specific asset is `yarn.config.cjs` (the `@yarnpkg/types` constraints engine), which is small and mechanical to re-express. This mirrors the reasoning in [the tsdown decision](../../archived/process/2026-06-11-tsdown-over-dumble.md): swap a load-bearing tool for the healthier-ecosystem option while the blast radius is still small.
+The switching cost is at its lowest right now. Nothing publishes from this repo yet (every package is `private: true`); development, tests, and source-mode demos run through their declared TypeScript launchers, while artifact checks build explicitly. The package manager therefore only has to (a) resolve and link `node_modules`, (b) run the workspace scripts, and (c) enforce the workspace constraints. The one Yarn-specific asset is `yarn.config.cjs` (the `@yarnpkg/types` constraints engine), which is small and mechanical to re-express. This mirrors the reasoning in [the tsdown decision](../../archived/process/2026-06-11-tsdown-over-dumble.md): swap a load-bearing tool for the healthier-ecosystem option while the blast radius is still small.
 
 ## Decision
 
diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md
index 4fbb357f68..eb13890b7e 100644
--- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md
+++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md
@@ -8,7 +8,7 @@ Status: implemented
 
 本仓库最初使用 **Yarn 4** 搭配 `node-modules` 链接器启动。这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时享有 Yarn 的 workspaces 和 `yarn constraints`。它能正常工作。但 Yarn 4 源自 Plug'n'Play 的血统,使得 `node-modules` 链接器成为非主流模式;而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者的熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent(智能体)构建、偶尔有人类贡献者阅读的仓库而言,「大多数工具和人所期望的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的解答。
 
-切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发/测试/演示全部通过 tsx **未构建**运行,因此包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。
+切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发流程、测试和源码模式 demo 都通过各自声明的 TypeScript 启动器运行,产物检查则会显式构建。因此,包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。
 
 ## 决策
 
diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml
index 45ddcdfdae..4319bcee3e 100644
--- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml
@@ -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-06-17-ts-build-config.md: 5bdfc5e170f12cd95a68f443ab8d02b16db554f3
-2026-06-17-ts-build-config.zh.md: 9f74fb6be9c8e00a070e27a609edf8421a2b5ca6
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-17-ts-build-config.md
+2026-06-17-ts-build-config.md: 20dd3d8d0a11e01397c36388903cd112a170f0bf
+2026-06-17-ts-build-config.zh.md: 196ed3f2921a4be6f65cd56fc496993b7121f484
diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md
index 5bdfc5e170..20dd3d8d0a 100644
--- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md
+++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md
@@ -62,7 +62,7 @@ pnpm run clean:
 tsx scripts/clean.ts
 ```
 
-`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step.
+The source-mode demos run through their declared TypeScript launchers and the root paths map. The `dsh` TUI chain uses Node's native transform plus its app-owned paths loader, the Web demo builds its required artifacts before entering that same CLI source chain, and the other source demos continue to use tsx.
 
 ## Alternatives considered
 
@@ -75,7 +75,7 @@ tsx scripts/clean.ts
 
 Build responsibilities are clearer:
 
-- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`.
+- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`.
 - The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`.
     - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output.
     - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files.
diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md
index 9f74fb6be9..196ed3f292 100644
--- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md
+++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md
@@ -62,7 +62,7 @@ pnpm run clean:
 tsx scripts/clean.ts
 ```
 
-`pnpm run demo:*` 仍通过 tsx 和根路径直接运行 `src`,无需编译步骤。
+源码模式 demo 通过各自声明的 TypeScript 启动器和根路径映射运行。`dsh` TUI 链使用 Node 原生转换及应用自有的路径 loader,Web demo 在进入同一条 CLI 源码链路前先构建所需产物,其他源码 demo 继续使用 tsx。
 
 ## 曾考虑的替代方案
 
@@ -75,7 +75,7 @@ tsx scripts/clean.ts
 
 构建职责更加清晰:
 
-- `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `tsx` 和 `vitest`)。
+- `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。
 - `build` 命令驱动根 solution 图。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责 `lib/index.*`。
     - `lib/types/*.d.ts` 和 `.d.ts.map` 是发布用的声明输出。
     - `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。
diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml
index e90e257c46..789f1ec6ea 100644
--- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md
-2026-07-26-briefed-minimal-translation-updates.md: 63f25d5c36caecba435e9192534e0b494e1e0105
-2026-07-26-briefed-minimal-translation-updates.zh.md: 17cf2f895b187fb794e691c2b14d6e0bff78363d
+2026-07-26-briefed-minimal-translation-updates.md: a47251376771165d0eb229aaa0fb7f63589d7d77
+2026-07-26-briefed-minimal-translation-updates.zh.md: 5a6787b9bdb3286b71842abec075c5a0ebfc7991
diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md
index 63f25d5c36..a472513767 100644
--- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md
+++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md
@@ -14,7 +14,7 @@ Pair updates run on a generated briefing instead of the guidance corpus; only ne
 
 - **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the [incremental prompt-pipeline work](https://github.com/deepseek-harness/deepseek-harness/pull/684), whose provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer.
 - **The update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document.
-- **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command.
+- **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command. Before recording, `--write` stores each side's exact bytes with `git hash-object -w --stdin` and pins the blob under a content-addressed local `refs/dsh/translation-pairing/snapshots/` ref; an uncommitted last-confirmed snapshot is therefore available to the briefing generator's later `git cat-file`, not merely named by a hash that Git cannot resolve or left vulnerable to garbage collection.
 
 ## Benchmark
 
@@ -40,6 +40,7 @@ A second head-to-head replay on the same ten examples compared this note's shipp
 
 - A small prose edit's counterpart update now costs a briefing generation plus one small focused task — no corpus reads, no archaeology, no corpus-wide scans inside the loop — and the same PR obligation holds; the cheap path and the correct path point the same way.
 - The briefing generator is a second consumer of the consistency records: recorded blob hashes now also drive diff recovery and section mapping, strengthening the incentive to keep records honest.
+- Each distinct confirmed snapshot retains a content-addressed local ref and object. An abandoned re-record may therefore leave an extra durable pin, but it changes no branch or commit history; this local retention is the tradeoff that prevents garbage collection from invalidating an accepted pairing record.
 - `--write` without arguments no longer works; muscle-memory callers must name pairs or pass `--all`. That is the point — the bulk bless is now a visible, deliberate act.
 - Scoped checks mean an update loop can be green while an unrelated pair elsewhere is red; the corpus-wide check in `doc-sync`/CI still owns the tree-level invariant.
 - Span mapping trusts an alignment only when the kind sequences match across the last-confirmed source, current source, and current counterpart; a mapping failure widens deterministically (units → sections → whole document) rather than guessing, so a restructured document gets an explicit "locate the regions yourself" briefing, never a wrong map.
@@ -47,4 +48,4 @@ A second head-to-head replay on the same ten examples compared this note's shipp
 
 ## Testing
 
-[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) pins unit and section span extraction (container-scoped kinds, depth-only section alignment so translated heading text still maps, preamble), alignment and changed-index detection, the mechanical code splice and each of its refusal conditions, terminology row matching in both directions with word-boundary and plural-inflection discipline, first-occurrence movement tracking, fence escalation, and the rendered briefing's contract (unit bundles with three-way context, mechanical/sections/document scopes, per-direction digests, scoped finish commands). [scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) pins argument normalization (any pair file or bare stem to the anchor) and the CLI matrix: scoped check, bare `--write` refusal, `--write `, `--write --all`, `--list` exclusivity, unknown flags.
+[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) pins unit and section span extraction (container-scoped kinds, depth-only section alignment so translated heading text still maps, preamble), alignment and changed-index detection, the mechanical code splice and each of its refusal conditions, terminology row matching in both directions with word-boundary and plural-inflection discipline, first-occurrence movement tracking, fence escalation, and the rendered briefing's contract (unit bundles with three-way context, mechanical/sections/document scopes, per-direction digests, scoped finish commands). [scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) pins exact-byte persistence and recovery for uncommitted snapshots, failure before an unavailable object can enter a sidecar, argument normalization (any pair file or bare stem to the anchor), and the CLI matrix: scoped check, bare `--write` refusal, `--write `, `--write --all`, `--list` exclusivity, unknown flags.
diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md
index 17cf2f895b..5a6787b9bd 100644
--- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md
@@ -14,7 +14,7 @@ Status: implemented
 
 - **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了[增量提示词流水线工作](https://github.com/deepseek-harness/deepseek-harness/pull/684)中的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。
 - **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。
-- **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。
+- **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。写下记录之前,`--write` 用 `git hash-object -w --stdin` 存入每一侧的精确字节,并在内容寻址的本地 `refs/dsh/translation-pairing/snapshots/` ref 下固定该 blob;未提交的上次确认快照因此能被简报生成器之后的 `git cat-file` 取回,而不只是留下一个 Git 无法解析的 hash 名称或暴露于垃圾回收。
 
 ## 基准测试
 
@@ -40,6 +40,7 @@ Status: implemented
 
 - 一次小的行文修改,其对侧更新如今只需生成一份简报,外加一个小而聚焦的任务(不读指导语料、不翻查历史、循环内不做全语料扫描),同一 PR 内完成更新的义务保持不变;低成本的路径与正确的路径指向同一个方向。
 - 简报生成器成为一致性记录的第二个消费方:记录的 blob hash 如今还驱动 diff 还原与章节映射,这进一步强化了如实维护记录的动机。
+- 每个不同的已确认快照都会保留一个内容寻址的本地 ref 和对象。因此,中途放弃的重新记录可能留下额外的持久固定项,但它不会改变任何分支或提交历史;这种本地保留正是防止垃圾回收让已接受配对记录失效所付出的代价。
 - 不带参数的 `--write` 不再可用;靠肌肉记忆的调用者必须点名配对或传 `--all`。这正是目的所在:批量背书如今是一个可见的、有意为之的动作。
 - 按对检查意味着一个更新循环可以在别处某个无关配对处于红灯时自己保持绿灯;`doc-sync`/CI 中的全语料检查仍然承载树级不变式。
 - 区间映射只在上次确认源文、当前源文与当前对侧文本三方的种类序列一致时才信任一处对齐;映射失败时粒度确定性地逐级放宽(单元 → 章节 → 整篇文档)而不是靠猜,因此被重构过的文档拿到的是一份明确写着「请自行定位相关区域」的简报,绝不会是一张错误的地图。
@@ -47,4 +48,4 @@ Status: implemented
 
 ## 测试
 
-[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) 固定单元与章节的区间提取(以容器为作用域的种类、只按深度对齐章节从而让已翻译的标题文字仍能映射、首个标题前的序言)、对齐与改动索引检测、机械代码拼接及其每一个拒绝条件、带词边界与复数变形约束的双向术语行匹配、首次出现移位跟踪、围栏升级,以及渲染后简报的契约(带三方上下文的单元条目、机械/章节/整篇文档三种范围、分方向的规则摘要、按对的收尾命令)。[scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) 固定参数归一化(配对的任一文件或裸词干都归一到锚点)与 CLI(命令行界面)用例矩阵:按对检查、裸 `--write` 拒绝执行、`--write `、`--write --all`、`--list` 的互斥性、未知标志。
+[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) 固定单元与章节的区间提取(以容器为作用域的种类、只按深度对齐章节从而让已翻译的标题文字仍能映射、首个标题前的序言)、对齐与改动索引检测、机械代码拼接及其每一个拒绝条件、带词边界与复数变形约束的双向术语行匹配、首次出现移位跟踪、围栏升级,以及渲染后简报的契约(带三方上下文的单元条目、机械/章节/整篇文档三种范围、分方向的规则摘要、按对的收尾命令)。[scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) 固定未提交快照的精确字节持久化与取回、在不可用对象进入伴随记录前失败、参数归一化(配对的任一文件或裸词干都归一到锚点),以及 CLI(命令行界面)用例矩阵:按对检查、裸 `--write` 拒绝执行、`--write `、`--write --all`、`--list` 的互斥性、未知标志。
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
index 9fd37bcedb..72cbbc368f 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
@@ -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-26-web-syntax-highlighting-shiki.md: b329e35f1d0ce7b3de454758403a09f67056b5af
-2026-07-26-web-syntax-highlighting-shiki.zh.md: 8e9d1f0d0c38ce64bcb5da1262538da762f70b12
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
+2026-07-26-web-syntax-highlighting-shiki.md: 48a1e4c43f19693f90906f210f0ed85db3f31687
+2026-07-26-web-syntax-highlighting-shiki.zh.md: 780b66a309c841c542f873f376226b8da454e050
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
index b329e35f1d..48a1e4c43f 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
@@ -17,7 +17,7 @@ The client rendered every code surface — markdown fences in assistant prose, t
 - **Dependency**: `shiki/core` + `@shikijs/langs`, composed via `createHighlighterCoreSync` with `createJavaScriptRegexEngine({ forgiving: true })` — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: `typescript` (embeds JS), `shellscript`, `json` — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here.
 - **Singleton**: `ui-primitives/src/markdown/highlight.ts` creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). Engine + grammar construction is a ~120-175ms long task, so the module pre-warms the singleton in a deferred task at plugin boot (the lazy path stays as the correctness fallback), keeping the cost off the render path where a stream's finalize swap would jank. The alias table is a `Map`, not an object: fence info strings are assistant-authored, so a label like `constructor` must miss instead of resolving an inherited property and crashing shiki. The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path.
 - **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in a new `ui-theme/styles/shiki.css` token sheet (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by the shell's `base.css` chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree.
-- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps.
+- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Tool output is never syntax-highlighted — it is arbitrary text, and guessing a grammar would mis-highlight more than it helps; a bash card's output carries only the color its own ANSI sequences declare, through [the terminal card](../feature/2026-07-28-web-terminal-card.md).
 
 ## Alternatives considered
 
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
index 8e9d1f0d0c..780b66a309 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
@@ -17,7 +17,7 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围
 - **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。
 - **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
 - **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
-- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。
+- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。工具输出从不做语法高亮——它是任意文本,硬猜一种语法,带来的误高亮会多于帮助;bash 卡片的输出只承载其自身 ANSI 序列声明的颜色,经由[终端卡片](../feature/2026-07-28-web-terminal-card.md)渲染。
 
 ## 曾考虑的替代方案
 
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
index 34dcf42c4f..4eb85b7d70 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
-2026-07-27-worktree-local-lefthook.md: d18f6c1bf8fe240759ad48f67ca6b231000eaf2c
-2026-07-27-worktree-local-lefthook.zh.md: 42a1625a3b2ec7b00942dc46b0c9c64058ecd2fc
+2026-07-27-worktree-local-lefthook.md: 75dfd47087356c34005ec4673e174e451a72c660
+2026-07-27-worktree-local-lefthook.zh.md: bc4902769561c3d33d2101de55e28e70d114f39b
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
index d18f6c1bf8..75dfd47087 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
@@ -16,9 +16,9 @@ Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, t
 
 Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking.
 
-Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
+Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Git seeds a new linked worktree's `config.worktree` from the main worktree; when that seed contains the marker-backed reserved hook path of a registered worktree, the installer replaces only the new worktree's config with its own path. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
 
-If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback.
+If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, copied new-worktree configuration, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback.
 
 ## Alternatives considered
 
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
index 42a1625a3b..bc49027695 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
@@ -16,9 +16,9 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝
 
 升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空且尚未生效的 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须手动恢复,不会被自动破坏。
 
-每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。
+每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Git 会以主 worktree 的配置为新链接 worktree 初始化 `config.worktree`;当该初始配置包含某个已注册 worktree 中由所有权标记佐证的保留钩子路径时,安装程序只会在新 worktree 的配置中将其替换为新 worktree 自有的路径。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。
 
-若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。
+若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、复制的新 worktree 配置、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。
 
 ## 考虑过的替代方案
 
diff --git a/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.i18n.yaml
new file mode 100644
index 0000000000..7505370b2b
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.i18n.yaml
@@ -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-07-28-local-json-tree-renderer.md
+2026-07-28-local-json-tree-renderer.md: 5e8e6d6968319ada53e65a48e9e03b297dc0141c
+2026-07-28-local-json-tree-renderer.zh.md: 221880391da05551d05ad9e3dadb909e2e74fa68
diff --git a/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.md b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.md
new file mode 100644
index 0000000000..5e8e6d6968
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.md
@@ -0,0 +1,32 @@
+# Agent Note: Local JSON tree renderer
+
+Status: implemented
+
+English | [中文](2026-07-28-local-json-tree-renderer.zh.md)
+
+## Problem
+
+The read-only JSON inspector used by the [trajectory ledger](../feature/2026-07-27-trajectory-inspection-ledger.md) needs compact object and array previews, explicit array paths for copy actions, fixed-open and collapsible root modes, and keyboard navigation. `react-json-view-lite` exposes neither custom node rendering nor row identity, so satisfying those requirements through that dependency requires a package-manager patch against compiled distribution files and DOM traversal that reconstructs data paths from visible labels. The patch behaves as an untyped fork while its source maps and upstream source remain unchanged.
+
+## Decision
+
+`JsonTree` owns its recursive presentation in `dsh-client-ui-primitives`.
+
+- Each rendered row receives its value and property path directly. Object keys and array indexes extend that path during recursion, so copy actions never recover application data from rendered DOM text.
+- Expandable rows render the compact preview locally and mount child rows only while expanded. `expandTopLevel` selects between a fixed-open bracket frame and a collapsible root node without changing the public component contract.
+- The tree keeps one tabbable expander among visible nodes. Pointer activation claims that tab stop; Up and Down move it cyclically, while Left and Right collapse or expand the focused node.
+- `react-json-view-lite` is not a package dependency and has no pnpm patch. Focused component tests pin previews, expansion, keyboard focus, and array copy paths.
+
+## Alternatives considered
+
+**Keep the distribution patch.** Rejected because the application-specific renderer and array identity contract would remain hidden in generated third-party files, and every dependency update would require reviewing a fork without matching source maps.
+
+**Use the upstream renderer without previews.** Rejected because `{…}` and `[…]` discard the compact payload context that the trajectory inspector uses for scanning.
+
+**Inject previews and row metadata after render.** Rejected because effects or mutation observers would depend on the same private DOM structure while splitting one row between React ownership and imperative mutation.
+
+**Adopt a larger JSON viewer.** Rejected because editing, search, and theme systems are outside the current read-only contract; the added dependency surface would not remove the inspector-specific copy and layout code.
+
+## Consequences
+
+The JSON inspector has one source-level owner, explicit data flow, accurate array paths, and no patched dependency. The package now owns recursive rendering, expansion state, ARIA tree structure, and roving focus behavior, so changes to those semantics require focused component coverage. The implementation remains intentionally read-only and limited to the preview, navigation, and copy behavior used by current consumers.
diff --git a/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.zh.md b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.zh.md
new file mode 100644
index 0000000000..221880391d
--- /dev/null
+++ b/.agents/notes/implemented/simplification/2026-07-28-local-json-tree-renderer.zh.md
@@ -0,0 +1,32 @@
+# Agent Note:本地 JSON 树渲染器
+
+Status: implemented
+
+[English](2026-07-28-local-json-tree-renderer.md) | 中文
+
+## 问题
+
+[轨迹检查记录表](../feature/2026-07-27-trajectory-inspection-ledger.md)使用的只读 JSON 检查器需要提供紧凑的对象和数组预览、供复制操作使用的明确数组路径、固定展开与可折叠两种根节点模式,以及键盘导航。`react-json-view-lite` 既不提供自定义节点渲染,也不提供行标识;要通过该依赖满足这些要求,就必须使用包管理器为编译后的发布文件打补丁,并遍历 DOM,从可见标签中还原数据路径。该补丁实际上相当于一个不受类型约束的 fork,但其源码映射与上游源码均未同步修改。
+
+## 决策
+
+`dsh-client-ui-primitives` 中的 `JsonTree` 自行负责递归呈现。
+
+- 每个渲染行都直接接收自身的值和属性路径。递归时,对象键和数组索引会附加到路径末尾,因此复制操作无需再从 DOM 渲染文本中反向还原应用数据。
+- 可展开行在本地渲染紧凑预览,仅在展开时挂载子行。`expandTopLevel` 可选择固定展开的括号框架或可折叠根节点,而不改变组件的公开契约。
+- 树中所有可见节点仅保留一个可通过 Tab 键聚焦的展开控件。使用指针激活控件后,该控件会成为 Tab 键焦点位置;上、下方向键循环移动焦点,左、右方向键折叠或展开当前焦点所在节点。
+- `react-json-view-lite` 不在 `dsh-client-ui-primitives` 的依赖项中,也没有 pnpm 补丁。针对性组件测试锁定预览、展开、键盘焦点和数组复制路径。
+
+## 曾考虑的替代方案
+
+**保留发布文件补丁。** 不予采纳:应用专用的渲染逻辑和数组标识契约仍会隐藏在生成的第三方文件中;每次更新依赖,都必须评审一个没有配套源码映射的 fork。
+
+**直接使用不提供预览的上游渲染器。** 不予采纳:`{…}` 和 `[…]` 会丢失轨迹检查器扫读时所需的紧凑载荷上下文。
+
+**渲染后注入预览和行元数据。** 不予采纳:Effect 或 MutationObserver 仍会依赖同一套私有 DOM 结构,同时使 React 和命令式变更机制分别负责同一行的不同部分。
+
+**采用更大型的 JSON 查看器。** 不予采纳:编辑、搜索与主题系统不在当前只读契约范围内;扩大依赖范围也无法去除检查器专用的复制和布局代码。
+
+## 后果
+
+JSON 检查器在源码层由单一实现负责,具有显式数据流和准确的数组路径,且没有经过补丁修改的依赖。`dsh-client-ui-primitives` 负责递归渲染、展开状态、ARIA 树结构和焦点循环移动行为,因此修改这些语义时必须提供针对性组件测试。实现有意保持只读,仅包含当前消费方使用的预览、导航与复制行为。
diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml
index 48850b2a53..f126d6a85b 100644
--- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml
@@ -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/testing/2026-06-19-acp-snapshot-tests.md
-2026-06-19-acp-snapshot-tests.md: ba46682b3087e3d2ff4c52d2ad22f54b7dac31db
-2026-06-19-acp-snapshot-tests.zh.md: 58195c47889edb31d5122116328928f916ad09a9
+2026-06-19-acp-snapshot-tests.md: e118ada58230fe31fbb2a6bffb83e5612757ab1f
+2026-06-19-acp-snapshot-tests.zh.md: 5c239d2c2589708050cd6199231ec5047f225107
diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
index ba46682b30..e118ada582 100644
--- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
+++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md
@@ -57,7 +57,7 @@ A snapshot run asserts **two** normalized surfaces, because the harness's extern
 
 The surfaces are complementary: stdout covers the minimal automation wire, while JSONL covers loop, tool, and boundary structure that the wire intentionally omits.
 
-Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Vitest updates only the stdout expected output; normalized session equality never overwrites the replay fixture.
+Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Record and refresh also store a generated workspace and its filesystem-resolved aliases as `{{cwd}}` in the replay fixture, so platform temp roots and random basenames do not affect recordings; authored temp paths and cwd values under an explicit `workspaceParent` remain literal. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Ordinary Vitest snapshot updates write only the stdout expected output; the explicit `record` and `refresh` modes own replay-fixture writes.
 
 ### Isolation: normalization now, sandbox later
 
diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
index 58195c4788..5c239d2c25 100644
--- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
+++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
@@ -57,7 +57,7 @@ Status: implemented
 
 两个表面互补:stdout 覆盖精简的自动化线协议,JSONL 覆盖线协议有意省略的 loop、工具和 boundary 结构。
 
-规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。Vitest 只更新 stdout 预期输出;规范化会话相等性检查从不覆盖重放 fixture。
+规范化会替换会话、cwd、协议 id、时间戳、路径和进程易变值,同时保留确定性序号。录制与刷新还会在回放 fixture 中将生成的 workspace 及其文件系统解析出的别名存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;手工编写的临时路径与显式 `workspaceParent` 下的 cwd 值仍保留字面值。场景把真实 bash 使用限制在稳定命令上。stdout 预期输出仍是线协议形状的 JSONL,每个原始行都必须可解析为 JSON。普通 Vitest 快照更新只写入 stdout 预期输出;回放 fixture 的写入由显式 `record` 和 `refresh` 模式负责。
 
 ### 隔离:当前靠归一化,后续可加沙箱
 
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
index 43ca03dc7a..0283559c9c 100644
--- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
@@ -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/testing/2026-07-24-web-gui-browser-e2e-lane.md
-2026-07-24-web-gui-browser-e2e-lane.md: d9e0a9660ecd6aeb75e835e68f92c0a268423872
-2026-07-24-web-gui-browser-e2e-lane.zh.md: e8c7d1c4596f20d88bd08423549fb6a9f7b0654b
+2026-07-24-web-gui-browser-e2e-lane.md: ce59dcce270d548c91e3719eee8e9c83aea0c154
+2026-07-24-web-gui-browser-e2e-lane.zh.md: bad3dd15ed7b98cc17340666a6c1094d0de057b1
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
index d9e0a9660e..ce59dcce27 100644
--- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
@@ -42,7 +42,7 @@ The typecheck plane split is structural: the host scaffold, its support module,
 
 ### Coverage contract
 
-The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout persistence, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown.
+The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown.
 
 ### CI stance
 
diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
index e8c7d1c459..bad3dd15ed 100644
--- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
+++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
@@ -42,7 +42,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 
 ### 覆盖契约
 
-该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局持久化、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。
+该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。
 
 ### CI 立场
 
diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml
index c9cebc9db9..c66891f689 100644
--- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml
+++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml
@@ -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-25-scriptable-llm-wire-fault-server.md: 0795f71f0eab1a107740aaa8cba6fa04b1fbd306
-2026-07-25-scriptable-llm-wire-fault-server.zh.md: a0e3c98d729adcc74e6d98933ab2beb538f71cb3
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md
+2026-07-25-scriptable-llm-wire-fault-server.md: b8e64d92db224c199d0f8c547f0caa588d0ca65e
+2026-07-25-scriptable-llm-wire-fault-server.zh.md: 35b27efa99b625fa3c815bbe58fef6a138477e55
diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md
index 0795f71f0e..b8e64d92db 100644
--- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md
+++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md
@@ -12,7 +12,7 @@ Connection refusal, a reset before the first event, clean EOF without `[DONE]`,
 
 ## Decision
 
-`@deepseek-ai/dsh-llm-mock-server` is a support package with an importable Node HTTP server and a standalone CLI. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`.
+`@deepseek-ai/dsh-llm-mock-server` is a private support package with an importable Node HTTP server. The repository-local `pnpm run mock:llm` source entry provides a standalone process for manual fault injection; the package exposes no installable binary. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`.
 
 Request behaviors cover socket reset, post-header disconnect, partial disconnect, stall, valid empty completion, clean truncated streams, malformed payloads, representative HTTP failures, complete text/reasoning/tool-call responses, slow streaming, and max-token completion. A true `connection_refused` is a CLI listener-lifecycle phase because a bound request handler cannot refuse its own TCP connection.
 
@@ -32,10 +32,12 @@ Package tests exercise every request behavior, split UTF-8 request decoding, HTT
 
 **Use only an in-process `LlmAdapter` mock** — rejected because it bypasses fetch, HTTP status/header parsing, SSE framing, socket termination, and the adapter idle watchdog: the exact boundaries this test infrastructure exists to exercise.
 
+**Expose an installable workspace binary** — rejected because pnpm links dependency binaries before repository build outputs exist, coupling clean installs to a test-only artifact. The repository-local source command supports the same manual fault injection without adding a package installation surface.
+
 **Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Extending recovery to `STREAM_CLOSED` requires a separate decision with its own cost, latency, and duplicate-generation trade-offs.
 
 ## Consequences
 
 Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and recovered empty completions without splicing attempts or modifying model history.
 
-The server adds a support package, executable build entry, and behavior vocabulary that must remain compatible with both direct tests and CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval.
+The server adds a private support package and behavior vocabulary that must remain compatible with both direct tests and repository-local CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval.
diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md
index a0e3c98d72..35b27efa99 100644
--- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md
+++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md
@@ -12,9 +12,9 @@ Status: implemented
 
 ## 决策
 
-`@deepseek-ai/dsh-llm-mock-server` 是一个支持包(package),提供可导入的 Node HTTP 服务器和独立 CLI(命令行界面)。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败;只有设置 `repeatLast` 才会重复最后一个行为。
+`@deepseek-ai/dsh-llm-mock-server` 是一个私有支持包(package),提供可导入的 Node HTTP 服务器。仓库内的 `pnpm run mock:llm` 源码入口提供一个用于手动故障注入的独立进程;该包不公开可安装的二进制命令。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败;只有设置 `repeatLast` 才会重复最后一个行为。
 
-请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI 的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。
+请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI(命令行界面)的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。
 
 脚本项 `random` 会为每个请求重新执行一次加权选择。服务器公开并记录其无符号 32 位 seed,允许调用方提供相对权重,并内置一套偏重成功结果的压力测试配置,将传输、协议、提供方、超时和语义空结果混合在一起。该配置用于提供可调的测试压力,并非对生产事故发生频率的估算;`connection_refused` 仍不进入请求级随机池。
 
@@ -32,10 +32,12 @@ Status: implemented
 
 **仅使用进程内的 `LlmAdapter` mock**:不予采纳。它会绕过 fetch、HTTP 状态与 header 解析、SSE 分帧、socket 终止以及适配器的空闲看门狗,而这正是这套测试基础设施要覆盖的边界。
 
+**公开可安装的 workspace 二进制命令**:不予采纳。pnpm 会在仓库构建产物存在之前链接依赖项的二进制命令,从而让干净安装与仅供测试的产物产生耦合。仓库内的源码命令支持相同的手动故障注入,而不会新增包安装接口。
+
 **随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否将恢复能力扩展到 `STREAM_CLOSED`,需要单独决策,并权衡成本、延迟和重复生成风险。
 
 ## 后果
 
 开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed,让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与恢复后的空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。
 
-服务器新增了一个支持包、可执行构建入口和行为词汇,三者必须同时兼容直接测试与 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。
+服务器新增了一个私有支持包和一套行为词汇,二者必须同时兼容直接测试与仓库内的 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。
diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml
index 2476db5785..8f720e33b5 100644
--- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml
+++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml
@@ -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/proposed/architecture/2026-07-27-session-projection-and-command-log.md
-2026-07-27-session-projection-and-command-log.md: 51cc60208ecafd55738c12f1887056c7b0427117
-2026-07-27-session-projection-and-command-log.zh.md: 71f6f6ea944c7c1bdd7e560ec8f0dc2528522fc1
+2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5
+2026-07-27-session-projection-and-command-log.zh.md: 500f07968db049e4a174ff3b7a075bfe095283db
diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
index 51cc60208e..6a073c956c 100644
--- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
+++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
@@ -51,7 +51,7 @@ declare module 'cordis' {
 
 - Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
 - **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code.
-- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, stateVersion, observedSeq, stateJson)`. A row is never wrong, only possibly stale — `observedSeq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
+- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
 - A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs).
 - Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
 - The package owns `./invariant` (every served key has a live registration).
@@ -133,7 +133,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
 2. **Client base**: the generic value store + `useProjection` seat; retire the per-domain cell machinery and, with title's unit registered, the `session/title` frame and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile).
 3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement, `{matched, commandId?}` admission. Parallel with 1.
 4. **Domain re-targets** (after 1+2): todo (unit in `tool-todo`, drop the rider field), then plan (two-event unit, RPCs retired, toggle → `/plan`), then goal (`goal/change` unit, drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
-5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, stateVersion, observedSeq, state)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
+5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, ver, seq, val)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
 
 ## Alternatives considered
 
diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md
index 71f6f6ea94..500f07968d 100644
--- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md
+++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md
@@ -51,7 +51,7 @@ declare module 'cordis' {
 
 - 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 侧单元、协议块、React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
 - **host 是投影唯一的计算地点。** 框架正向驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。
-- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, stateVersion, observedSeq, stateJson)` 的行。一行永远不会是错的,至多是陈旧的——`observedSeq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
+- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
 - 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。
 - 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
 - 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
@@ -133,7 +133,7 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `
 2. **客户端基座**:通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1(在此之前 fixture(测试前置数据)喂合成帧)。
 3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。
 4. **领域重新对接**(在 1+2 之后):先 todo(单元进 `tool-todo`,删掉搭载字段),再 plan(双事件单元、RPC 下线、开关改发 `/plan`),最后 goal(`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject)。
-5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, stateVersion, observedSeq, state)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
+5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, ver, seq, val)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
 
 ## Alternatives considered
 
diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
new file mode 100644
index 0000000000..af41b392bd
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
@@ -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/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
+2026-07-28-storage-root-and-derived-medium-recovery.md: 06fa98b10dc5ac3164d8905e7005a42d9e99ae92
+2026-07-28-storage-root-and-derived-medium-recovery.zh.md: b7bd18ffdbfaf412d9a91940cf1770e273f5b847
diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
new file mode 100644
index 0000000000..06fa98b10d
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
@@ -0,0 +1,57 @@
+# Agent Note: Storage root placement and derived-medium recovery
+
+Status: proposed
+
+English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
+
+## Problem
+
+The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack.
+
+**Where the files actually live.** The shipped composition gives the json backend a relative root — `root: './.storages'` (apps/cli/cordis.yml) — and `AppCLIEntry.composePatches` patches only the session store's root to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`, profile-overridable via `persistenceRoot`); no equivalent patch or profile key exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
+
+**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
+
+## Proposal
+
+Two independent changes, one per gap.
+
+### One global storage root, resolved once
+
+- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')` — `~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today.
+- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
+- Pre-release stance applies: no migration shim. A deployment that cached under `/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
+
+### Declared derived media: reset instead of reject
+
+- `DomainSpec` gains `recovery?: 'reject' | 'reset'` (default `'reject'`). The spec object is already the single source of a domain's identity and layout; whether its medium is authoritative or derived is the same kind of fact and lives in the same place. `session_projcache` declares `'reset'`; `workspace` stays on the default.
+- `KvFacet` gains one primitive: `destroy(descriptor): Promise` — remove the unit's medium entirely (json: delete the file; sqlite: drop the unit's tables). Like `open`, it is a backend storage primitive, not policy.
+- `DomainFacility.open`, when a spec declares `'reset'` and the open fails with exactly a damage-class error — `StorageError('version-mismatch' | 'malformed-medium')` or `DomainError('invalid-record')` — logs one warning naming the domain and the discarded medium, calls `destroy`, and opens again empty. Every other failure (`backend-not-found`, `facet-unsupported`, `already-open`, I/O errors) stays loud regardless of the declaration: misconfiguration and environmental faults are not medium damage. The retry is single-shot — a second failure propagates, so a persistently failing medium cannot loop.
+- With this in place the cache domain spec's version field gains its intended meaning: bumping `version` (or letting zod reject drifted rows) genuinely discards the whole medium and the cache rebuilds through its normal write points and cold reads — the recovery ladder's outermost rung, matching the row-level rungs already shipped.
+
+## Alternatives considered
+
+**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
+
+**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single.
+
+**Cache-plugin-local recovery (catch damage errors in `SessionProjectionCache[Service.init]`, delete the file, reopen)** — rejected: the plugin cannot name the medium path without reaching around the backend abstraction, and every future derived domain would re-implement the same catch; the facility is the one place that already classifies open failures.
+
+**Fall back to an ephemeral in-memory domain on damage** — rejected: it silently degrades to memory-only for the life of the process and the damaged file never heals; the next boot fails the same way.
+
+**Rename the damaged medium aside (`.json.corrupt-`) instead of deleting** — not chosen: a derived medium's damaged bytes have no recovery value (the logs are the source of truth) and the litter accumulates unbounded; delete is the honest operation. Rename-aside remains the right choice if a future *authoritative* domain ever wants reset semantics — which is exactly why `recovery` is per-spec.
+
+**A blanket auto-reset for every domain (no spec field)** — rejected outright: `workspace.json` is authoritative user data; silently resetting it on a version bump would destroy workspaces. Authority is a property of the domain and must be declared by its owner.
+
+## Acceptance criteria
+
+- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction.
+- With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed.
+- The same damage to `workspace.json` still fails boot loudly.
+- Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends.
+
+## Risks
+
+- **Auto-delete on a misclassified error destroys a healthy file.** Mitigated by the closed damage-class list: reset fires only on the three deterministic parse-time codes; ENOENT is already "empty unit", and every I/O error (EACCES, EIO) propagates loudly. The single-shot retry bounds the blast radius to one delete per open.
+- **Root relocation changes where existing checkouts look.** Accepted under the pre-release stance (backends reject old formats, no external consumers); the note above records the one-time manual move for anyone who cares about a per-cwd `workspace.json`'s content.
+- **`destroy` is a new destructive primitive on the storage seam.** Its only caller is the facility's declared-reset path; the backend contract documents it as facility-owned, and nothing model-facing or user-facing can reach it.
diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
new file mode 100644
index 0000000000..b7bd18ffdb
--- /dev/null
+++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
@@ -0,0 +1,57 @@
+# Agent Note:存储根目录落点与派生介质恢复
+
+Status: proposed
+
+[English](2026-07-28-storage-root-and-derived-medium-recovery.md) | 中文
+
+## Problem
+
+持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。
+
+**文件到底存在哪。** 出厂组合给 json 后端的是相对根目录——`root: './.storages'`(apps/cli/cordis.yml)——而 `AppCLIEntry.composePatches` 只把会话存储的根 patch 到全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`,可经 profile 键 `persistenceRoot` 覆盖);`storage-json` 没有对应的 patch 也没有 profile 键。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
+
+**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
+
+## Proposal
+
+两个独立改动,一个缺口一个。
+
+### 全局唯一存储根,构造时 resolve 一次
+
+- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →(`storage-json`,`root`),与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同。
+- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
+- 适用 pre-release 立场:不做迁移垫片。曾在 `/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。
+
+### 声明派生介质:损坏时重置而非拒绝
+
+- `DomainSpec` 增加 `recovery?: 'reject' | 'reset'`(默认 `'reject'`)。spec 对象已经是一个域的身份与布局的单一来源;其介质是权威还是派生属于同类事实,落在同一处。`session_projcache` 声明 `'reset'`;`workspace` 保持默认。
+- `KvFacet` 增加一个原语:`destroy(descriptor): Promise`——整体移除该 unit 的介质(json:删文件;sqlite:drop 该 unit 的表)。与 `open` 一样,它是后端存储原语,不是策略。
+- `DomainFacility.open` 在 spec 声明 `'reset'` 且 open 恰以损坏类错误失败时——`StorageError('version-mismatch' | 'malformed-medium')` 或 `DomainError('invalid-record')`——记一条命名该域和被丢弃介质的警告,调用 `destroy`,再空开一次。其余一切失败(`backend-not-found`、`facet-unsupported`、`already-open`、I/O 错误)无论声明与否都保持大声:配置错误和环境故障不是介质损坏。重试单发——第二次失败原样传播,持续失败的介质不会成环。
+- 有了这个,缓存域 spec 的 version 字段才获得其本意:bump `version`(或让 zod 拒绝漂移行)真正丢弃整个介质,缓存经正常写点和冷读重建——恢复阶梯的最外一档,与已落地的行级各档对齐。
+
+## Alternatives considered
+
+**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
+
+**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。
+
+**缓存插件本地恢复(在 `SessionProjectionCache[Service.init]` 捕获损坏错误、删文件、重开)**——拒绝:插件不越过后端抽象就叫不出介质路径,且未来每个派生域都要重抄同一段 catch;facility 是唯一已经在分类 open 失败的地方。
+
+**损坏时退到内存态临时域**——拒绝:进程余生静默降级为仅内存,损坏文件永不自愈;下次启动照样失败。
+
+**把损坏介质改名旁置(`.json.corrupt-`)而非删除**——未选:派生介质的损坏字节没有恢复价值(日志才是真源),残骸无界累积;删除才是诚实的操作。若未来某个*权威*域想要重置语义,旁置改名才是对的——这正是 `recovery` 按 spec 声明的理由。
+
+**所有域一律自动重置(不加 spec 字段)**——断然拒绝:`workspace.json` 是权威用户数据;版本 bump 时静默重置会毁掉工作区。权威性是域的属性,必须由其所有者声明。
+
+## Acceptance criteria
+
+- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`);profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。
+- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。
+- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。
+- facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。
+
+## Risks
+
+- **错误分类失误导致自动删除健康文件。** 由封闭的损坏类清单缓解:重置只在三个确定性解析期代码上触发;ENOENT 本来就是「空 unit」,一切 I/O 错误(EACCES、EIO)大声传播。单发重试把爆炸半径限定为每次 open 至多一删。
+- **根迁移改变既有 checkout 的查找位置。** 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费者);上文为在乎 per-cwd `workspace.json` 内容的人记录了一次性手动搬移。
+- **`destroy` 是存储 seam 上新增的破坏性原语。** 唯一调用方是 facility 的声明重置路径;后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。
diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml
index b14e969a84..62fc537c2f 100644
--- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml
+++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml
@@ -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/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
-2026-07-04-prune-dead-core-spine-surface.md: a6c608617415f3af07de5c95fd20b0bde40bdef3
-2026-07-04-prune-dead-core-spine-surface.zh.md: 83603d8a8432b99d8f42222442b38005e196ac4e
+2026-07-04-prune-dead-core-spine-surface.md: 473f655ab0944b43f9b1193413801eb7a80286d8
+2026-07-04-prune-dead-core-spine-surface.zh.md: 00c6f7acca403c937ff765b6d73d3d0fa0c3153a
diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
index a6c6086174..473f655ab0 100644
--- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
+++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md
@@ -19,7 +19,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime
 | `code-runtime-worker` protocol/bootstrap re-exports | Outside-package production/e2e consumers use `WorkerCodeRuntime` and config, not `BootstrapPort`, `PatchableStream`, or worker message/boot types. | Keep the runtime class/config contract and make its wire/bootstrap vocabulary source-private. |
 | ACP `agentOptions` root export | The helper has only same-file and ACP-test consumers; the sole outside-package production consumer mounts the plugin namespace. | Keep `name`, `inject`, `Config`, `AcpConfig`, and `apply`; make `agentOptions` source-private and test it through bridge behavior. |
 | `providerWording` and `completedTurnPrefix` root exports | Each has one same-package production caller; only the balanced-prefix helper has a same-package white-box test. | Make them source-private and test provider behavior. |
-| `depthOf`, `SubagentDepthError`, `SENSITIVE_ENV_PATTERN`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. | Keep depth/environment/exit behavior but make the helpers and error/regex source-private; test through spawn and disposal. |
+| `depthOf`, `SubagentDepthError`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. `SENSITIVE_ENV_PATTERN` is excluded because the SDK helper applies it to caller-supplied environments. | Keep depth and exit behavior but make the remaining helpers and error source-private; test through spawn and disposal. Keep the shared credential pattern public. |
 | `PersistenceCoordinator.inits`, backend `inits` accessors, `seedCoversPrefix`, and `assertSerializable` | The accessors exist for white-box tests; `seedCoversPrefix` has no outside production importer; `assertSerializable` has no production caller and duplicates the coordinator append boundary's lossless snapshot. | Observe initialization through `session/flush`, make `seedCoversPrefix` source-private, and delete `assertSerializable`. Keep both backends, `SessionHeader`, and SQLite's version contract. |
 | `LlmError.status` and replay status | Adapters/replay populate it, but production branches on stable error code/message and never reads raw status. | Remove the unread field and replay plumbing while preserving error classification. |
 | `BlockAssembler.push()` return value | Both production callers ignore the returned completed block. | Return `void`; keep the deliberately public `blocks()`/`message()` contract. |
diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md
index 83603d8a84..00c6f7acca 100644
--- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md
+++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md
@@ -19,7 +19,7 @@ Status: proposed
 | `code-runtime-worker` 的 protocol/bootstrap 再导出 | 包外的生产/e2e 消费方使用 `WorkerCodeRuntime` 和配置,而非 `BootstrapPort`、`PatchableStream` 或 worker 消息/启动类型。 | 保留运行时类/配置契约,将其协议格式/bootstrap 词汇改为源码私有。 |
 | ACP 的 `agentOptions` 根导出 | 该辅助函数只有同文件和 ACP 测试消费方;唯一的包外生产消费方挂载的是插件命名空间。 | 保留 `name`、`inject`、`Config`、`AcpConfig` 和 `apply`;将 `agentOptions` 改为源码私有,通过桥接层行为测试。 |
 | `providerWording` 与 `completedTurnPrefix` 根导出 | 各有一个同包生产调用者;只有 balanced-prefix 辅助函数有一个同包白盒测试。 | 改为源码私有,测试提供方行为。 |
-| `depthOf`、`SubagentDepthError`、`SENSITIVE_ENV_PATTERN`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。 | 保留深度/环境/退出行为,但将辅助函数和 error/regex 改为源码私有;通过 spawn 和 dispose 测试。 |
+| `depthOf`、`SubagentDepthError`、`waitForExit` 与 `exitsWithin` 根导出 | 生产 subagent 后端消费的是进程内 runner 和子进程构造/dispose(资源释放)辅助函数,而非这些强制/测试内部实现。`SENSITIVE_ENV_PATTERN` 不在其中,因为 SDK helper 会将它应用于调用方传入的环境。 | 保留深度与退出行为,但将剩余辅助函数和 error 改为源码私有;通过 spawn 和 dispose 测试。保持共享凭据正则公开。 |
 | `PersistenceCoordinator.inits`、后端 `inits` 访问器、`seedCoversPrefix` 与 `assertSerializable` | 访问器为白盒测试而存在;`seedCoversPrefix` 没有包外生产导入者;`assertSerializable` 没有生产调用者,且与 coordinator append 边界的无损快照重复。 | 通过 `session/flush` 观察初始化,将 `seedCoversPrefix` 改为源码私有,删除 `assertSerializable`。保留两个后端、`SessionHeader` 和 SQLite 的版本契约。 |
 | `LlmError.status` 与回放 status | 适配器/回放填充它,但生产分支基于稳定的错误码/消息判断,从不读取原始 status。 | 移除未读字段和回放管道,保留错误分类。 |
 | `BlockAssembler.push()` 返回值 | 两个生产调用者都忽略返回的已完成块。 | 返回 `void`;保留有意公开的 `blocks()`/`message()` 契约。 |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8817fb325d..a8e30b9394 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -59,6 +59,8 @@ jobs:
           persist-credentials: false
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       - uses: actions/setup-node@v6
         with:
@@ -126,6 +128,8 @@ jobs:
           persist-credentials: false
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       - uses: actions/setup-node@v6
         with:
@@ -204,6 +208,8 @@ jobs:
             ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full-
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       - uses: actions/setup-node@v6
         with:
@@ -267,6 +273,8 @@ jobs:
       - uses: actions/checkout@v6
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       - uses: actions/setup-node@v6
         with:
@@ -318,6 +326,8 @@ jobs:
           persist-credentials: false
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       - uses: actions/setup-node@v6
         with:
@@ -418,6 +428,8 @@ jobs:
           fetch-depth: 2
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       - uses: actions/setup-node@v6
         with:
@@ -495,6 +507,8 @@ jobs:
           fetch-depth: 0
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       - uses: actions/setup-node@v6
         with:
@@ -528,6 +542,8 @@ jobs:
       - uses: actions/checkout@v6
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       - uses: actions/setup-node@v6
         with:
@@ -559,6 +575,8 @@ jobs:
           /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       - uses: actions/setup-node@v6
         with:
@@ -651,6 +669,8 @@ jobs:
       - uses: actions/checkout@v6
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       # The Windows lanes deliberately skip the store cache like the required
       # windows job; an empty cache input disables setup-node's caching.
@@ -740,6 +760,8 @@ jobs:
       - uses: actions/checkout@v6
 
       - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
 
       # Unlike the larger-runner suite, both platforms cache the store here:
       # the consolidated topology measures cache mechanics as workload.
diff --git a/.gitignore b/.gitignore
index 2ac3f1d7a9..4444b7b675 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,4 @@ apps/web/dist/
 .worktrees/
 worktrees/
 .agents/worktrees/
+.typert-*/
diff --git a/AGENTS.md b/AGENTS.md
index c987801c17..d496ec471a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,6 +12,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every
 vendor/      Vendored Cordis source — manifest + sync procedure in vendor/README.md
 packages/    @deepseek-ai/dsh- workspaces at packages///
   core/        product API spine: session, system-prompt, tools, agent, agent-loop
+  typert/      type graph generator, loader, and runtime registry
   llm/         LLM seam + DeepSeek adapters (direct-fetch + pi-ai design twin)
   bash/        bash executor seam + local impl + model-facing bash tools
   subprocess/  subprocess seam + local process-tree impl
@@ -89,7 +90,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 ## Conventions
 
 - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
-- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)).
+- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
 - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
 - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)).
 - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.
@@ -113,7 +114,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers.
 - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
 - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up.
-- **Use incremental merge commits.** Split independent changes; never squash, rebase, or rewrite pushed history. Fix the introducing PR before merging down-stack. If the base advances mid-merge, never restart: finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)).
+- **Use incremental merge commits.** Split independent changes. Pushed history may be rewritten before review; afterward prefer new commits. Fix the introducing PR before merging down-stack. If the base advances mid-merge, finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)).
 - **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible.
 - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
 - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it.
diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml
index abe51abc2f..bb5f370009 100644
--- a/apps/cli/README.i18n.yaml
+++ b/apps/cli/README.i18n.yaml
@@ -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
-README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9
-README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de
+#   pnpm run verify-translation-pairing --write apps/cli/README.md
+README.md: 93c36d18abd06bbd7a80c918f520b92489180395
+README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd
diff --git a/apps/cli/README.md b/apps/cli/README.md
index 42d2a9641c..93c36d18ab 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
 
-Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot.
+Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
 
 The TUI surface:
 
@@ -16,6 +16,8 @@ The TUI surface:
 
 The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
 
+The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
+
 `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
 
 ## Install (developer machine)
@@ -26,4 +28,6 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr
 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
 ```
 
+Source launches run `apps/cli/src/bin.ts` through tsx's ESM-only hook (`node --import tsx/esm`), which transforms TypeScript and projects the root tsconfig `paths` map into module resolution. Node's native TypeScript modes are not used: Node 26 removed `--experimental-transform-types`, and strip-only mode rejects syntax the source graph relies on (vendored parameter properties, decorators, runtime enums/namespaces). The CJS hook stays off because the source graph is ESM-only and the CJS resolver adds ~0.4s of startup. `bin/dsh` pins `TSX_TSCONFIG_PATH` to the checkout's root tsconfig so resolution is cwd-independent, and the `dsh-source-launch-smoke` node-compat gate runs this exact launch vector on every supported Node line. tsx applies the `paths` map without checking dependency declarations, so declaration completeness rests on the static gates: the TUI configs resolve bare plugins through `examples/package.json`, the Web/headless `cordis.yml` through this package's `dependencies`, and `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
+
 `pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
index 0a62f8bb72..85f4624a59 100644
--- a/apps/cli/README.zh.md
+++ b/apps/cli/README.zh.md
@@ -4,7 +4,7 @@
 
 `dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。
 
-Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。
+Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
 
 TUI 界面:
 
@@ -16,6 +16,8 @@ TUI 界面:
 
 Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
 
+已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
+
 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
 
 ## 安装(开发机)
@@ -26,4 +28,6 @@ Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将
 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
 ```
 
+源码启动会通过 tsx 的 ESM-only hook(`node --import tsx/esm`)运行 `apps/cli/src/bin.ts`,由它转换 TypeScript 并将根 tsconfig 的 `paths` 映射投射到模块解析中。不使用 Node 原生 TypeScript 模式:Node 26 移除了 `--experimental-transform-types`,而 strip-only 模式无法接受源码图依赖的语法(vendor 中的参数属性、装饰器、运行时 enum/namespace)。CJS hook 保持关闭,因为源码图是纯 ESM,而 CJS 解析器会增加约 0.4s 启动耗时。`bin/dsh` 将 `TSX_TSCONFIG_PATH` 固定到 checkout 的根 tsconfig,使解析与 cwd 无关;node-compat 门禁 `dsh-source-launch-smoke` 会在每条受支持的 Node 版本线上运行这一精确启动向量。tsx 应用 `paths` 映射时不检查依赖声明,声明完整性由静态门禁保障:TUI 配置通过 `examples/package.json` 解析裸插件,Web/无头 `cordis.yml` 通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
+
 `pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。
diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml
index 0dbd6c839e..ed44f75c7f 100644
--- a/apps/cli/cordis.yml
+++ b/apps/cli/cordis.yml
@@ -86,6 +86,19 @@
     apiKey: !!js process.env.DEEPSEEK_API_KEY
     baseURL: !!js process.env.DEEPSEEK_BASE_URL
 
+# Common pi-ai provider routes read credentials and endpoint overrides from the
+# boot's layered environment.
+- id: llm-pi-ai
+  name: '@deepseek-ai/dsh-llm-pi-ai'
+  config:
+    providers:
+      - provider: openai
+        apiKey: !!js process.env.OPENAI_API_KEY
+        baseURL: !!js process.env.OPENAI_BASE_URL
+      - provider: anthropic
+        apiKey: !!js process.env.ANTHROPIC_API_KEY
+        baseURL: !!js process.env.ANTHROPIC_BASE_URL
+
 # Transient-failure recovery around the loop's model calls (same policy as
 # the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff).
 - id: llm-retry
@@ -116,12 +129,59 @@
 - id: workspace
   name: '@deepseek-ai/dsh-workspace'
 
+# Persisted projection cache: durable per-session checkpoints of every
+# registered projection unit (json backend → ./.storages/session_projcache.json,
+# beside workspace.json), throttled between the two mandatory points
+# (turn/end + detach), serving cold listings without full-log loads.
+- id: session-projection-cache
+  name: '@deepseek-ai/dsh-session-projection-cache'
+  config:
+    writeEveryEvents: 200
+    writeIntervalMs: 5000
+
 # Managed child-process groups for the bash executor (spawn/kill/output plumbing).
 - id: subprocess
   name: '@deepseek-ai/dsh-subprocess-local'
 
-- id: bash-local
-  name: '@deepseek-ai/dsh-bash-local'
+# The sandboxed product path (the acp-agent composition): per-platform
+# runner provider, the shared policy home, the confined bash executor, and
+# the approval seam its escalation asks through. The web deployment default
+# is danger-full-access + never (same behavior as the former bash-local
+# rows); DSH_PERMISSION_MODE opts a process into a confined default, and
+# per-session switches ride the /permission command's knob events.
+- id: sandbox
+  name: '@deepseek-ai/dsh-sandbox-local'
+
+- id: sandbox-policy
+  name: '@deepseek-ai/dsh-sandbox-policy'
+  config:
+    mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
+    workspaceRoot: !!js process.cwd()
+
+- id: bash-sandbox
+  name: '@deepseek-ai/dsh-bash-sandbox'
+
+- id: approval
+  name: '@deepseek-ai/dsh-user-approval'
+  config:
+    policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'"
+
+# Presets over the two knobs (requires the confining executor + approval):
+# the web permission chip's table, served through the permissions projection
+# and switched through /permission.
+- id: permission
+  name: '@deepseek-ai/dsh-permission'
+  config:
+    presets:
+      read-only:
+        sandbox: read-only
+        approval: ask
+      workspace-write:
+        sandbox: workspace-write
+        approval: ask
+      danger-full-access:
+        sandbox: danger-full-access
+        approval: never
 
 - id: tool-bash
   name: '@deepseek-ai/dsh-tool-bash'
@@ -133,9 +193,11 @@
   name: '@deepseek-ai/dsh-tool-tasks'
 
 # fs cwd stays the package default (process.cwd()) — the same value the
-# gateway injects into session.cwd, so paths and sessions agree.
-- id: fs-local
-  name: '@deepseek-ai/dsh-fs-local'
+# gateway injects into session.cwd, so paths and sessions agree. The
+# sandboxed backend rides the SAME policy as bash: write/edit fence by the
+# effective mode, so read/write/edit stay available under every mode.
+- id: fs-sandbox
+  name: '@deepseek-ai/dsh-fs-sandbox'
 
 - id: fs-policy
   name: '@deepseek-ai/dsh-fs-policy'
@@ -165,6 +227,18 @@
 - id: commands
   name: '@deepseek-ai/dsh-commands'
 
+# Goal service + automatic same-session continuation + the /goal command.
+# The GoalService registers the 'goal' session projection unit; the web
+# GoalBar reads it through useProjection.
+- id: goal
+  name: '@deepseek-ai/dsh-goal'
+
+- id: goal-session
+  name: '@deepseek-ai/dsh-goal-session'
+
+- id: command-goal
+  name: '@deepseek-ai/dsh-command-goal'
+
 # Plan mode registers /plan (the first real command on the web surface).
 # Section text mirrors examples/tui-agent/cordis.yml (the reference
 # deployment); plan-mode throws at load on an empty section.
@@ -240,6 +314,13 @@
 # The API gateway: the transport-agnostic dispatch face every client shape
 # shares. provider/model are the host default routing — the profile json's
 # mapping target (user config overrides these engineering defaults).
+# Directory-picking package, dual-face: the node half serves the gateway's
+# host.* picker RPCs, the browser half fills ui-workspace's directory-flow
+# slots — one row composes the whole interaction. Swap point: mount
+# '-native' instead for the host-display OS chooser.
+- id: directory-picker
+  name: '@deepseek-ai/dsh-host-directory-picker-browse'
+
 - id: api-gateway
   name: '@deepseek-ai/dsh-host-apiproxy'
   config:
@@ -316,10 +397,22 @@
 - id: ui-subagent
   name: '@deepseek-ai/dsh-client-ui-subagent'
 
+# Goal surface: GoalBar in the input dock over the goal session projection.
+- id: ui-goal
+  name: '@deepseek-ai/dsh-client-ui-goal'
+
 # Model selection: the /model popupSelect + composer seat over session.models.
 - id: ui-model
   name: '@deepseek-ai/dsh-client-ui-model'
 
+# The /permission popup picker (hostBacked over the host /permission command).
+- id: ui-permission
+  name: '@deepseek-ai/dsh-client-ui-permission'
+
+# Plan control: the composer plan seat over the plan projection + /plan channel.
+- id: ui-plan
+  name: '@deepseek-ai/dsh-client-ui-plan'
+
 - id: ui-question
   name: '@deepseek-ai/dsh-client-ui-question'
 
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 7d984ee1b1..0914aaf5aa 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -20,7 +20,7 @@
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
-    "@deepseek-ai/dsh-bash-local": "workspace:^",
+    "@deepseek-ai/dsh-bash-sandbox": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-hmr": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
@@ -28,9 +28,12 @@
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-command": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-goal": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-model": "workspace:^",
     "@deepseek-ai/dsh-client-ui-models": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-permission": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-plan": "workspace:^",
     "@deepseek-ai/dsh-client-ui-question": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
@@ -42,21 +45,31 @@
     "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
+    "@deepseek-ai/dsh-command-goal": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-compact-basic": "workspace:^",
     "@deepseek-ai/dsh-frontend": "workspace:^",
-    "@deepseek-ai/dsh-fs-local": "workspace:^",
     "@deepseek-ai/dsh-fs-policy": "workspace:^",
+    "@deepseek-ai/dsh-fs-sandbox": "workspace:^",
+    "@deepseek-ai/dsh-goal": "workspace:^",
+    "@deepseek-ai/dsh-goal-session": "workspace:^",
     "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+    "@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-llm": "workspace:^",
     "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
+    "@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-paths": "workspace:^",
+    "@deepseek-ai/dsh-permission": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
+    "@deepseek-ai/dsh-sandbox-local": "workspace:^",
+    "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",
@@ -84,6 +97,7 @@
     "@deepseek-ai/dsh-tool-workflow": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-tui": "workspace:^",
+    "@deepseek-ai/dsh-user-approval": "workspace:^",
     "@deepseek-ai/dsh-user-interaction": "workspace:^",
     "@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^",
diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts
index 344c66e2b3..668d8f7f02 100644
--- a/apps/cli/src/app-cli-entry.ts
+++ b/apps/cli/src/app-cli-entry.ts
@@ -9,6 +9,7 @@
 
 import { readFileSync } from 'node:fs'
 import { createRequire } from 'node:module'
+import { networkInterfaces } from 'node:os'
 import { join, resolve } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { Context } from 'cordis'
@@ -25,6 +26,41 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
 const PROFILE_DIR = '.dsh-tmp-profile'
 const PROFILE_FILE = 'config.json'
 
+/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
+const ALL_INTERFACES_HOST = '0.0.0.0'
+
+/**
+ * Non-internal IPv4 interface addresses of this machine — the IP-literal
+ * authorities an all-interfaces bind is reachable by on the LAN.
+ * @returns the addresses in interface order (possibly empty).
+ */
+function lanIPv4Addresses(): string[] {
+  return Object.values(networkInterfaces()).flat()
+    .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
+    .map(iface => iface.address)
+}
+
+/**
+ * One LAN-trust resolution for one invocation, sampled exactly once: the
+ * machine's LAN IP literals when the effective bind is all-interfaces, and
+ * the `trustedHosts` value built from them plus the explicit extras. The
+ * single sample is deliberate — display must advertise only addresses the
+ * fence was configured with, so both read this snapshot. Derived entries are
+ * port-less IP literals: DNS rebinding needs an attacker-controlled name, so
+ * an IP-literal Host is safe on any port, and the bound port may be
+ * OS-assigned, unknowable pre-boot.
+ * @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
+ * @param extra - `--trusted-host` values, in argv order.
+ * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
+ */
+export function resolveLanTrust(
+  bindHost: string | undefined,
+  extra: readonly string[],
+): { lanAddresses: string[]; trustedHosts: string[] } {
+  const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
+  return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
+}
+
 /** One profile-json key mapped onto a yml row's config field. */
 interface ProfileMapping {
   jsonPath: string
@@ -79,6 +115,8 @@ export interface AppCLIEntryOptions {
   port?: number
   /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
   workspaceRoot?: string
+  /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
+  trustedHosts?: string[]
 }
 
 /**
@@ -91,6 +129,14 @@ export class AppCLIEntry {
   /** The root context, set by {@link run}. */
   ctx!: Context
 
+  /**
+   * LAN IPv4 addresses sampled once at patch composition — the exact snapshot
+   * the /api trust fence was configured with. Display reads this instead of
+   * re-sampling, so the advertised LAN URL can never name an address the
+   * fence rejects. Empty unless the effective bind is all-interfaces.
+   */
+  lanAddresses: readonly string[] = []
+
   private patches: PatchOptions[] = []
 
   constructor(private readonly options: AppCLIEntryOptions) {}
@@ -152,6 +198,13 @@ export class AppCLIEntry {
     if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
     if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
 
+    // Source 2b: authorities for the /api browser-trust fence (rationale on
+    // resolveLanTrust).
+    const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
+    const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
+    this.lanAddresses = lanAddresses
+    if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
+
     // Source 3: the frontend dist — an assembly fact of this app, never yml
     // user config. Workspace knowledge stays here.
     put('webserver', 'distIndex', this.resolveDistIndex())
diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts
index 9fd0f4d9bf..b929dc73f2 100644
--- a/apps/cli/src/args.ts
+++ b/apps/cli/src/args.ts
@@ -40,6 +40,8 @@ interface WebInvocation {
   port?: number
   dev: boolean
   workspaceRoot?: string
+  /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
+  trustedHosts?: string[]
 }
 
 /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
@@ -51,6 +53,7 @@ interface WebOptions {
   port?: string
   dev?: boolean
   workspaceRoot?: string
+  trustedHost?: string[]
 }
 
 /**
@@ -66,6 +69,7 @@ function resolveWeb(options: WebOptions): WebInvocation {
     ...options.port !== undefined && { port: Number(options.port) },
     dev: options.dev === true,
     ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
+    ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
   }
 }
 
@@ -117,6 +121,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
     .option('--port ', 'override the config listen port (0 requests an OS-assigned port)')
     .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
     .option('--workspace-root ', 'parent directory for name-created workspaces')
+    .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
     .action((options: WebOptions) => {
       // Commander parses the parent (default-surface) options on either side of
       // the subcommand into `program.opts()`. `web` shares none of them, so a
diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts
index f9e1eefc9b..88dbece55a 100644
--- a/apps/cli/src/bin.ts
+++ b/apps/cli/src/bin.ts
@@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion())
 switch (invocation.mode) {
   case 'web': {
     const { runWeb } = await import('./web.ts')
-    await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot)
+    await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts)
     break
   }
   case 'headless': {
diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts
index a87812ce00..0d402c1c51 100644
--- a/apps/cli/src/tui.ts
+++ b/apps/cli/src/tui.ts
@@ -24,7 +24,10 @@ import {
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-paths'
 import type { Context } from 'cordis'
-import type { TuiResumeHost } from '@deepseek-ai/dsh-tui'
+import {
+  TUI_GOODBYE_MESSAGE_KEY,
+  type TuiResumeHost,
+} from '@deepseek-ai/dsh-tui'
 
 const NAME = 'dsh'
 
@@ -70,8 +73,10 @@ export async function runTui(config: string | undefined, resumeSessionId: string
   const entry = process.argv[1]
   const execve = process.execve?.bind(process)
   const app: { current?: Context } = {}
+  const resumeCommand = (sessionId: string): string =>
+    `${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}`
   const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : {
-    async handoff(sessionId): Promise {
+    async handoff(sessionId, cwd): Promise {
       const current = app.current
       if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
       // Rebuild argv from the parsed config plus the selected id: TUI mode's
@@ -83,6 +88,11 @@ export async function runTui(config: string | undefined, resumeSessionId: string
         `--resume=${sessionId}`,
         ...config !== undefined ? ['--config', config] : [],
       ]
+      try {
+        process.chdir(cwd)
+      } catch (error) {
+        throw new Error(`${NAME}: cannot resume in "${cwd}": ${String(error)}`)
+      }
       try {
         await current.fiber.dispose()
         execve(process.execPath, nextArgv, process.env)
@@ -101,6 +111,9 @@ export async function runTui(config: string | undefined, resumeSessionId: string
       // Inject the resume id (or undefined) so the shipped config's `!!js`
       // reads it as a bare identifier; then offer the in-place handoff host.
       hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
+      if (resumeSessionId !== undefined) {
+        hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`)
+      }
       if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
     },
   )
diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts
index 31282c8f5f..69e79ab5d9 100644
--- a/apps/cli/src/web.ts
+++ b/apps/cli/src/web.ts
@@ -6,17 +6,14 @@
  * gates them at boot.
  */
 
-import { networkInterfaces } from 'node:os'
 import { fileURLToPath } from 'node:url'
 import { AppCLIEntry } from './app-cli-entry.ts'
 
 const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
 
-// Display-only mirrors of the webserver schema's allowed hosts: the loopback
-// address the local URL always prints, and the all-interfaces value that gates
-// LAN-address discovery. Not a source of truth — the schema is.
+// Display-only mirror of the webserver schema's loopback host: the address the
+// local URL always prints. Not a source of truth — the schema is.
 const LOOPBACK_HOST = '127.0.0.1'
-const ALL_INTERFACES_HOST = '0.0.0.0'
 
 /**
  * Serve the browser UI from the shipped config tree. `host`/`port` are passed
@@ -25,12 +22,14 @@ const ALL_INTERFACES_HOST = '0.0.0.0'
  * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
  * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
  * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
+ * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
  */
 export async function runWeb(
   host: string | undefined,
   port: number | undefined,
   dev: boolean,
   workspaceRoot: string | undefined,
+  trustedHosts: string[] | undefined,
 ): Promise {
   const entry = new AppCLIEntry({
     configPath: CONFIG_PATH,
@@ -38,6 +37,7 @@ export async function runWeb(
     ...host !== undefined && { host },
     ...port !== undefined && { port },
     ...workspaceRoot !== undefined && { workspaceRoot },
+    ...trustedHosts !== undefined && { trustedHosts },
   })
   const { ctx, port: boundPort } = await entry.run()
 
@@ -48,12 +48,11 @@ export async function runWeb(
     void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
   }
 
-  const lanCandidate = host === ALL_INTERFACES_HOST
-    ? Object.values(networkInterfaces()).flat()
-      .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
-    : undefined
+  // The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
+  // must name an address the /api trust fence was configured with.
+  const lanCandidate = entry.lanAddresses[0]
   const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
-  console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
+  console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
 
   process.on('SIGTERM', () => { shutdown(0) })
   process.on('SIGINT', () => { shutdown(130) })
diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts
index 052e96e9a0..45830eee30 100644
--- a/apps/cli/tests/args.spec.ts
+++ b/apps/cli/tests/args.spec.ts
@@ -35,6 +35,9 @@ describe('parseDshArgs', () => {
     // at boot); the adapter only coerces the port string to a number.
     expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
       .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
+    // --trusted-host is variadic and repeatable; authorities pass through unvalidated.
+    expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
+      .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
   })
 
   it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts
new file mode 100644
index 0000000000..bc6959261a
--- /dev/null
+++ b/apps/cli/tests/source-launch.compat.spec.ts
@@ -0,0 +1,36 @@
+import { fileURLToPath } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts`
+ * with the exact production launch vector (`node --import tsx/esm`, the same
+ * shape as `bin/dsh` and the root `dsh`/`demo:tui`/`demo:web` scripts) and
+ * assert the piped-stdio TTY refusal. The Node compatibility matrix runs this
+ * WHOLE file, so a Node release changing module hooks or TypeScript handling
+ * breaks this gate instead of every developer's `pnpm dsh`; the built-bin
+ * suite covers the published `lib/` entry, not this source chain.
+ */
+
+const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
+const dshSourceBin = 'apps/cli/src/bin.ts'
+
+describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
+  it('boots the source entry and refuses pipes LOUD (non-zero exit + stderr)', async () => {
+    const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], {
+      cwd: repoRoot,
+      input: '',
+      timeout: 25_000,
+      killSignal: 'SIGKILL',
+      reject: false,
+    })
+    if (result.timedOut) {
+      throw new Error(`dsh source launch did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+    }
+    expect(result.exitCode).not.toBe(0)
+    expect(result.stderr).toContain('requires stdin and stdout to be interactive TTYs')
+    expect(result.stderr).toContain('dsh -p')
+    // The refusal happens before any plugin mounts: stdout stays silent.
+    expect(result.stdout).toBe('')
+  }, 30_000)
+})
diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/apps/cli/tests/trusted-hosts.spec.ts
new file mode 100644
index 0000000000..571a9f76b7
--- /dev/null
+++ b/apps/cli/tests/trusted-hosts.spec.ts
@@ -0,0 +1,33 @@
+/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
+
+import { describe, expect, it, vi } from 'vitest'
+import { resolveLanTrust } from '../src/app-cli-entry.ts'
+
+vi.mock('node:os', () => ({
+  networkInterfaces: () => ({
+    lo0: [
+      { family: 'IPv4', internal: true, address: '127.0.0.1' },
+    ],
+    en0: [
+      { family: 'IPv6', internal: false, address: 'fe80::1' },
+      { family: 'IPv4', internal: false, address: '192.168.1.5' },
+    ],
+    en1: [
+      { family: 'IPv4', internal: false, address: '10.0.0.7' },
+    ],
+    utun0: undefined,
+  }),
+}))
+
+describe('resolveLanTrust', () => {
+  it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
+    const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
+    expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
+    expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
+  })
+
+  it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => {
+    expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
+    expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
+  })
+})
diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json
index 05947889b2..ce43c0f31c 100644
--- a/apps/cli/tsconfig.json
+++ b/apps/cli/tsconfig.json
@@ -50,6 +50,9 @@
     {
       "path": "../../packages/client/ui-models"
     },
+    {
+      "path": "../../packages/client/ui-permission"
+    },
     {
       "path": "../../packages/client/locale"
     },
@@ -62,6 +65,9 @@
     {
       "path": "../../packages/client/ui-conversation"
     },
+    {
+      "path": "../../packages/client/ui-plan"
+    },
     {
       "path": "../../packages/client/ui-trajectory"
     },
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
new file mode 100644
index 0000000000..69d5d5cfae
--- /dev/null
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -0,0 +1,117 @@
+// @vitest-environment jsdom
+// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
+// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
+// ModuleLoader path (fetchBundle/executeBundle) and proves the boot graph
+// assembles — staged activation across the immediately tier and the inject
+// layers, per-plugin CSS injection, and a rendered journey reaching chat
+// content from the keyless FixtureApiClient transport.
+//
+// Behavior assertions do NOT belong here: component and wiring behavior is
+// pinned by the per-package suites (SlotTestRuntime benches over src), which
+// this smoke's plugin set cannot influence — bundling, module-table
+// resolution, and boot layering are the only failure modes left to it.
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+
+const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
+  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  {
+    id: '@deepseek-ai/dsh-client-ui-workspace',
+    dir: 'ui-workspace',
+    url: '/plugins/ui-workspace.js',
+    rev: 'fx',
+    inject: [
+      '@deepseek-ai/dsh-client-runtime',
+      '@deepseek-ai/dsh-client-ui-conversation',
+      '@deepseek-ai/dsh-client-ui-sidebar',
+    ],
+  },
+  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
+]
+
+const bundles = new Map(PLUGINS.map(plugin => [
+  plugin.url,
+  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
+]))
+
+interface FixtureWindow extends Window {
+  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
+  __ModuleLoader__?: unknown
+}
+
+class ResizeObserverStub {
+  observe(): void {}
+  disconnect(): void {}
+  unobserve(): void {}
+}
+
+const win = window as FixtureWindow
+let unmount: (() => void) | undefined
+
+beforeEach(() => {
+  localStorage.clear()
+  document.title = 'DeepSeek Harness'
+  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+    setTimeout(() => { callback(0) }, 0) as unknown as number)
+  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
+})
+
+afterEach(() => {
+  act(() => { unmount?.() })
+  unmount = undefined
+  cleanup()
+  delete win.__DSH_BOOT__
+  delete win.__ModuleLoader__
+  document.body.innerHTML = ''
+  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+  document.title = ''
+  history.replaceState(null, '', '/')
+  vi.unstubAllGlobals()
+})
+
+it('boots the built plugin graph and renders a fixture session end to end', async () => {
+  history.replaceState(null, '', '/?fixture')
+  const root = document.createElement('div')
+  root.id = 'root'
+  document.body.appendChild(root)
+  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
+  act(() => {
+    const entry = new AppWebEntry(root, {
+      fetchBundle: (url) => {
+        const code = bundles.get(url)
+        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
+      },
+      executeBundle: (code) => { (0, eval)(code) },
+    })
+    void entry.run()
+    unmount = () => { entry.dispose() }
+  })
+
+  // The sidebar renders from the boot graph: every inject layer activated.
+  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+  await within(tree).findByText('4 sessions')
+
+  // Opening a session reaches chat content through the fixture transport.
+  fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
+  await waitFor(() => {
+    expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
+  }, { timeout: 10_000 })
+
+  // Every bundle injected its plugin-owned style tag (the loader's CSS path).
+  const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
+    .map(style => style.getAttribute('data-plugin'))
+  for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation']) {
+    expect(styleOwners).toContain(plugin)
+  }
+})
diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts
deleted file mode 100644
index ce5a98b2eb..0000000000
--- a/apps/web/tests/code-mode-fixture.snapshot.ts
+++ /dev/null
@@ -1,259 +0,0 @@
-// @vitest-environment jsdom
-// Code Mode fixture snapshot over the BUILT client graph (the workspace-flow
-// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
-// Opens the fixture history session and pins the run_code turn's rendering:
-// the code-variant parent row titled by the model-authored description, its
-// three always-visible nested sub-rows (bash through the sample registration,
-// read through GenericToolCard, the failing read wearing the error state),
-// the expanded program body, inert bash / file-link sub-row gestures, and
-// the trajectory/waterfall tabs' sub-call cells and timing lanes.
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
-
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    dir: 'ui-workspace',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
-  },
-  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  document.title = 'DeepSeek Harness'
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  delete (globalThis as Record).__fxTiming
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
-
-/** Boot the complete built client graph against the populated fixture branch. */
-function boot(): void {
-  history.replaceState(null, '', '/?fixture')
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      fetchBundle: (url) => {
-        const code = bundles.get(url)
-        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
-      },
-      executeBundle: (code) => { (0, eval)(code) },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
-}
-
-/** Collapse decorative whitespace while preserving the text a user sees. */
-function visibleText(element: Element): string {
-  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
-}
-
-/** Open the fixture history session (the alpha log carrying the run_code turn) and scroll to its tail. */
-async function openFixtureSession(): Promise {
-  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
-  const group = within(tree).getByText('4 sessions').closest('[role="treeitem"]')
-  if (group === null) throw new Error('fixture Workspace group missing')
-  if (group.getAttribute('aria-expanded') === 'false') {
-    fireEvent.click(within(group).getByText('fixture'))
-    await waitFor(() => {
-      expect(within(tree).getByText('4 sessions').closest('[role="treeitem"]')?.getAttribute('aria-expanded')).toBe('true')
-    })
-  }
-  const session = await within(tree).findByText('Fixture 历史会话')
-  fireEvent.click(session)
-  await waitFor(() => {
-    expect(document.querySelector('[data-variant="code"]')).not.toBeNull()
-  }, { timeout: 10_000 })
-}
-
-it('renders the fixture run_code turn: code parent row, nested sub-rows, error state', async () => {
-  boot()
-  await openFixtureSession()
-
-  const codeRoot = document.querySelector('[data-variant="code"]')
-  if (codeRoot === null) throw new Error('code-variant row missing')
-  const nest = codeRoot.closest('[class*="callRow"]')?.querySelector('[data-subcalls]')
-  if (nest === undefined || nest === null) throw new Error('sub-call nest missing under the code row')
-
-  expect({
-    parentRow: visibleText(codeRoot),
-    // The three sub-rows in dispatch order: bash rides the sample plugin's
-    // keyed registration (the same one a native top-level bash row uses),
-    // both reads ride GenericToolCard.
-    bashSample: nest.querySelector('[data-sample="bash-global"]') !== null,
-    subRows: [...nest.querySelectorAll(':scope > *')].map(visibleText),
-    errorSubRow: nest.querySelector('[data-state="error"]') !== null,
-  }).toMatchInlineSnapshot(`
-    {
-      "bashSample": true,
-      "errorSubRow": true,
-      "parentRow": "CodeRead the notes files and summarize",
-      "subRows": [
-        "BashList notes",
-        "Readnotes/demo.txt",
-        "Readnotes/missing.txt",
-      ],
-    }
-  `)
-})
-
-it('expands the code row into the program body; sub-row clicks do not open details', async () => {
-  boot()
-  await openFixtureSession()
-
-  // Expand: the leading control reveals the program (shiki-tokenized: the
-  // text splits into styled spans inside one 
 tree).
-  const codeRoot = document.querySelector('[data-variant="code"]')
-  if (codeRoot === null) throw new Error('code-variant row missing')
-  const toggle = codeRoot.querySelector('button[aria-expanded]')
-  if (toggle === null) throw new Error('code row expand control missing')
-  fireEvent.click(toggle)
-  await waitFor(() => {
-    // Scope to THIS row: the markdown fixture turn also renders shiki pres.
-    const pre = codeRoot.querySelector('pre.shiki')
-    if (pre === null || !(pre.textContent ?? '').includes('const listing = await tools.bash')) {
-      throw new Error('highlighted program body missing under the code row')
-    }
-  })
-
-  // Tool rows no longer drive the details panel: bash is inert, file paths
-  // are host-open links (fixture openPath is a no-op success).
-  const nest = document.querySelector('[data-subcalls]')
-  if (nest === null) throw new Error('sub-call nest missing')
-  const bashRow = nest.querySelector('[data-sample="bash-global"]')
-  if (bashRow === null) throw new Error('bash sample sub-row missing')
-  const fileLink = nest.querySelector('button')
-  if (fileLink === null) throw new Error('file-path summary link missing on a read sub-row')
-  const frame = document.querySelector('[data-details-collapsed]')
-  if (frame === null) throw new Error('app frame missing')
-  expect(frame.getAttribute('data-details-collapsed')).toBe('true')
-  fireEvent.click(bashRow)
-  expect(frame.getAttribute('data-details-collapsed')).toBe('true')
-  fireEvent.click(fileLink)
-  expect(frame.getAttribute('data-details-collapsed')).toBe('true')
-  expect({
-    fileLink: visibleText(fileLink),
-    detailsCollapsed: frame.getAttribute('data-details-collapsed'),
-  }).toMatchInlineSnapshot(`
-    {
-      "detailsCollapsed": "true",
-      "fileLink": "notes/demo.txt",
-    }
-  `)
-})
-
-it('trajectory and waterfall surface the run_code sub-calls with real timing', async () => {
-  boot()
-  await openFixtureSession()
-
-  // Switch to the trajectory tab (same slot ring the chat view registers in).
-  fireEvent.click(await screen.findByRole('tab', { name: 'Trajectory' }))
-  await waitFor(() => {
-    expect(document.querySelector('[data-kind="subtool"]')).not.toBeNull()
-  }, { timeout: 10_000 })
-  const subCells = [...document.querySelectorAll('[data-kind="subtool"]')]
-  expect({
-    // Three Sub cells nested under the run_code Tool cell, in dispatch order,
-    // each with a real +N.Ns own-duration off the start/settle pair (the
-    // fixture spaces every event 800ms apart — never the em dash).
-    subCells: subCells.map(cell => visibleText(cell)),
-  }).toMatchInlineSnapshot(`
-    {
-      "subCells": [
-        "#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
-        "#52Subread · {"path":"notes/demo.txt"}+0.8s",
-        "#53Subread · {"path":"notes/missing.txt"}+0.8s",
-      ],
-    }
-  `)
-
-  // Waterfall: each sub-call draws a measured lane scaled into the parent
-  // turn's dispatch window.
-  fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
-  await waitFor(() => {
-    expect(document.querySelector('[data-subspan]')).not.toBeNull()
-  }, { timeout: 10_000 })
-  const lanes = [...document.querySelectorAll('[data-subspan]')]
-  expect({
-    lanes: lanes.map(lane => ({
-      label: visibleText(lane.querySelector('[class*="subTag"]') ?? lane),
-      title: lane.querySelector('[data-timing]')?.getAttribute('title'),
-      timing: lane.querySelector('[data-timing]')?.getAttribute('data-timing'),
-    })),
-  }).toMatchInlineSnapshot(`
-    {
-      "lanes": [
-        {
-          "label": "bash",
-          "timing": "measured",
-          "title": "bash · 0.80s",
-        },
-        {
-          "label": "read",
-          "timing": "measured",
-          "title": "read · 0.80s",
-        },
-        {
-          "label": "read",
-          "timing": "measured",
-          "title": "read · 0.80s",
-        },
-      ],
-    }
-  `)
-})
diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts
index a7f493e448..9826d35f7c 100644
--- a/apps/web/tests/code-mode-round.e2e.ts
+++ b/apps/web/tests/code-mode-round.e2e.ts
@@ -118,14 +118,14 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
     expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('a bash sub-row click leaves the details panel collapsed', async () => {
+  it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel open', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details'))
     const nest = page.locator('[data-subcalls]').first()
-    const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
-    expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
+    const frame = page.locator('[style*="grid-template-columns"]').first()
+    expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
     await nest.locator('[data-sample="bash-global"]').first().click()
-    // Tool rows no longer open details; the column stays width 0.
-    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
+    // Tool rows do not drive layout geometry; the Session's default panel stays open.
+    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
   })
 
   it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts
new file mode 100644
index 0000000000..c4d6483245
--- /dev/null
+++ b/apps/web/tests/details-session-lifecycle.e2e.ts
@@ -0,0 +1,95 @@
+// Keyless browser regression for the details column's Session ownership.
+// The shipped composition retains geometry through unselected states and closes it only when a different Session takes ownership.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
+
+const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url))
+const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
+const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
+const MODE = webSnapshotMode()
+
+/** Last AppFrame grid track in CSS pixels. */
+async function detailsTrack(page: Page): Promise {
+  return await appFrame(page).evaluate((element) => {
+    const tracks = getComputedStyle(element).gridTemplateColumns.split(' ')
+    return Number.parseFloat(tracks.at(-1) ?? 'NaN')
+  })
+}
+
+/** AppFrame is the only product element with an inline grid track template. */
+function appFrame(page: Page) {
+  return page.locator('[style*="grid-template-columns"]').first()
+}
+
+describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType
+
+  beforeAll(async () => {
+    const fixture = await readFile(FIXTURE, 'utf8')
+    expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
+    scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 })
+    await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed')
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await appFrame(page).waitFor({ timeout: 30_000 })
+    await connectFreshWorkspace(page)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('retains geometry through hero and closes it for a different Session', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle'))
+    const settled = scaffold.whenTurnSettled()
+    const input = page.locator('textarea').first()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    await settled
+    await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
+
+    await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360)
+    expect(await page.getByText('详情', { exact: true }).count()).toBe(1)
+
+    await page.getByRole('button', { name: 'New session', exact: true }).last().click()
+    await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
+    await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
+    expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
+
+    const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first()
+    await original.click()
+    await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
+    await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360)
+    expect(await page.getByText('详情', { exact: true }).count()).toBe(1)
+
+    const ungrouped = page.getByText('Ungrouped', { exact: true })
+    const ungroupedRow = ungrouped.locator('..').locator('..')
+    const ungroupedSection = ungroupedRow.locator('..')
+    await expect.poll(async () => {
+      if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
+        await ungrouped.click()
+        await page.waitForTimeout(50)
+      }
+      return await ungroupedRow.getAttribute('aria-expanded')
+    }, { timeout: 5_000 }).toBe('true')
+    const seeded = ungroupedSection.locator('[role="treeitem"]').nth(1)
+    await seeded.click()
+    await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
+    await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 90_000)
+})
diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts
index 4b54242495..a07db275fd 100644
--- a/apps/web/tests/lifecycle-chrome.e2e.ts
+++ b/apps/web/tests/lifecycle-chrome.e2e.ts
@@ -101,23 +101,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
 
   it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload'))
-    // Fold a layout preference into the same reload: collapse the sidebar
-    // (persisted under dsh.layout.panels) before reloading.
-    await page.getByRole('button', { name: 'Collapse sidebar' }).click()
-    await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
     const warningStart = tripwire.warnings.length
     await page.reload({ waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
-    // Layout persisted: the sidebar comes back collapsed.
-    await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1)
     // Selection persisted (dsh.sessions.current) and history replayed: the
     // recorded turn re-renders from session.history with zero model calls —
     // the replay cursor was fully consumed before the reload, so any stray
     // request would fail the scenario loudly at close().
     await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
-    // Expand back and confirm the tree still lists the materialized session.
-    await page.getByRole('button', { name: 'Open sidebar' }).click()
     await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
     // Golden of the recovered conversation region: rebuilt from the log, it
     // must render the same settled transcript the live turn produced.
diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts
new file mode 100644
index 0000000000..a453d53cbc
--- /dev/null
+++ b/apps/web/tests/message-actions.e2e.ts
@@ -0,0 +1,93 @@
+// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history
+// fixture (zero model calls) and pins the settled conversation aria after the
+// user/assistant footers are focus-revealed — the surface package jsdom tests
+// cannot substitute for (docs/testing.md snapshot rule).
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url))
+// Borrowed read-only: this scenario needs any settled user+assistant pair, not
+// a new recording (workspace-management / sidebar-scrollbar pattern).
+const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const MODE = webSnapshotMode()
+const SEED_ID = 'message-actions-web-e2e'
+
+const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
+
+describe('web e2e: message IconActions and clocks on settled history', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
+    await mkdir(sessionCwd, { recursive: true })
+    await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
+    await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
+    const raw = await readFile(SEED, 'utf8')
+    expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT])
+    await seedSession(scaffold, raw, SEED_ID)
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions'))
+    const groupRow = page.locator('[role="treeitem"]').first()
+    await groupRow.waitFor({ timeout: 15_000 })
+    await groupRow.click()
+    const sessionRow = page.locator('[role="treeitem"]').nth(1)
+    await sessionRow.waitFor({ timeout: 10_000 })
+    await sessionRow.click()
+    await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+
+    // Focus-reveal the footers (hover:hover keeps them opacity-hidden until
+    // hover/focus-within). User has three actions; each finalized assistant
+    // text node has copy + branch.
+    const copyButtons = page.getByRole('button', { name: '复制' })
+    await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
+    await copyButtons.first().focus()
+    await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 })
+      .toBeGreaterThanOrEqual(2)
+    await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1)
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
+    await page.getByRole('button', {
+      name: '选择模型,当前 deepseek-v4-flash',
+    }).waitFor({ timeout: 10_000 })
+    // Keep a footer focused so opacity-hidden actions stay in the a11y tree
+    // as an active/focused control during the capture.
+    await page.getByRole('button', { name: '复制' }).first().focus()
+    const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+  })
+
+  it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => {
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+  })
+})
diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts
index a27a61f80f..0f42d911aa 100644
--- a/apps/web/tests/navigation-panes.e2e.ts
+++ b/apps/web/tests/navigation-panes.e2e.ts
@@ -1,11 +1,11 @@
-// Web e2e scenarios: navigation & panes — the view tabs (Trajectory /
-// Waterfall) and sidebar search, all over ONE rich two-turn seeded fixture
-// rendered purely from the log (the seeded-history pattern: zero model calls
-// in replay, so every surface here is the client fold + host history RPC,
-// not replay binding). The seed is recorded live under the standard
-// discipline: turn 1 produces a bash call plus two parallel reads in one
-// assistant message (tool-call density for the trajectory/waterfall lanes),
-// turn 2 a markdown-rich reply (a second turn so the waterfall has two lanes).
+// Web e2e scenarios: navigation & panes — the Trajectory view and timing
+// overview, its local details inspector, and sidebar search, all over ONE rich
+// two-turn seeded fixture rendered purely from the log (the seeded-history
+// pattern: zero model calls in replay, so every surface here is the client
+// fold + host history RPC, not replay binding). The seed is recorded live
+// under the standard discipline: turn 1 produces a bash call plus two
+// parallel reads in one assistant message (tool-call density for the
+// trajectory ledger/timing lanes), turn 2 a markdown-rich reply.
 import { mkdir, readFile, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -23,7 +23,7 @@ import { saveFailureShot } from './support.ts'
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
 const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
-const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
+const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const MODE = webSnapshotMode()
 const SEED_ID = 'navigation-panes-web-e2e'
 
@@ -38,6 +38,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
   let browser: Browser
   let page: Page
   let tripwire: ReturnType
+  let slotErrors: string[]
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
@@ -57,6 +58,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     browser = await chromium.launch()
     page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
     tripwire = watchConsole(page)
+    slotErrors = []
+    page.on('console', (message) => {
+      if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
+        slotErrors.push(message.text())
+      }
+    })
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
   }, 120_000)
@@ -122,58 +129,157 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => {
+  it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
     await page.getByRole('tab', { name: 'Trajectory' }).click()
-    // Two sticky turn sections; turn 1's step group summarizes its tool mix
-    // (bash + the two parallel reads collapse to 'bash read×2').
-    await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
-    await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
-    await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
+    await page.waitForTimeout(100)
+    expect({
+      pageErrors: tripwire.pageErrors,
+      slotErrors,
+      warnings: tripwire.warnings,
+    }).toEqual({
+      pageErrors: [],
+      slotErrors: [],
+      warnings: [],
+    })
+    // Turn rules partition the ledger without restoring a separate header row.
+    await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
+    await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
+    await page.locator('tr[data-kind="tool"]').first().click()
+    await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1)
+    await page.getByRole('tab', { name: 'Result' }).click()
+    await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
     const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
+    await page.getByRole('complementary', { name: 'Event details' })
+      .getByRole('button', { name: 'Close details' }).click()
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall'))
-    await page.getByRole('tab', { name: 'Waterfall' }).click()
-    // The stats header rides the waterfall body. The span fold counts THREE
-    // spans for this two-turn log: only assistant/steering nodes carry a turn
-    // number, so the first user message lands in a turn-0 prologue span (a
-    // P-I placeholder shape — pinned as-is; real spans are deferred to
-    // P-III per the view's deviation ledger). Calls: bash + two reads.
-    await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1)
-    // One lane per span, tagged by turn number, prologue included.
-    for (const tag of ['turn 0', 'turn 1', 'turn 2']) {
-      await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
-    }
-    const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
-      .split(SEED_ID).join('{{seededId}}')
-    await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE)
+  it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
+    const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
+    const before = await page.locator('tr[data-kind]').count()
+    const box = await plot.boundingBox()
+    if (box === null) throw new Error('trajectory timeline plot has no layout box')
+    await page.mouse.move(box.x + box.width * 0.55, box.y + box.height / 2)
+    await page.mouse.down()
+    await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2)
+    await page.mouse.up()
+    await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 })
+      .toBeGreaterThan(0)
+    await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before)
+    await plot.click({ button: 'right' })
+    await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
   }, 60_000)
 
-  it.skipIf(MODE === 'record')('bash and file-path rows leave the details column collapsed', async () => {
+  it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column open', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
     await page.getByRole('tab', { name: 'Chat' }).click()
     const bashRow = page.locator('[data-sample="bash-global"]').first()
     await bashRow.waitFor({ timeout: 15_000 })
-    const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
-    expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
+    const frame = page.locator('[style*="grid-template-columns"]').first()
+    expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
     await bashRow.click()
-    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
+    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
+    // The card's own controls are outside the summary row and must not open
+    // details either — the terminal card is read in place.
+    await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
+    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
     // Read summaries are host-open file links; they also must not open details.
     const fileLink = page.locator('[data-variant="read"] button').first()
     await fileLink.waitFor({ timeout: 10_000 })
     await fileLink.click()
-    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
+    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
+    await page.getByRole('tab', { name: 'Chat' }).click()
+    // The card is resident in the keyed bash row (no expand gesture): the
+    // recorded command's own output sits in the message flow, derived from the
+    // logged call/result presentations alone.
+    const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first()
+    await card.waitFor({ timeout: 15_000 })
+    // Real layout, not jsdom's stub (which computes no geometry at all):
+    // squeeze the output pane below its content width and the line must keep
+    // its single row and overflow sideways instead of folding. Soft-wrapping
+    // here is what shredded the column alignment this card exists to hold.
+    const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
+      const pane = node as HTMLElement
+      const row = pane.querySelector('[class*="_line_"]')
+      if (row === null) throw new Error('output pane has no line')
+      const before = row.offsetHeight
+      const restore = pane.style.width
+      pane.style.width = '8px'
+      const squeezed = { wrapped: row.offsetHeight > before, scrollsSideways: pane.scrollWidth > pane.clientWidth }
+      pane.style.width = restore
+      return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed }
+    })
+    expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true })
+    // The run-state dot's color is the whole point of it and is the one thing
+    // jsdom cannot report: --dsw-* tokens resolve only against the real theme
+    // stylesheet. This command settled cleanly, so the dot must be the green
+    // success token — a red one here would read as a failed command.
+    const dot = await card.locator('[class*="_runState_"][data-state]').first().evaluate((node) => {
+      // The token lives on body, so the probe must sit in the same cascade.
+      const probe = document.createElement('span')
+      probe.style.color = 'var(--dsw-alias-state-success-primary)'
+      document.body.appendChild(probe)
+      const success = getComputedStyle(probe).color
+      probe.remove()
+      return {
+        state: node.getAttribute('data-state'),
+        color: getComputedStyle(node as HTMLElement).color,
+        success,
+        // One label per card (the state is the call's), so it hangs off the
+        // prompt column rather than the row the dot sits in.
+        label: node.closest('[class*="_prompt_"]')?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
+        // The dot precedes the prompt label in document order, which is what
+        // puts it to the left of the `$`.
+        beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!)
+          === Node.DOCUMENT_POSITION_FOLLOWING,
+        // The dot lives in the card's OWN left padding, so it sits inside the
+        // card box yet left of the prompt text. Owning the reservation as padding
+        // rather than margin is what keeps a consumer's own margin from
+        // cancelling it and letting a container clip the dot — geometry jsdom
+        // cannot compute.
+        insideCard: (node as HTMLElement).getBoundingClientRect().left
+          >= (node.closest('[data-terminal]')?.getBoundingClientRect().left ?? Infinity),
+        leftOfPrompt: (node as HTMLElement).getBoundingClientRect().right
+          <= (node.closest('[class*="_promptLine_"]')
+            ?.querySelector('[class*="_cwd_"]')
+            ?.getBoundingClientRect().left ?? -Infinity),
+      }
+    })
+    expect(dot.state).toBe('done')
+    expect(dot.label).toBe('已完成')
+    expect(dot.beforePrompt).toBe(true)
+    expect(dot.insideCard).toBe(true)
+    expect(dot.leftOfPrompt).toBe(true)
+    // Resolved through the theme token, not a literal hex in the component.
+    expect(dot.success).toMatch(/^rgb/)
+    expect(dot.color).toBe(dot.success)
+    // Golden of the card at rest — captured before the copy click, whose
+    // confirmation label self-reverts on a timer and would not hold still.
+    const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
+    // Copy writes the raw output through the browser's own clipboard, which in
+    // a real page is the async Clipboard API rather than the jsdom fallback.
+    await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+    await card.locator('[class*="_copyButton_"]').first().click()
+    await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 })
+      .toBe('复制成功')
+    expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
   }, 60_000)
 
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
     expect(tripwire.pageErrors).toEqual([])
+    expect(slotErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md',
+      'seed.jsonl', 'trajectory.expected.md', 'terminal-card.expected.md',
     ])
   })
 })
diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts
index 6ecdb683b3..8689a74fc2 100644
--- a/apps/web/tests/question-composer.e2e.ts
+++ b/apps/web/tests/question-composer.e2e.ts
@@ -104,7 +104,7 @@ describe('web e2e: resident question composer round trip', () => {
             const inner = child.getBoundingClientRect()
             return Math.max(box.top - inner.top, inner.bottom - box.bottom)
           })))
-          const list = rows[0]?.parentElement ?? null
+          const list = card.querySelector('[data-question-scroll]')
           return {
             rows: rows.length,
             spill: Math.max(...spill),
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
index f722ead61b..753cdc2953 100644
--- a/apps/web/tests/scaffold.ts
+++ b/apps/web/tests/scaffold.ts
@@ -389,6 +389,11 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string {
     .split(base).join('{{workspace}}')
     .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
     .replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
+    // Message IconActions clocks widen by calendar day/year; collapse every
+    // shape so goldens stay stable across midnight and year boundaries.
+    .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
+    .replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
+    .replace(/(? {
     // capture; still zero model calls.
     const fileLink = page.locator('[data-variant="read"] button').first()
     await fileLink.waitFor({ timeout: 10_000 })
-    const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
-    expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
+    const frame = page.locator('[style*="grid-template-columns"]').first()
+    expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
     await fileLink.click()
-    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
+    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
     // Path label survives from the recorded args (a.txt).
     await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
   })
diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts
deleted file mode 100644
index 67881d003c..0000000000
--- a/apps/web/tests/session-title.snapshot.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-// @vitest-environment jsdom
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
-
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
-  { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
-  { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-model', dir: 'ui-model', url: '/plugins/ui-model.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-command'] },
-  { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
-  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureTiming {
-  appendTitle(id: string, title: string): void
-}
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  history.replaceState(null, '', '/?fixture')
-  document.title = 'DeepSeek Harness'
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  delete (globalThis as Record).__fxTiming
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
-
-/** Read only the stable, user-facing title surfaces from the assembled app. */
-function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } {
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  const sidebar = within(tree).getByText(label).textContent ?? ''
-  const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' }))
-    .getByRole('button', { name: label }).textContent ?? ''
-  return { sidebar, breadcrumb, documentTitle: document.title }
-}
-
-it('projects titles and routes the next turn through the selected model in the built fixture app', async () => {
-  const root = document.querySelector('#root')
-  if (root === null) throw new Error('snapshot root missing')
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      fetchBundle: (url) => {
-        const code = bundles.get(url)
-        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
-      },
-      executeBundle: (code) => { (0, eval)(code) },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
-
-  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
-  // The fixture Intent selects the workspace, so the current-group effect
-  // already expanded it; clicking the header would now collapse (the twist
-  // stays live since intent stopped forcing expansion).
-  await within(tree).findByText('4 sessions')
-
-  const initialLabel = 'Fixture 历史会话'
-  const initialRowLabel = await screen.findByText(initialLabel)
-  const initialRow = initialRowLabel.closest('[role="treeitem"]')
-  if (initialRow === null) throw new Error('fixture session row missing')
-  fireEvent.click(initialRow)
-  await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) })
-  const initial = titleSurfaces(initialLabel)
-
-  const revisedLabel = 'Fixture 修订标题'
-  const timing = (globalThis as Record).__fxTiming as FixtureTiming
-  act(() => { timing.appendTitle('fx-alpha', revisedLabel) })
-  await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) })
-  const revised = titleSurfaces(revisedLabel)
-
-  const modelTrigger = await screen.findByRole('button', {
-    name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
-  })
-  fireEvent.click(modelTrigger)
-  fireEvent.click(screen.getByRole('menuitem', { name: /Model/ }))
-  fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
-  await waitFor(() => {
-    expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Medium')
-  })
-  fireEvent.click(modelTrigger)
-  fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
-  fireEvent.click(screen.getByRole('menuitemradio', { name: 'Max' }))
-  await waitFor(() => {
-    expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Max')
-  })
-
-  // fx-alpha starts in the running state. Selecting above is intentionally
-  // allowed for the next turn; stop the fixture's resident run before sending
-  // the route-report prompt.
-  fireEvent.click(screen.getByRole('button', { name: 'Stop generating' }))
-  const composer = await screen.findByPlaceholderText('Message the agent')
-  fireEvent.change(composer, { target: { value: 'report model' } })
-  fireEvent.keyDown(composer, { key: 'Enter' })
-  await screen.findByText('当前模型:openai/gpt-5 · 推理等级:max', {}, { timeout: 10_000 })
-
-  await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`)
-    .toMatchFileSnapshot('./snapshots/session-title.json')
-})
diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts
new file mode 100644
index 0000000000..925ff924ca
--- /dev/null
+++ b/apps/web/tests/skill-invocation-policy.e2e.ts
@@ -0,0 +1,115 @@
+// Web e2e scenario: the real host filters skill.list to the model-and-user
+// intersection before the browser slash source renders candidates. A real
+// chromium connects a fresh workspace seeded with all four policy quadrants;
+// no model call is issued, so a stray stream fails loud on the open LLM seam.
+import { mkdir, writeFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory,
+  captureStableAria,
+  compareOrRefreshGolden,
+  launchWebScaffold,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url))
+const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
+const MODE = webSnapshotMode()
+
+interface SeedSkill {
+  name: string
+  description: string
+  frontmatter: string
+}
+
+const SKILLS: readonly SeedSkill[] = [
+  {
+    name: 'policy-shared',
+    description: 'Available to both model and user invocation',
+    frontmatter: '',
+  },
+  {
+    name: 'policy-model-only',
+    description: 'Available only to model invocation',
+    frontmatter: 'user-invocable: false\n',
+  },
+  {
+    name: 'policy-user-only',
+    description: 'Available only to user invocation',
+    frontmatter: 'disable-model-invocation: true\n',
+  },
+  {
+    name: 'policy-trusted-only',
+    description: 'Available only to trusted internal callers',
+    frontmatter: 'disable-model-invocation: true\nuser-invocable: false\n',
+  },
+]
+
+async function seedSkills(workspaceCwd: string): Promise {
+  for (const skill of SKILLS) {
+    const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', skill.name)
+    await mkdir(directory, { recursive: true })
+    const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
+    await writeFile(join(directory, 'SKILL.md'), [
+      '---',
+      `name: ${skill.name}`,
+      `description: ${skill.description}`,
+      ...policyLines,
+      '---',
+      '',
+      `# ${skill.name}`,
+      '',
+    ].join('\n'))
+  }
+}
+
+describe('web e2e: skill invocation policy through the real host', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    await seedSkills(scaffold.workspaceCwd)
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('renders only the model-and-user intersection in slash candidates', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
+    const input = page.locator('textarea').first()
+    await input.fill('/policy')
+    const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+    await expect.poll(
+      () => menu.getByRole('option', { name: /policy-shared/ }).count(),
+      { timeout: 10_000 },
+    ).toBe(1)
+
+    expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
+    expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
+    expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
+
+    const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
+  })
+})
diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts
deleted file mode 100644
index ac47ca4cd0..0000000000
--- a/apps/web/tests/slash-flow.snapshot.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-// @vitest-environment jsdom
-// Assembled keyless snapshot of the slash/input/session convergence under the
-// agent-parity model: the New Session view state locks the composer until a
-// Workspace is picked (connectWorkspace materializes the full Session+Agent),
-// the '/' menu serves the session's wire command catalog (sessions are always
-// agent-backed — no draft/materialized split), a leadingInput command claims,
-// submits over the wire, and notices its result, and the SAME composer
-// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
-// flips blank and surfaces the session in lists. This is the user-visible
-// acceptance anchor — package mocks do not substitute for the assembled
-// application transcript.
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
-
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] },
-  { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
-  { id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
-  { id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
-  {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    dir: 'ui-workspace',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
-  },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  document.title = 'DeepSeek Harness'
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  delete (globalThis as Record).__fxTiming
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
-
-/** Boot the complete built client graph against one keyless fixture branch. */
-function boot(search: string): void {
-  history.replaceState(null, '', `/${search}`)
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      fetchBundle: (url) => {
-        const code = bundles.get(url)
-        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
-      },
-      executeBundle: (code) => { (0, eval)(code) },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
-}
-
-/** Collapse decorative whitespace while preserving the text a user sees. */
-function visibleText(element: Element): string {
-  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
-}
-
-/** Type into the machine-driven composer and let the change echo back. */
-async function typeComposer(composer: HTMLTextAreaElement, value: string): Promise {
-  fireEvent.change(composer, { target: { value } })
-  await waitFor(() => { expect(composer.value).toBe(value) })
-}
-
-it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
-  boot('?fixture=empty')
-
-  // View state: no session entity — the composer renders locked; only the
-  // workspace picker is live.
-  const locked = await screen.findByPlaceholderText(
-    'Choose a workspace to start', {}, { timeout: 10_000 },
-  )
-  expect(locked.disabled).toBe(true)
-
-  // Pick (create) a Workspace: connectWorkspace materializes the full
-  // Session+Agent and the provider swaps in the live blank-session hero.
-  fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
-    .find(el => el.getAttribute('aria-haspopup') === 'menu')!)
-  fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
-  const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
-  fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
-    target: { value: 'nova' },
-  })
-  fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
-
-  const composer = await screen.findByPlaceholderText(
-    'Describe what you want to build', {}, { timeout: 10_000 },
-  )
-  expect(composer.disabled).toBe(false)
-
-  // '/' opens the menu with the session's wire command catalog (the session
-  // is agent-backed from birth — the catalog is the single-address list).
-  await typeComposer(composer, '/')
-  const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
-  await waitFor(() => { expect(visibleText(menu)).toContain('echo') })
-  const menuText = visibleText(menu)
-
-  // Pick /echo (leadingInput): the claim token lands in the same textarea.
-  fireEvent.mouseDown(screen.getByRole('option', { name: /echo/ }))
-  await waitFor(() => { expect(composer.value).toBe('/echo ') })
-
-  // Type args and submit: the claim executes over the wire and notices its
-  // result; the token is consumed and the draft returns to plain text.
-  await typeComposer(composer, '/echo hello parser')
-  fireEvent.keyDown(composer, { key: 'Enter' })
-  await screen.findByText('hello parser', {}, { timeout: 10_000 })
-  await waitFor(() => { expect(composer.value).toBe('') })
-
-  // Slash execution does not flip blank: the selected row remains New Session.
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  expect(within(tree).getByText('1 session')).toBeDefined()
-  expect(within(tree).getByText('New Session')).toBeDefined()
-
-  // First plain send through the SAME textarea: acceptance logs the user
-  // message and converts the existing sidebar row out of blank.
-  const before = composer
-  await typeComposer(composer, 'build me a parser')
-  fireEvent.keyDown(composer, { key: 'Enter' })
-  await waitFor(() => {
-    expect(screen.queryByText("Let's start building")).toBeNull()
-  }, { timeout: 10_000 })
-  await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
-  const after = document.querySelector('textarea')
-
-  expect({
-    menuHadEcho: menuText.includes('echo'),
-    menuHadCompact: menuText.includes('compact'),
-    composerSurvivedConversion: after === before,
-    sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
-  }).toMatchInlineSnapshot(`
-    {
-      "composerSurvivedConversion": true,
-      "menuHadCompact": true,
-      "menuHadEcho": true,
-      "sessionListed": "nova1 session",
-    }
-  `)
-})
diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts
index 7b7721ce2c..aceb002d05 100644
--- a/apps/web/tests/smoke-real.e2e.ts
+++ b/apps/web/tests/smoke-real.e2e.ts
@@ -357,16 +357,20 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
     requireDist()
     sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
     const port = await probeFreePort()
-    // tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. cwd is a
-    // temp dir (persistenceRoot is cwd-relative), so tsx needs the repo's loader
-    // and tsconfig paths pointed at explicitly.
+    // tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate
+    // the global Harness home inside the temp world; tsx also needs the repo's
+    // loader and tsconfig paths pointed at explicitly.
     const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
     child = spawn(
       process.execPath,
       ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
       {
         cwd: sessionsDir,
-        env: { ...process.env, TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json') },
+        env: {
+          ...process.env,
+          DSH_HOME: join(sessionsDir, '.dsh'),
+          TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
+        },
         stdio: ['ignore', 'pipe', 'pipe'],
       },
     )
@@ -440,17 +444,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
     await screen(page, '04-round-complete')
   }, 150_000)
 
-  it('4 view tabs: Chat / Trajectory / Waterfall all switch', async () => {
+  it('view tabs: Chat and Trajectory switch', async () => {
     onTestFailed(() => saveFailureShot(page, 'w5-tabs'))
     await page.locator('button', { hasText: /Trajectory/i }).first().click()
     await screen(page, '05-trajectory-tab')
-    await page.locator('button', { hasText: /Waterfall/i }).first().click()
-    await screen(page, '06-waterfall-tab')
+    await page.getByLabel('Trajectory timeline').waitFor()
+    await expect.poll(() => page.getByRole('tab', { name: 'Waterfall' }).count()).toBe(0)
     await page.locator('button', { hasText: /^Chat$/i }).first().click()
     await screen(page, '07-back-to-chat')
   })
 
-  it('5 bash differential rendering: tool row click leaves the details column collapsed', async () => {
+  it('5 bash differential rendering: tool row click leaves the default details column open', async () => {
     onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
     const input = page.locator('textarea').first()
     await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
@@ -462,14 +466,14 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
     const toolRow = page.locator('[data-sample="bash-global"]')
     await toolRow.waitFor({ timeout: 120_000 })
     await screen(page, '08-bash-round')
-    expect(await detailsTrack(page)).toBe(0)
+    expect(await detailsTrack(page)).toBe(360)
     await toolRow.click()
-    // Tool rows no longer drive layout.openDetails; the column stays closed.
-    expect(await detailsTrack(page)).toBe(0)
-    await screen(page, '09-details-closed')
+    // Tool rows no longer drive layout.openDetails; the default column stays open.
+    expect(await detailsTrack(page)).toBe(360)
+    await screen(page, '09-details-open')
   }, 150_000)
 
-  it('6 sidebar drag widens the column and persists across reload', async () => {
+  it('6 sidebar drag widens the column and resets across reload', async () => {
     onTestFailed(() => saveFailureShot(page, 'w5-drag'))
     const before = await firstTrack(page)
     const handle = page.locator('[class*="handle"]').first()
@@ -484,7 +488,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
     await screen(page, '10-sidebar-dragged')
     await page.reload({ waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    expect(await firstTrack(page)).toBe(after)
+    expect(await firstTrack(page)).toBe(before)
   })
 
   it('7 dark mode: the body attribute cascades the token sheets', async () => {
diff --git a/apps/web/tests/snapshots/code-mode-round/session.jsonl b/apps/web/tests/snapshots/code-mode-round/session.jsonl
index 6e9e481129..b49160e3f9 100644
--- a/apps/web/tests/snapshots/code-mode-round/session.jsonl
+++ b/apps/web/tests/snapshots/code-mode-round/session.jsonl
@@ -1,6 +1,6 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785013630399,"cwd":"{{cwd}}/workspace"}
 {"type":"turn/start","seq":0,"time":1785013630411,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
-{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"1c4a6a0f-a7bd-4642-919d-6ed7395df98b"},"surfaceOp":"append"}
 {"type":"session/title","seq":2,"time":1785013630418,"data":{"title":"Using ONE run_code program: run","messageSeqs":[1],"source":{"kind":"fallback"}}}
 {"type":"step/start","seq":3,"time":1785013630479,"data":{"turn":1,"step":1}}
 {"type":"request/header","seq":4,"time":1785013630480,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
@@ -12,13 +12,13 @@
 {"type":"assistant/chunk","seq":202,"time":1785013633103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n  command: \\\"echo CODE_ROUND_OK\\\",\\n  description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n  await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n  readError = {\\n    toolName: e.toolName,\\n    message: e.message\\n  };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}}}
 {"type":"assistant/chunk","seq":203,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}}}
 {"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n  command: \\\"echo CODE_ROUND_OK\\\",\\n  description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n  await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n  readError = {\\n    toolName: e.toolName,\\n    message: e.message\\n  };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
+{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n  command: \\\"echo CODE_ROUND_OK\\\",\\n  description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n  await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n  readError = {\\n    toolName: e.toolName,\\n    message: e.message\\n  };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0d217af9-3bb3-425b-b53e-7ab0366f0c66"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
 {"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n  command: \\\"echo CODE_ROUND_OK\\\",\\n  description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n  await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n  readError = {\\n    toolName: e.toolName,\\n    message: e.message\\n  };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
 {"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
 {"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
 {"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
 {"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
-{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n  \"bash\": \"CODE_ROUND_OK\",\n  \"readError\": {\n    \"toolName\": \"read\",\n    \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n  }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
+{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_6VNoF1gDSerTBKoCfYSH3765"},"content":[{"type":"tool-result","toolCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n  \"bash\": \"CODE_ROUND_OK\",\n  \"readError\": {\n    \"toolName\": \"read\",\n    \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n  }\n}"}],"isError":false}],"role":"user","id":"1580d4a8-5760-415f-984f-f93927271d3f"}},"sourceEventSeqs":[206],"surfaceOp":"append"}
 {"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":214,"time":1785013633985,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -30,6 +30,6 @@
 {"type":"assistant/chunk","seq":233,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
 {"type":"assistant/chunk","seq":234,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}}}}
 {"type":"assistant/chunk","seq":235,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
+{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3037ec34-6c2a-4e01-91eb-a0ef89ce0d15"}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
 {"type":"step/end","seq":237,"time":1785013634225,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":238,"time":1785013634225,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
index ea364a44db..ccbf531e96 100644
--- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md
+++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
@@ -1,32 +1,52 @@
 - banner:
   - navigation "Session hierarchy":
     - 'button "Using ONE run_code program: run" [disabled]'
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."
+- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
+- button "▸ 上下文注入"
 - 'button "Think The user wants me to write a single `run_code` program that:"':
+  - img
   - img
   - text: "Think The user wants me to write a single `run_code` program that:"
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}}
 - button:
   - img
-- text: Code Run bash echo and catch missing file read Echo CODE_ROUND_OK
-- button
-- text: Read missing.txt
+  - img
+- text: Code Run bash echo and catch missing file read
+- img
+- text: Bash Echo CODE_ROUND_OK Read
+- button "missing.txt"
 - button "Think The program ran successfully. Let me now reply DONE as instructed.":
+  - img
   - img
   - text: Think The program ran successfully. Let me now reply DONE as instructed.
 - paragraph: DONE
-- text: cache hit 52% · 17,490 tokens · 1 turns · 2 steps
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps
 - textbox "Message the agent"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
-- button "选择模型,当前 deepseek-v4-flash":
-  - text: deepseek-v4-flash
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/cordis-tool-round/session.jsonl b/apps/web/tests/snapshots/cordis-tool-round/session.jsonl
index ab6c86089d..558f194795 100644
--- a/apps/web/tests/snapshots/cordis-tool-round/session.jsonl
+++ b/apps/web/tests/snapshots/cordis-tool-round/session.jsonl
@@ -1,6 +1,6 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785157562825,"cwd":"{{cwd}}/workspace"}
 {"type":"turn/start","seq":0,"time":1785157562881,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
-{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"27e1fc72-20d0-4875-bf5b-d34750441f30"},"surfaceOp":"append"}
 {"type":"session/title","seq":2,"time":1785157562883,"data":{"title":"Use only Cordis tools. First","messageSeqs":[1],"source":{"kind":"fallback"}}}
 {"type":"step/start","seq":3,"time":1785157562937,"data":{"turn":1,"step":1}}
 {"type":"request/header","seq":4,"time":1785157562938,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
@@ -12,9 +12,9 @@
 {"type":"assistant/chunk","seq":100,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}}}
 {"type":"assistant/chunk","seq":101,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}}}}
 {"type":"assistant/chunk","seq":102,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"}
+{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a3b56157-c6fd-43f8-bc8a-0e2a0c99a8b5"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"}
 {"type":"tool/call","seq":104,"time":1785157565496,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}
-{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false},"sourceEventSeqs":[104],"surfaceOp":"append"}
+{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_KZk918WtlKan9pHMULIT8794"},"content":[{"type":"tool-result","toolCallId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false}],"role":"user","id":"95ae124e-f065-4aab-a9c5-bd33c7eafff1"}},"sourceEventSeqs":[104],"surfaceOp":"append"}
 {"type":"step/end","seq":106,"time":1785157565503,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":107,"time":1785157565503,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":108,"time":1785157566524,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -25,9 +25,9 @@
 {"type":"assistant/chunk","seq":157,"time":1785157567041,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}}}
 {"type":"assistant/chunk","seq":158,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}}}}
 {"type":"assistant/chunk","seq":159,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"}
+{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b90af675-f604-4fb4-9c27-89c91b27f4ce"}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"}
 {"type":"tool/call","seq":161,"time":1785157567043,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}
-{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[161],"surfaceOp":"append"}
+{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361"},"content":[{"type":"tool-result","toolCallId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"3148fc0a-f511-4b72-abd5-d184e974cd49"}},"sourceEventSeqs":[161],"surfaceOp":"append"}
 {"type":"step/end","seq":163,"time":1785157567050,"data":{"turn":1,"step":2}}
 {"type":"step/start","seq":164,"time":1785157567050,"data":{"turn":1,"step":3}}
 {"type":"assistant/chunk","seq":165,"time":1785157567835,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -38,9 +38,9 @@
 {"type":"assistant/chunk","seq":204,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
 {"type":"assistant/chunk","seq":205,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}}}}
 {"type":"assistant/chunk","seq":206,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"}
+{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdc5b840-4302-486a-8065-fa59bf13f2d9"}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"}
 {"type":"tool/call","seq":208,"time":1785157568280,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
-{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[208],"surfaceOp":"append"}
+{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_e38S6zeYdZGvbhecUCil6659"},"content":[{"type":"tool-result","toolCallId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"758a7b27-56e3-4997-8c4d-effc699cc5e6"}},"sourceEventSeqs":[208],"surfaceOp":"append"}
 {"type":"step/end","seq":210,"time":1785157568282,"data":{"turn":1,"step":3}}
 {"type":"step/start","seq":211,"time":1785157568282,"data":{"turn":1,"step":4}}
 {"type":"assistant/chunk","seq":212,"time":1785157569185,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -51,6 +51,6 @@
 {"type":"assistant/chunk","seq":244,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CORDIS_UI_DONE"}}}}
 {"type":"assistant/chunk","seq":245,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}}}}
 {"type":"assistant/chunk","seq":246,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"}
+{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"71540121-7bf6-410e-975c-f5ea99d8175a"}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"}
 {"type":"step/end","seq":248,"time":1785157569554,"data":{"turn":1,"step":4}}
 {"type":"turn/end","seq":249,"time":1785157569554,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
index e2a1275f6c..5e1a148f9e 100644
--- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
+++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
@@ -1,12 +1,10 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Use only Cordis tools. First" [disabled]
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."
+- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}"
 - button "复制":
   - img
 - button "在新对话中分支":
@@ -15,34 +13,64 @@
   - img
 - button "▸ 上下文注入"
 - button "Think The user wants me to:":
+  - img
   - img
   - text: "Think The user wants me to:"
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}}
 - button:
   - img
+  - img
 - text: Inspect temporary
 - 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."':
+  - img
   - img
   - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}}
 - button [expanded]:
   - img
 - text: Mount temporary Plugin typescript
 - button "复制"
 - code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
 - 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."':
+  - img
   - img
   - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}}
 - button:
   - img
+  - img
 - text: Unmount temporary Plugin dyn-1
 - button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.":
+  - img
   - img
   - text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
 - paragraph: CORDIS_UI_DONE
-- text: cache hit 77% · 66,813 tokens · 1 turns · 4 steps
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps
 - textbox "Message the agent"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
 - button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl
index 53a75267e5..202b73f83a 100644
--- a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl
+++ b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl
@@ -1,6 +1,6 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"}
 {"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
-{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"2c594e5a-bbcc-4c64-b5ee-8e84eb5dd949"},"surfaceOp":"append"}
 {"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
 {"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}}
 {"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
@@ -12,9 +12,9 @@
 {"type":"assistant/chunk","seq":52,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}}
 {"type":"assistant/chunk","seq":53,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}}
 {"type":"assistant/chunk","seq":54,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"}
+{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"e5d8d5fb-f555-4fda-948f-9ab178145929"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"}
 {"type":"tool/call","seq":56,"time":1784973851499,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}
-{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"}
+{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_BYXlxjFaalMg95YVqEeF2495"},"content":[{"type":"tool-result","toolCallId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false}],"role":"user","id":"215febf9-ff3c-42b8-93b7-27ce1505c1c2"}},"sourceEventSeqs":[56],"surfaceOp":"append"}
 {"type":"step/end","seq":58,"time":1784973851517,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":59,"time":1784973851518,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":60,"time":1784973852194,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -26,6 +26,6 @@
 {"type":"assistant/chunk","seq":88,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
 {"type":"assistant/chunk","seq":89,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}}}}
 {"type":"assistant/chunk","seq":90,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
+{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"61e83a98-18a9-4f1f-9151-c8783cc39901"}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
 {"type":"step/end","seq":92,"time":1784973852461,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":93,"time":1784973852462,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
index dc572023fd..d0810ea315 100644
--- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
+++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
@@ -1,28 +1,47 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Use the bash tool to" [disabled]
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."
+- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
+- button "▸ 上下文注入"
 - button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
+  - img
   - img
   - text: Think The user wants me to run a simple bash command and reply with "DONE".
-- text: Echo the test string
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}}
+- img
+- text: Bash Echo the test string
 - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
+  - img
   - img
   - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
 - paragraph: DONE
-- text: cache hit 99% · 15,818 tokens · 1 turns · 2 steps
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps
 - textbox "Message the agent"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
-- button "选择模型,当前 deepseek-v4-flash":
-  - text: deepseek-v4-flash
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
index 2f958fa552..ee6c1a7475 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
@@ -1,3 +1,4 @@
+- button "New session"
 - button "Collapse sidebar":
   - img
 - button "New session":
@@ -27,11 +28,13 @@
 - textbox "Describe what you want to build"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
-- button "选择模型,当前 deepseek-v4-flash":
-  - text: deepseek-v4-flash
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
 - text: 详情
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
index 056bd71144..6d601aad87 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
@@ -1,24 +1,36 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Reply with the single word" [disabled]
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: Reply with the single word LIGHTHOUSE and stop.
+- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
+- button "▸ 上下文注入"
 - button "Think The user wants me to reply with a single word. Let me comply.":
+  - img
   - img
   - text: Think The user wants me to reply with a single word. Let me comply.
 - paragraph: LIGHTHOUSE
-- text: cache hit 99% · 7,810 tokens · 1 turns · 1 steps
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps
 - textbox "Message the agent"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
-- button "选择模型,当前 deepseek-v4-flash":
-  - text: deepseek-v4-flash
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl b/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl
index 4d7caa325d..a168f82d9e 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl
+++ b/apps/web/tests/snapshots/lifecycle-chrome/session.jsonl
@@ -1,6 +1,6 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
 {"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
-{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"ee28d2ee-08b2-4ed7-a1e9-84865f55e2af"},"surfaceOp":"append"}
 {"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
 {"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
 {"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
@@ -12,6 +12,6 @@
 {"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
 {"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
 {"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
+{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2101eadc-3475-4c77-ae0a-0107b12c34bc"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
 {"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
 {"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
index 972062a3c5..a141ad147c 100644
--- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
@@ -1,21 +1,33 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Reply with a one-sentence description" [disabled]
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: Reply with a one-sentence description of event sourcing, then stop.
+- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
+- button "▸ 上下文注入"
 - paragraph: partial
-- text: 已停止 0 tokens · 1 turns · 1 steps
+- text: 已停止
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}} 0 tokens · 1 turns · 1 steps
 - textbox "Message the agent"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
-- button "选择模型,当前 deepseek-v4-flash":
-  - text: deepseek-v4-flash
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
index 64bdb9c39b..7fd444dc9d 100644
--- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
@@ -1,19 +1,26 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Reply with a one-sentence description" [disabled]
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: Reply with a one-sentence description of event sourcing, then stop.
+- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
+- button "▸ 上下文注入"
 - textbox "Message the agent"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
-- button "选择模型,当前 deepseek-v4-flash":
-  - text: deepseek-v4-flash
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md
index 9d41e87f41..d857b6663c 100644
--- a/apps/web/tests/snapshots/live-interactions/retry.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md
@@ -1,24 +1,36 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Reply with a one-sentence description" [disabled]
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: Reply with a one-sentence description of event sourcing, then stop.
+- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
+- button "▸ 上下文注入"
 - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
+  - img
   - img
   - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
 - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
-- text: cache hit 99% · 7,869 tokens · 1 turns · 1 steps
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps
 - textbox "Message the agent"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
-- button "选择模型,当前 deepseek-v4-flash":
-  - text: deepseek-v4-flash
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/live-interactions/session.jsonl b/apps/web/tests/snapshots/live-interactions/session.jsonl
index e002ec48ee..91b0c730a8 100644
--- a/apps/web/tests/snapshots/live-interactions/session.jsonl
+++ b/apps/web/tests/snapshots/live-interactions/session.jsonl
@@ -1,6 +1,6 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784998084441,"cwd":"{{cwd}}/workspace"}
 {"type":"turn/start","seq":0,"time":1784998084454,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
-{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"dc9a43b2-63a3-49bc-97e9-9dfb6465f6c1"},"surfaceOp":"append"}
 {"type":"session/title","seq":2,"time":1784998084457,"data":{"title":"Reply with a one-sentence description","messageSeqs":[1],"source":{"kind":"fallback"}}}
 {"type":"step/start","seq":3,"time":1784998084519,"data":{"turn":1,"step":1}}
 {"type":"request/header","seq":4,"time":1784998084520,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
@@ -12,6 +12,6 @@
 {"type":"assistant/chunk","seq":86,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}}}}
 {"type":"assistant/chunk","seq":87,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}}}}
 {"type":"assistant/chunk","seq":88,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"}
+{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3396618c-56a2-4f97-a9c7-2a3cbb16a3c8"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"}
 {"type":"step/end","seq":90,"time":1784998085820,"data":{"turn":1,"step":1}}
 {"type":"turn/end","seq":91,"time":1784998085821,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md
new file mode 100644
index 0000000000..bd833b6c3c
--- /dev/null
+++ b/apps/web/tests/snapshots/message-actions/ui.expected.md
@@ -0,0 +1,51 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Use the read tool twice" [disabled]
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
+- button "复制":
+  - img
+- tooltip "复制"
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
+- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
+  - img
+  - img
+  - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}}
+- img
+- text: Read
+- button "a.txt"
+- img
+- text: Read
+- button "b.txt"
+- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
+  - img
+  - img
+  - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
+- paragraph: DONE
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps
+- textbox "Message the agent"
+- button "Add attachment":
+  - img
+- text: Danger Full Access
+- combobox "Access mode":
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 deepseek-v4-flash":
+  - text: deepseek-v4-flash
+  - img
+- button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/navigation-panes/seed.jsonl b/apps/web/tests/snapshots/navigation-panes/seed.jsonl
index 72df45daac..3623cd749a 100644
--- a/apps/web/tests/snapshots/navigation-panes/seed.jsonl
+++ b/apps/web/tests/snapshots/navigation-panes/seed.jsonl
@@ -1,6 +1,6 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785011380476,"cwd":"{{cwd}}/workspace"}
 {"type":"turn/start","seq":0,"time":1785011380489,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
-{"type":"user/message","seq":1,"time":1785011380490,"data":{"content":[{"type":"text","text":"NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1785011380490,"data":{"content":[{"type":"text","text":"NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"7dcfe547-5555-4e5d-b2a0-3f9d2e0836a9"},"surfaceOp":"append"}
 {"type":"session/title","seq":2,"time":1785011380492,"data":{"title":"NavScenario: first run bash to","messageSeqs":[1],"source":{"kind":"fallback"}}}
 {"type":"step/start","seq":3,"time":1785011380549,"data":{"turn":1,"step":1}}
 {"type":"request/header","seq":4,"time":1785011380550,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
@@ -18,13 +18,13 @@
 {"type":"assistant/chunk","seq":130,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":3,"block":{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}}}
 {"type":"assistant/chunk","seq":131,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}}}}
 {"type":"assistant/chunk","seq":132,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":133,"time":1785011382091,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"}
+{"type":"assistant/message","seq":133,"time":1785011382091,"data":{"turn":1,"step":1,"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"83b27ad0-4286-478f-92ef-3bc82bf0589b"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"}
 {"type":"tool/call","seq":134,"time":1785011382092,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"}}
-{"type":"tool/result","seq":135,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false},"sourceEventSeqs":[134],"surfaceOp":"append"}
+{"type":"tool/result","seq":135,"time":1785011382105,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_kFKHaEXcTYEex0iDZw0C2432"},"content":[{"type":"tool-result","toolCallId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false}],"role":"user","id":"d6e29f9e-8177-4f12-b1b6-2eda4c8f5c31"}},"sourceEventSeqs":[134],"surfaceOp":"append"}
 {"type":"tool/call","seq":136,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"}}
 {"type":"tool/call","seq":137,"time":1785011382106,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}
-{"type":"tool/result","seq":138,"time":1785011382113,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"{{cwd}}/workspace/nav-a.md\nfile\n\n1: # alpha nav\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[136],"surfaceOp":"append"}
-{"type":"tool/result","seq":139,"time":1785011382114,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"{{cwd}}/workspace/nav-b.md\nfile\n\n1: # beta nav\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[137],"surfaceOp":"append"}
+{"type":"tool/result","seq":138,"time":1785011382113,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212"},"content":[{"type":"tool-result","toolCallId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"{{cwd}}/workspace/nav-a.md\nfile\n\n1: # alpha nav\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"efca7baf-cbb8-462f-ad75-580357b58676"}},"sourceEventSeqs":[136],"surfaceOp":"append"}
+{"type":"tool/result","seq":139,"time":1785011382114,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224"},"content":[{"type":"tool-result","toolCallId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"{{cwd}}/workspace/nav-b.md\nfile\n\n1: # beta nav\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"e1c70190-9e68-429d-963d-8a30ab779ddd"}},"sourceEventSeqs":[137],"surfaceOp":"append"}
 {"type":"step/end","seq":140,"time":1785011382117,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":141,"time":1785011382118,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":142,"time":1785011382673,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -35,11 +35,11 @@
 {"type":"assistant/chunk","seq":200,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST_DONE"}}}}
 {"type":"assistant/chunk","seq":201,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}}}}
 {"type":"assistant/chunk","seq":202,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":203,"time":1785011383091,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"}
+{"type":"assistant/message","seq":203,"time":1785011383091,"data":{"turn":1,"step":2,"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0115844e-fac0-4985-9ba7-dcf1dde63b83"}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"}
 {"type":"step/end","seq":204,"time":1785011383091,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":205,"time":1785011383092,"data":{"turn":1,"reason":{"kind":"completed"}}}
 {"type":"turn/start","seq":206,"time":1785011383106,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
-{"type":"user/message","seq":207,"time":1785011383107,"data":{"content":[{"type":"text","text":"Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":207,"time":1785011383107,"data":{"content":[{"type":"text","text":"Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"1d7a8563-4ae6-49f3-9a65-41f3df300a75"},"surfaceOp":"append"}
 {"type":"step/start","seq":208,"time":1785011383107,"data":{"turn":2,"step":1}}
 {"type":"assistant/chunk","seq":209,"time":1785011383497,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
 {"type":"reasoning-chunks","seq0":210,"time0":1785011383497,"data":{"turn":2,"step":1,"index":0,"dt":[125,23,1,0,0,88,0,0,5,0,1,0,0,7,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," specific"," format","."," Let"," me"," do"," that","."]}}
@@ -49,6 +49,6 @@
 {"type":"assistant/chunk","seq":247,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}}}}
 {"type":"assistant/chunk","seq":248,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}}}}
 {"type":"assistant/chunk","seq":249,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":250,"time":1785011383904,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"}
+{"type":"assistant/message","seq":250,"time":1785011383904,"data":{"turn":2,"step":1,"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"70b38db9-38a2-4f9f-8094-1bd799f5f270"}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"}
 {"type":"step/end","seq":251,"time":1785011383904,"data":{"turn":2,"step":1}}
 {"type":"turn/end","seq":252,"time":1785011383904,"data":{"turn":2,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
new file mode 100644
index 0000000000..298bf764b6
--- /dev/null
+++ b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
@@ -0,0 +1,3 @@
+- text: 已完成 {{workspace}} echo NAVIGATION_OK
+- button "复制"
+- text: NAVIGATION_OK
diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md
index 80d6f161ca..0bdf87d67a 100644
--- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md
+++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md
@@ -1 +1,53 @@
-- text: "Turn 1 Message {{duration}} #1 User NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. +{{duration}} Step 1 {{duration}} bash read×2 #2 Tool bash · {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} +{{duration}} #3 Tool read · {\"file_path\": \"nav-a.md\"} +{{duration}} #4 Tool read · {\"file_path\": \"nav-b.md\"} +{{duration}} Step 2 {{duration}} #5 Message FIRST_DONE 349 56 51 +{{duration}} Turn 2 Message {{duration}} #6 User Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. +{{duration}} Step 1 {{duration}} #7 Message ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ``` 141 36 16 +{{duration}}"
+- toolbar "Trajectory toolbar":
+  - button "Use actual duration": Duration
+  - button "Collapse turns": Turns
+  - button "Collapse calls": Calls
+  - img
+  - searchbox "Search trajectory"
+- region "Trajectory timeline"
+- table:
+  - rowgroup:
+    - row "SYSTEM, Initial System Prompt":
+      - cell "SYSTEM"
+      - cell "Initial System Prompt"
+    - 'row "USER, NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."':
+      - cell "Turn 1 USER"
+      - 'cell "NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop."'
+    - 'row "Request 1, ASSISTANT, The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."':
+      - 'cell "Request #1 ASSISTANT"':
+        - 'button "Request #1"'
+        - text: ASSISTANT
+      - 'cell "The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."'
+    - 'row "TOOL, bash {\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}" [selected]':
+      - cell "TOOL"
+      - 'cell "bash{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"} → NAVIGATION_OK"'
+    - 'row "TOOL, read {\"file_path\": \"nav-a.md\"}"':
+      - cell "TOOL"
+      - 'cell "read{\"file_path\": \"nav-a.md\"} → {{cwd}}/nav-a.md file  1: # alpha nav (End of file - total 1 lines) "'
+    - 'row "TOOL, read {\"file_path\": \"nav-b.md\"}"':
+      - cell "TOOL"
+      - 'cell "read{\"file_path\": \"nav-b.md\"} → {{cwd}}/nav-b.md file  1: # beta nav (End of file - total 1 lines) "'
+    - row "Request 2, ASSISTANT, FIRST_DONE":
+      - 'cell "Request #2 ASSISTANT"':
+        - 'button "Request #2"'
+        - text: ASSISTANT
+      - cell "FIRST_DONE"
+    - 'row "USER, Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."':
+      - cell "Turn 2 USER"
+      - 'cell "Reply in markdown with: a level-2 heading \"Navigation Summary\", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop."'
+    - row "Request 3, ASSISTANT, Navigation Summary alpha nav beta nav echo WATERFALL":
+      - 'cell "Request #3 ASSISTANT"':
+        - 'button "Request #3"'
+        - text: ASSISTANT
+      - cell "Navigation Summary alpha nav beta nav echo WATERFALL"
+- complementary "Event details":
+  - separator "Resize event details"
+  - text: TOOL Turn 1 · Step 1
+  - button "Close details"
+  - tablist "Event details":
+    - tab "Summary"
+    - tab "Payload"
+    - tab "Result" [selected]
+    - tab "Schema"
+    - tab "Timing"
+  - tabpanel "Result": NAVIGATION_OK
diff --git a/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md b/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md
deleted file mode 100644
index 6c5ab1a046..0000000000
--- a/apps/web/tests/snapshots/navigation-panes/waterfall.expected.md
+++ /dev/null
@@ -1 +0,0 @@
-- text: 3 turns · 3 steps · 3 tool calls turn 0 turn 1 turn 2
diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md
index 94179416a4..bd03429898 100644
--- a/apps/web/tests/snapshots/question-composer/answered.expected.md
+++ b/apps/web/tests/snapshots/question-composer/answered.expected.md
@@ -1,36 +1,49 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Use the ask_user_question tool to" [disabled]
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."
+- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}"
 - button "复制":
   - img
 - button "在新对话中分支":
   - img
 - button "编辑":
   - img
+- button "▸ 上下文注入"
 - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
+  - img
   - img
   - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}}
 - button:
   - img
+  - img
 - text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"
 - button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
+  - img
   - img
   - text: Think The user answered "Blue". I should now reply with the single word DONE and stop.
 - paragraph: DONE
-- text: cache hit 95% · 8,769 tokens · 1 turns · 2 steps
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}} cache hit 95% · 8,769 tokens · 1 turns · 2 steps
 - textbox "Message the agent"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
-- button "选择模型,当前 deepseek-v4-flash":
-  - text: deepseek-v4-flash
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl
index a98f2a92f2..b13a84e22c 100644
--- a/apps/web/tests/snapshots/question-composer/session.jsonl
+++ b/apps/web/tests/snapshots/question-composer/session.jsonl
@@ -1,6 +1,6 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"}
 {"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
-{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"6deba879-8787-4853-a5f2-0d108a08eb2d"},"surfaceOp":"append"}
 {"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
 {"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}}
 {"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
@@ -12,9 +12,9 @@
 {"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}}
 {"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}}
 {"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
+{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdb1676c-e781-41ee-8f28-a3595371d729"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
 {"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}
-{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
+{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Cijldc88LYmVPCXYUsRq1617"},"content":[{"type":"tool-result","toolCallId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false}],"role":"user","id":"c69ef39f-6f62-439f-b3f8-e8d10fba572f"}},"sourceEventSeqs":[132],"surfaceOp":"append"}
 {"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -26,6 +26,6 @@
 {"type":"assistant/chunk","seq":160,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
 {"type":"assistant/chunk","seq":161,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}}}}
 {"type":"assistant/chunk","seq":162,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":163,"time":1785150170857,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}},"sourceEventSeqs":[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162],"surfaceOp":"append"}
+{"type":"assistant/message","seq":163,"time":1785150170857,"data":{"turn":1,"step":2,"usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b5f572cb-f2a7-4fa0-8097-a5551700a920"}},"sourceEventSeqs":[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162],"surfaceOp":"append"}
 {"type":"step/end","seq":164,"time":1785150170858,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":165,"time":1785150170858,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/seeded-history/seed.jsonl b/apps/web/tests/snapshots/seeded-history/seed.jsonl
index abf7a61162..bf69bcf152 100644
--- a/apps/web/tests/snapshots/seeded-history/seed.jsonl
+++ b/apps/web/tests/snapshots/seeded-history/seed.jsonl
@@ -1,6 +1,6 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"}
 {"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
-{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"38f072be-5254-4cb7-b76e-d612b2ae3b3a"},"surfaceOp":"append"}
 {"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}}
 {"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}}
 {"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
@@ -15,11 +15,11 @@
 {"type":"assistant/chunk","seq":61,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}}
 {"type":"assistant/chunk","seq":62,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}}}
 {"type":"assistant/chunk","seq":63,"time":1784974101974,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"}
+{"type":"assistant/message","seq":64,"time":1784974101978,"data":{"turn":1,"step":1,"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"ec076738-f75d-4525-ba99-c8fc16acf955"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"}
 {"type":"tool/call","seq":65,"time":1784974101979,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}
 {"type":"tool/call","seq":66,"time":1784974101981,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}
-{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[65],"surfaceOp":"append"}
-{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[66],"surfaceOp":"append"}
+{"type":"tool/result","seq":67,"time":1784974101985,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OsndvlcKnCcUmae7QXal8633"},"content":[{"type":"tool-result","toolCallId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ea2294eb-8652-4492-8a08-9c24d3f8a60f"}},"sourceEventSeqs":[65],"surfaceOp":"append"}
+{"type":"tool/result","seq":68,"time":1784974101986,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725"},"content":[{"type":"tool-result","toolCallId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"f02b3acf-4e03-4b4d-beeb-1a564c9c6d61"}},"sourceEventSeqs":[66],"surfaceOp":"append"}
 {"type":"step/end","seq":69,"time":1784974101988,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":70,"time":1784974101988,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":71,"time":1784974102397,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -31,6 +31,6 @@
 {"type":"assistant/chunk","seq":105,"time":1784974102749,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
 {"type":"assistant/chunk","seq":106,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}}}}
 {"type":"assistant/chunk","seq":107,"time":1784974102750,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[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],"surfaceOp":"append"}
+{"type":"assistant/message","seq":108,"time":1784974102750,"data":{"turn":1,"step":2,"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"07627a5e-4cb2-47ef-9b50-88893aac7406"}},"sourceEventSeqs":[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],"surfaceOp":"append"}
 {"type":"step/end","seq":109,"time":1784974102751,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":110,"time":1784974102751,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md
index 4f9181f702..c65bf7e712 100644
--- a/apps/web/tests/snapshots/seeded-history/ui.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md
@@ -1,32 +1,49 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Use the read tool twice" [disabled]
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."
+- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
 - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
+  - img
   - img
   - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
-- button:
+- button "复制":
   - img
-- text: Read a.txt
-- button:
+- button "在新对话中分支":
   - img
-- text: Read b.txt
+- text: {{clock}}
+- img
+- text: Read
+- button "a.txt"
+- img
+- text: Read
+- button "b.txt"
 - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
+  - img
   - img
   - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
 - paragraph: DONE
-- text: cache hit 98% · 15,962 tokens · 1 turns · 2 steps
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps
 - textbox "Message the agent"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
 - button "选择模型,当前 deepseek-v4-flash":
   - text: deepseek-v4-flash
   - img
diff --git a/apps/web/tests/snapshots/session-title.json b/apps/web/tests/snapshots/session-title.json
deleted file mode 100644
index 2063036803..0000000000
--- a/apps/web/tests/snapshots/session-title.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-  "initial": {
-    "sidebar": "Fixture 历史会话",
-    "breadcrumb": "Fixture 历史会话",
-    "documentTitle": "Fixture 历史会话 — DeepSeek Harness"
-  },
-  "revised": {
-    "sidebar": "Fixture 修订标题",
-    "breadcrumb": "Fixture 修订标题",
-    "documentTitle": "Fixture 修订标题 — DeepSeek Harness"
-  }
-}
diff --git a/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md
new file mode 100644
index 0000000000..ba62c84f91
--- /dev/null
+++ b/apps/web/tests/snapshots/skill-invocation-policy/menu.expected.md
@@ -0,0 +1,3 @@
+- listbox "Trigger suggestions":
+  - text: 技能
+  - option "policy-shared Available to both model and user invocation" [selected]
diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md
index a26bbb7bd8..b70aa38d2c 100644
--- a/apps/web/tests/snapshots/steering/mid-steer.expected.md
+++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md
@@ -1,19 +1,30 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Use the ask_user_question tool to" [disabled]
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
+- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
+- button "▸ 上下文注入"
 - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
+  - img
   - img
   - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
-- button
-- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)"
-- button "▸ 问题内容"
-- text: 请在原客户端处理(web 端作答后续里程碑提供) cache hit 98% · 7,946 tokens · 1 turns · 1 steps
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}}
+- button:
+  - img
+  - img
+- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} cache hit 98% · 7,946 tokens · 1 turns · 1 steps"
 - region "Ready to continue?":
   - text: Checkpoint
   - heading "Ready to continue?" [level=2]
diff --git a/apps/web/tests/snapshots/steering/session.jsonl b/apps/web/tests/snapshots/steering/session.jsonl
index 4015fa4ab8..eb21251997 100644
--- a/apps/web/tests/snapshots/steering/session.jsonl
+++ b/apps/web/tests/snapshots/steering/session.jsonl
@@ -1,6 +1,6 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785004180013,"cwd":"{{cwd}}/workspace"}
 {"type":"turn/start","seq":0,"time":1785004180030,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
-{"type":"user/message","seq":1,"time":1785004180030,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"user/message","seq":1,"time":1785004180030,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"checkpoint\", question \"Ready to continue?\", header \"Checkpoint\", and options labeled \"Yes\" and \"No\". After I answer, reply with one short sentence acknowledging my answer and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"eb77fa3d-5d60-4028-a592-e1f07a288f35"},"surfaceOp":"append"}
 {"type":"session/title","seq":2,"time":1785004180033,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
 {"type":"step/start","seq":3,"time":1785004180105,"data":{"turn":1,"step":1}}
 {"type":"request/header","seq":4,"time":1785004180106,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
@@ -12,10 +12,10 @@
 {"type":"assistant/chunk","seq":85,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}}}
 {"type":"assistant/chunk","seq":86,"time":1785004181401,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}}}}
 {"type":"assistant/chunk","seq":87,"time":1785004181402,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
-{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"}
+{"type":"assistant/message","seq":88,"time":1785004181406,"data":{"turn":1,"step":1,"usage":{"inputTokens":151,"outputTokens":115,"cacheReadTokens":7680,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"ac0cfafd-795f-4bc3-9dc9-a8df33538a38"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"}
 {"type":"tool/call","seq":89,"time":1785004181407,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"}}
-{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"callId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"}
-{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
+{"type":"tool/result","seq":90,"time":1785004181867,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_sAvjivLShvnWVk0sPQPV7661"},"content":[{"type":"tool-result","toolCallId":"call_00_sAvjivLShvnWVk0sPQPV7661","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"checkpoint\",\"selected\":[\"Yes\"]}]}"}],"isError":false}],"role":"user","id":"9c2175f8-3cd4-4d6c-9320-498d25f83342"}},"sourceEventSeqs":[89],"surfaceOp":"append"}
+{"type":"steering/message","seq":91,"time":1785004181867,"data":{"turn":1,"message":{"content":[{"type":"text","text":"Interjection: include the word BANANA in your final reply."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"291e02ad-cf8a-459f-a388-f81ed334e629"}},"surfaceOp":"append"}
 {"type":"step/end","seq":92,"time":1785004181870,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":93,"time":1785004181870,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":94,"time":1785004182322,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
@@ -26,6 +26,6 @@
 {"type":"assistant/chunk","seq":137,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Great, let's move forward. BANANA!"}}}}
 {"type":"assistant/chunk","seq":138,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}}}}
 {"type":"assistant/chunk","seq":139,"time":1785004182893,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
-{"type":"assistant/message","seq":140,"time":1785004182894,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."},{"type":"text","text":"Great, let's move forward. BANANA!"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29}},"sourceEventSeqs":[94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139],"surfaceOp":"append"}
+{"type":"assistant/message","seq":140,"time":1785004182894,"data":{"turn":1,"step":2,"usage":{"inputTokens":172,"outputTokens":41,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer."},{"type":"text","text":"Great, let's move forward. BANANA!"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b49cca3a-fa40-4f45-91c9-e7c3f15fa233"}},"sourceEventSeqs":[94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139],"surfaceOp":"append"}
 {"type":"step/end","seq":141,"time":1785004182895,"data":{"turn":1,"step":2}}
 {"type":"turn/end","seq":142,"time":1785004182895,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md
index a5735a09d5..790bd022f2 100644
--- a/apps/web/tests/snapshots/steering/settled.expected.md
+++ b/apps/web/tests/snapshots/steering/settled.expected.md
@@ -1,30 +1,49 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Use the ask_user_question tool to" [disabled]
-    - text: · 1 turns
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
-    - tab "Waterfall"
-- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
+- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- button "编辑":
+  - img
+- button "▸ 上下文注入"
 - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
+  - img
   - img
   - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}}
 - button:
   - img
+  - img
 - text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply."
 - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
+  - img
   - img
   - text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer.
 - paragraph: Great, let's move forward. BANANA!
-- text: cache hit 98% · 15,967 tokens · 1 turns · 2 steps
+- button "复制":
+  - img
+- button "在新对话中分支":
+  - img
+- text: {{clock}} cache hit 98% · 15,967 tokens · 1 turns · 2 steps
 - textbox "Message the agent"
 - button "Add attachment":
   - img
+- text: Danger Full Access
 - combobox "Access mode":
-  - option "Read-only" [selected]
-  - option "Read-write"
-- button "选择模型,当前 deepseek-v4-flash":
-  - text: deepseek-v4-flash
+  - option "Read Only"
+  - option "Workspace Write"
+  - option "Danger Full Access" [selected]
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md
new file mode 100644
index 0000000000..7fbcd35d75
--- /dev/null
+++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md
@@ -0,0 +1,23 @@
+- dialog "选择工作区目录":
+  - heading "选择工作区目录" [level=2]
+  - navigation:
+    - button "主目录"
+    - img
+    - button "browse-golden"
+  - button "编辑路径"
+  - list:
+    - listitem:
+      - button "alpha":
+        - img
+        - text: alpha
+        - img
+    - listitem:
+      - button "beta":
+        - img
+        - text: beta
+        - img
+  - button "新建文件夹":
+    - img
+    - text: 新建文件夹
+  - button "取消"
+  - button "打开"
diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts
deleted file mode 100644
index 2ae5f0e402..0000000000
--- a/apps/web/tests/todo-display.snapshot.ts
+++ /dev/null
@@ -1,220 +0,0 @@
-// @vitest-environment jsdom
-// Todo display snapshot over the BUILT client graph (the code-mode-fixture
-// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
-// Opens the fixture history session and pins the todo_write turn's two
-// surfaces: the dedicated TodoRow in the chat flow (keyed toolview, summary
-// derived from the call args) and the TodoPanel plan strip riding the
-// 'conversation.input.dock' slot (fed by the host `todos` projection via
-// useProjection, seeded by the tail history page), including the collapse
-// interaction and the next-turn clearance of the standing plan.
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
-
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    dir: 'ui-workspace',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
-  },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  document.title = 'DeepSeek Harness'
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  delete (globalThis as Record).__fxTiming
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
-
-/** Boot the complete built client graph against the populated fixture branch. */
-function boot(): void {
-  history.replaceState(null, '', '/?fixture')
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      fetchBundle: (url) => {
-        const code = bundles.get(url)
-        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
-      },
-      executeBundle: (code) => { (0, eval)(code) },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
-}
-
-/** Collapse decorative whitespace while preserving the text a user sees. */
-function visibleText(element: Element): string {
-  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
-}
-
-/** Open the fixture history session (the alpha log carrying the todo_write turn) and wait for its tail. */
-async function openFixtureSession(): Promise {
-  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
-  // Anchor on the expandable Workspace group row: the title and the blank
-  // session row can both read "fixture", and the session-count meta shifts
-  // when a blank session joins the group.
-  const group = (await within(tree).findAllByText('fixture'))
-    .map(el => el.closest('[role="treeitem"]'))
-    .find(el => el?.getAttribute('aria-expanded') !== null)
-  if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
-  if (group.getAttribute('aria-expanded') === 'false') {
-    fireEvent.click(within(group).getByText('fixture'))
-    await waitFor(() => {
-      expect(group.getAttribute('aria-expanded')).toBe('true')
-    })
-  }
-  const session = await within(tree).findByText('Fixture 历史会话')
-  fireEvent.click(session)
-  await waitFor(() => {
-    expect(document.querySelector('[data-sample="todo-row"]')).not.toBeNull()
-  }, { timeout: 10_000 })
-}
-
-it('renders the todo_write turn: dedicated tool row + the dock plan strip', async () => {
-  boot()
-  await openFixtureSession()
-
-  const row = document.querySelector('[data-sample="todo-row"]')
-  if (row === null) throw new Error('todo row missing')
-  const panel = document.querySelector('[data-testid="todo-panel"]')
-  if (panel === null) throw new Error('todo panel missing from the input dock')
-
-  // Header spans are adjacent inline nodes; textContent joins "To-dos" +
-  // "1/3…" with no space (visual gap is CSS gap: 10px, not a text node).
-  expect({
-    row: visibleText(row),
-    rowState: row.getAttribute('data-state'),
-    panelHeader: visibleText(panel.querySelector('button') ?? panel),
-    panelItems: [...panel.querySelectorAll('li')].map(item => ({
-      status: item.getAttribute('data-status'),
-      text: visibleText(item),
-    })),
-  }).toMatchInlineSnapshot(`
-    {
-      "panelHeader": "To-dos1/3 tasks · 1 in progress",
-      "panelItems": [
-        {
-          "status": "completed",
-          "text": "梳理需求",
-        },
-        {
-          "status": "in_progress",
-          "text": "实现 fixture 样本",
-        },
-        {
-          "status": "pending",
-          "text": "浏览器验收",
-        },
-      ],
-      "row": "更新任务清单1/3 已完成 · 实现 fixture 样本",
-      "rowState": "ok",
-    }
-  `)
-})
-
-it('collapses the plan strip to the count summary and restores it', async () => {
-  boot()
-  await openFixtureSession()
-
-  const panel = document.querySelector('[data-testid="todo-panel"]')
-  if (panel === null) throw new Error('todo panel missing from the input dock')
-  const header = panel.querySelector('button')
-  if (header === null) throw new Error('todo panel header missing')
-
-  fireEvent.click(header)
-  expect({
-    collapsedHeader: visibleText(header),
-    listGone: panel.querySelector('ul') === null,
-  }).toMatchInlineSnapshot(`
-    {
-      "collapsedHeader": "To-dos1/3 tasks · 1 in progress",
-      "listGone": true,
-    }
-  `)
-
-  fireEvent.click(header)
-  expect(panel.querySelectorAll('li')).toHaveLength(3)
-})
-
-it('hides the plan strip when the next turn starts', async () => {
-  boot()
-  await openFixtureSession()
-  expect(document.querySelector('[data-testid="todo-panel"]')).not.toBeNull()
-
-  const composer = await screen.findByPlaceholderText('Message the agent', {}, { timeout: 10_000 })
-  fireEvent.change(composer, { target: { value: '下一轮清空计划' } })
-  fireEvent.keyDown(composer, { key: 'Enter' })
-
-  await screen.findByText('下一轮清空计划', { exact: true }, { timeout: 10_000 })
-  await waitFor(() => {
-    expect(document.querySelector('[data-testid="todo-panel"]')).toBeNull()
-  }, { timeout: 10_000 })
-
-  expect({
-    promptVisible: screen.getByText('下一轮清空计划', { exact: true }).textContent,
-    panelGone: document.querySelector('[data-testid="todo-panel"]') === null,
-    // Historical todo_write row stays in the flow; only the dock strip clears.
-    rowStillPresent: document.querySelector('[data-sample="todo-row"]') !== null,
-  }).toMatchInlineSnapshot(`
-    {
-      "panelGone": true,
-      "promptVisible": "下一轮清空计划",
-      "rowStillPresent": true,
-    }
-  `)
-})
diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts
deleted file mode 100644
index b33df1b157..0000000000
--- a/apps/web/tests/workspace-flow.snapshot.ts
+++ /dev/null
@@ -1,357 +0,0 @@
-// @vitest-environment jsdom
-// Assembled keyless snapshots of the New Session flow under the agent-parity
-// model: startup auto-connects the recent Workspace's blank session when one
-// exists; without any Workspace the composer is locked in the pure view
-// state until one is chosen. Picking one materializes the full Session+Agent
-// (reuse-or-create of the workspace's blank session), the first ACCEPTED
-// prompt flips blank and surfaces the session in lists, and failures leave
-// no client-side transaction state: a failed attach keeps the view state
-// locked, a rejected prompt keeps the session blank with the draft restored.
-import { readFileSync } from 'node:fs'
-import { join } from 'node:path'
-import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
-import { afterEach, beforeEach, expect, it, vi } from 'vitest'
-import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
-import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
-
-const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
-  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
-  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
-  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
-  { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
-  { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
-  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
-  {
-    id: '@deepseek-ai/dsh-client-ui-workspace',
-    dir: 'ui-workspace',
-    url: '/plugins/ui-workspace.js',
-    rev: 'fx',
-    inject: [
-      '@deepseek-ai/dsh-client-runtime',
-      '@deepseek-ai/dsh-client-ui-conversation',
-      '@deepseek-ai/dsh-client-ui-sidebar',
-    ],
-  },
-  { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
-]
-
-const bundles = new Map(PLUGINS.map(plugin => [
-  plugin.url,
-  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
-]))
-
-interface FixtureWindow extends Window {
-  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
-  __ModuleLoader__?: unknown
-}
-
-class ResizeObserverStub {
-  observe(): void {}
-  disconnect(): void {}
-  unobserve(): void {}
-}
-
-const win = window as FixtureWindow
-let unmount: (() => void) | undefined
-
-beforeEach(() => {
-  localStorage.clear()
-  document.title = 'DeepSeek Harness'
-  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
-    setTimeout(() => { callback(0) }, 0) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
-})
-
-afterEach(() => {
-  act(() => { unmount?.() })
-  unmount = undefined
-  cleanup()
-  delete win.__DSH_BOOT__
-  delete win.__ModuleLoader__
-  delete (globalThis as Record).__fxTiming
-  document.body.innerHTML = ''
-  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
-  document.title = ''
-  history.replaceState(null, '', '/')
-  vi.unstubAllGlobals()
-})
-
-/** Boot the complete built client graph against one keyless fixture branch. */
-function boot(search: string): void {
-  history.replaceState(null, '', `/${search}`)
-  const root = document.createElement('div')
-  root.id = 'root'
-  document.body.appendChild(root)
-  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
-  act(() => {
-    const entry = new AppWebEntry(root, {
-      fetchBundle: (url) => {
-        const code = bundles.get(url)
-        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
-      },
-      executeBundle: (code) => { (0, eval)(code) },
-    })
-    void entry.run()
-    unmount = () => { entry.dispose() }
-  })
-}
-
-/** Collapse decorative whitespace while preserving the text a user sees. */
-function visibleText(element: Element): string {
-  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
-}
-
-/** Identify the interactive Workspace chip (view state or blank-session hero) by its menu contract. */
-function workspaceChip(): HTMLElement {
-  const chip = screen.getAllByRole('button', { name: 'Choose workspace' })
-    .find(element => element.getAttribute('aria-haspopup') === 'menu')
-  if (chip === undefined) throw new Error('Workspace chip missing')
-  return chip
-}
-
-/** The locked view-state composer (no session yet). */
-async function findLockedComposer(): Promise {
-  return await screen.findByPlaceholderText(
-    'Choose a workspace to start', {}, { timeout: 10_000 },
-  )
-}
-
-/** The live blank-session hero composer (session materialized). */
-async function findHeroComposer(): Promise {
-  return await screen.findByPlaceholderText(
-    'Describe what you want to build', {}, { timeout: 10_000 },
-  )
-}
-
-/** Edit the machine-owned controlled input and assert the same-tick echo. */
-function setComposerText(composer: HTMLElement, value: string): void {
-  fireEvent.change(composer, { target: { value } })
-  expect((composer as HTMLTextAreaElement).value).toBe(value)
-}
-
-/** Drive the picker's create flow: chip → Create a new workspace → name dialog. */
-async function createWorkspaceViaPicker(name: string): Promise {
-  fireEvent.click(workspaceChip())
-  fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
-  const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
-  fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
-    target: { value: name },
-  })
-  fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
-}
-
-/** Pick an existing Workspace row from the chip menu. */
-async function pickWorkspace(title: string): Promise {
-  fireEvent.click(workspaceChip())
-  fireEvent.click(await screen.findByRole('menuitem', { name: title }))
-}
-
-it('locks the composer in the New Session view state until a Workspace is chosen', async () => {
-  boot('?fixture=empty')
-
-  const composer = await findLockedComposer()
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-
-  expect({
-    headline: visibleText(screen.getByText("Let's start building")),
-    chip: visibleText(workspaceChip()),
-    composerDisabled: composer.disabled,
-    sendDisabled: screen.getByRole('button', { name: 'Send message' }).disabled,
-    sidebar: visibleText(tree),
-  }).toMatchInlineSnapshot(`
-    {
-      "chip": "Choose workspace",
-      "composerDisabled": true,
-      "headline": "Let's start building",
-      "sendDisabled": true,
-      "sidebar": "No sessions yet",
-    }
-  `)
-})
-
-it('selects the recent Workspace and opens its blank Session on first load', async () => {
-  boot('?fixture')
-
-  const composer = await findHeroComposer()
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  await waitFor(() => { expect(within(tree).getByText('4 sessions')).toBeDefined() }, { timeout: 10_000 })
-
-  expect({
-    chip: visibleText(workspaceChip()),
-    composerDisabled: composer.disabled,
-    blankRow: within(tree).getByText('New Session').textContent,
-  }).toMatchInlineSnapshot(`
-    {
-      "blankRow": "New Session",
-      "chip": "fixture",
-      "composerDisabled": false,
-    }
-  `)
-})
-
-it('creating a Workspace materializes and lists its selected blank Session', async () => {
-  boot('?fixture=empty')
-
-  await findLockedComposer()
-  await createWorkspaceViaPicker('nova')
-
-  // The pick connected the workspace: full Session+Agent exists, composer live.
-  const composer = await findHeroComposer()
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() })
-  expect(within(tree).getByText('New Session')).toBeDefined()
-  const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
-  if (group === null) throw new Error('created Workspace projection missing')
-
-  expect({
-    composerDisabled: composer.disabled,
-    chip: visibleText(workspaceChip()),
-    workspace: visibleText(group),
-  }).toMatchInlineSnapshot(`
-    {
-      "chip": "nova",
-      "composerDisabled": false,
-      "workspace": "nova1 session",
-    }
-  `)
-})
-
-it('New Session reuses the Workspace blank session and converts the single visible row', async () => {
-  boot('?fixture=empty')
-
-  await findLockedComposer()
-  await createWorkspaceViaPicker('nova')
-  await findHeroComposer()
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
-
-  // New Session resolves through the recent Workspace and reuses its blank
-  // session in place: no locked interlude, no second entity.
-  const newSessionButton = screen.getAllByRole('button', { name: 'New session' })
-    .find(button => visibleText(button) === 'New Session')
-  if (newSessionButton === undefined) throw new Error('New Session button missing')
-  fireEvent.click(newSessionButton)
-  const composer = await findHeroComposer()
-  await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
-
-  setComposerText(composer, 'first light')
-  fireEvent.keyDown(composer, { key: 'Enter' })
-
-  // Conversion: the accepted prompt flips blank without adding a second row.
-  await screen.findByText('first light', { exact: true }, { timeout: 10_000 })
-  await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
-  const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
-  if (group === null) throw new Error('converted Session projection missing')
-
-  expect({
-    workspace: visibleText(group),
-    promptVisible: screen.getByText('first light', { exact: true }).textContent,
-  }).toMatchInlineSnapshot(`
-    {
-      "promptVisible": "first light",
-      "workspace": "nova1 session",
-    }
-  `)
-})
-
-it('a failed Workspace attach recovers by reusing the published blank session', async () => {
-  boot('?fixture&fixtureAttach=fail')
-
-  // The rejected startup connect surfaces the locked view state first: the
-  // failure leaves no client-side transaction state to unwind.
-  await findLockedComposer()
-
-  // The host published the session before rejecting attachment (blank, with
-  // the workspace cwd), so the next connect — retry or manual pick — reuses
-  // it instead of minting a duplicate, and the hero opens on it.
-  await pickWorkspace('fixture')
-  const composer = await findHeroComposer()
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  const group = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
-  if (group === null) throw new Error('fixture Workspace projection missing')
-
-  expect({
-    headline: visibleText(screen.getByText("Let's start building")),
-    composerDisabled: composer.disabled,
-    chip: visibleText(workspaceChip()),
-    workspace: visibleText(group),
-  }).toMatchInlineSnapshot(`
-    {
-      "chip": "fixture",
-      "composerDisabled": false,
-      "headline": "Let's start building",
-      "workspace": "fixture3 sessions",
-    }
-  `)
-})
-
-it('a rejected first prompt keeps the session blank and the draft in the machine', async () => {
-  boot('?fixture=empty&fixturePrompt=reject')
-
-  await findLockedComposer()
-  await createWorkspaceViaPicker('nova')
-  const composer = await findHeroComposer()
-
-  setComposerText(composer, 'do not lose this')
-  fireEvent.click(screen.getByRole('button', { name: 'Send message' }))
-
-  const alert = await screen.findByRole('alert', {}, { timeout: 10_000 })
-  // Failure restore rides the machine (no pendingPrompt transaction): the
-  // draft returns to the same resident textarea one render later. The
-  // attempt flips the composer out of the hero (engaging = retry chrome),
-  // but acceptance never happened: the session row stays New Session.
-  const retained = await screen.findByDisplayValue('do not lose this')
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  const group = within(tree).getByText('1 session').closest('[role="treeitem"]')
-  if (group === null) throw new Error('rejected-send Workspace projection missing')
-
-  expect({
-    error: visibleText(alert),
-    prompt: (retained as HTMLTextAreaElement).value,
-    blankRow: within(tree).getByText('New Session').textContent,
-    workspace: visibleText(group),
-  }).toMatchInlineSnapshot(`
-    {
-      "blankRow": "New Session",
-      "error": "fixture: prompt rejected before acceptance (agent-busy)",
-      "prompt": "do not lose this",
-      "workspace": "nova1 session",
-    }
-  `)
-})
-
-it('switching Workspace before the first message carries the draft to the new blank session', async () => {
-  boot('?fixture')
-
-  const composer = await findHeroComposer()
-  setComposerText(composer, 'carry me')
-
-  // Switch = session switch: the new workspace's blank session takes over,
-  // the typed draft moves machine-to-machine, the old blank stays hidden.
-  await createWorkspaceViaPicker('nova')
-  await waitFor(() => { expect(visibleText(workspaceChip())).toBe('nova') }, { timeout: 10_000 })
-  const carried = await screen.findByDisplayValue('carry me')
-  const tree = screen.getByRole('tree', { name: 'Sessions' })
-  const fixtureGroup = within(tree).getByText('3 sessions').closest('[role="treeitem"]')
-  const novaGroup = within(tree).getByText('1 session').closest('[role="treeitem"]')
-  if (fixtureGroup === null || novaGroup === null) throw new Error('Workspace projections missing after switch')
-
-  expect({
-    chip: visibleText(workspaceChip()),
-    prompt: (carried as HTMLTextAreaElement).value,
-    fixtureWorkspace: visibleText(fixtureGroup),
-    novaWorkspace: visibleText(novaGroup),
-  }).toMatchInlineSnapshot(`
-    {
-      "chip": "nova",
-      "fixtureWorkspace": "fixture3 sessions",
-      "novaWorkspace": "nova1 session",
-      "prompt": "carry me",
-    }
-  `)
-})
diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts
index fd18d89087..ff9d458280 100644
--- a/apps/web/tests/workspace-management.e2e.ts
+++ b/apps/web/tests/workspace-management.e2e.ts
@@ -13,8 +13,8 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import {
-  acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole,
-  webSnapshotMode, type WebScaffold,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { saveFailureShot } from './support.ts'
 
@@ -23,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', i
 // spec needs any one cold session row, not new recorded content.
 const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
 const MODE = webSnapshotMode()
+const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
 const SEED_ID = 'workspace-management-web-e2e'
 
 describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
@@ -30,14 +31,40 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
   let browser: Browser
   let page: Page
   let tripwire: ReturnType
-  let pickedDirectory: string | null = null
+
+  /**
+   * Drive the in-app browser to a directory via its path-edit affordance,
+   * confirm it, and wait for the adoption to settle host-side (workspace
+   * registered + the flow's New-Session agent up), so later test steps can't
+   * race the in-flight blank-session attach.
+   */
+  async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise {
+    const agentsBefore = scaffold.ctx.agents.list().length
+    await page.getByRole('button', { name: 'Create workspace' }).click()
+    await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+    const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
+    await dialog.waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: '编辑路径' }).click()
+    await dialog.getByLabel('编辑路径').fill(path)
+    await dialog.getByLabel('编辑路径').press('Enter')
+    await dialog.getByRole('button', { name: '打开' }).click()
+    await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
+    await expect.poll(
+      () => scaffold.ctx.workspace.resolveByPath(path),
+      { timeout: 10_000 },
+    ).not.toBeUndefined()
+    // First adoption births a blank Session+Agent whose workspace attach must
+    // settle before a test may delete the registration; the reuse path (same
+    // canonical cwd already has a blank session) creates no agent, so callers
+    // opt in only where a fresh attach is possible.
+    if (options.waitForAgent === true) {
+      await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
+        .toBeGreaterThan(agentsBefore)
+    }
+  }
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
-    scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({
-      rpcId: request.rpcId,
-      result: { ok: true, value: { path: pickedDirectory } },
-    })
     // Seed one cold session (Ungrouped bucket) for the flat view + hover card.
     const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
     await mkdir(sessionCwd, { recursive: true })
@@ -137,14 +164,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
       collect()
     })
     // Register the scaffold's existing project directory through the real UI.
-    pickedDirectory = scaffold.workspaceCwd
-    await page.getByRole('button', { name: 'Create workspace' }).click()
-    await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
-
-    await expect.poll(
-      () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
-      { timeout: 10_000 },
-    ).not.toBeUndefined()
+    await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true })
     const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
     if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
     await workspace.attachSession(SessionId(SEED_ID))
@@ -200,9 +220,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
     // Re-registering the exact deleted path immediately, without a reload, is
     // a supported reversible flow. It creates a fresh Workspace id without
     // re-adopting the retained Session.
-    pickedDirectory = scaffold.workspaceCwd
-    await page.getByRole('button', { name: 'Create workspace' }).click()
-    await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+    await openLocalFolder(scaffold.workspaceCwd)
     await expect.poll(
       () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
       { timeout: 10_000 },
@@ -272,9 +290,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
       collect()
     })
 
-    pickedDirectory = oldPath
-    await page.getByRole('button', { name: 'Create workspace' }).click()
-    await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+    await openLocalFolder(oldPath)
     await expect.poll(
       () => scaffold.ctx.workspace.resolveByPath(oldPath),
       { timeout: 10_000 },
@@ -330,6 +346,42 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
 
+  it('matches the directory-browser dialog aria golden at a staged directory', async () => {
+    // A staged subtree under the scaffold cwd keeps the listing deterministic
+    // (normalizeAria scrubs the cwd), and pointing the in-process host's HOME
+    // at the cwd collapses the breadcrumb ancestry into the Home crumb — no
+    // machine-specific path segments or real $HOME contents enter the golden.
+    const staged = join(scaffold.workspaceCwd, 'browse-golden')
+    await mkdir(join(staged, 'alpha'), { recursive: true })
+    await mkdir(join(staged, 'beta'), { recursive: true })
+    // homedir() reads HOME on POSIX and USERPROFILE on Windows: root both
+    // at the scaffold cwd so the golden's ancestry collapses everywhere.
+    const realHome = process.env.HOME
+    const realUserProfile = process.env.USERPROFILE
+    process.env.HOME = scaffold.workspaceCwd
+    process.env.USERPROFILE = scaffold.workspaceCwd
+    try {
+      await page.getByRole('button', { name: 'Create workspace' }).click()
+      await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+      const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
+      await dialog.waitFor({ timeout: 10_000 })
+      await dialog.getByRole('button', { name: '编辑路径' }).click()
+      await dialog.getByLabel('编辑路径').fill(staged)
+      await dialog.getByLabel('编辑路径').press('Enter')
+      await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+      const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)
+      await dialog.getByRole('button', { name: '取消' }).click()
+      await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
+    } finally {
+      if (realHome === undefined) delete process.env.HOME
+      else process.env.HOME = realHome
+      if (realUserProfile === undefined) delete process.env.USERPROFILE
+      else process.env.USERPROFILE = realUserProfile
+    }
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it('shows the session hover card after a dwell on the row', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
     // Expand Ungrouped to reveal the seeded session row, then dwell on it
@@ -361,8 +413,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
 
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
     expect(tripwire.warnings).toEqual([])
-    // This spec mints no fixture directory contents of its own; the seed it
-    // reuses is owned (and inventory-guarded) by seeded-history.
-    await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep'])
+    // The directory-browser aria golden is this spec's one owned artifact;
+    // the seed it reuses is owned (and inventory-guarded) by seeded-history.
+    await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md'])
   })
 })
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 1b0f807d5f..6bc412bbe9 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -28,13 +28,16 @@
     "tests/steering.e2e.ts",
     "tests/navigation-panes.e2e.ts",
     "tests/lifecycle-chrome.e2e.ts",
+    "tests/details-session-lifecycle.e2e.ts",
     "tests/settings-chrome.e2e.ts",
     "tests/workspace-management.e2e.ts",
     "tests/replay-round-trip.e2e.ts",
     "tests/seeded-history.e2e.ts",
     "tests/sidebar-scrollbar.e2e.ts",
     "tests/code-mode-round.e2e.ts",
-    "tests/cordis-tool-round.e2e.ts"
+    "tests/cordis-tool-round.e2e.ts",
+    "tests/message-actions.e2e.ts",
+    "tests/skill-invocation-policy.e2e.ts"
   ],
   "references": [
     {
diff --git a/bin/dsh b/bin/dsh
index 040cfface2..f85f28a5cd 100755
--- a/bin/dsh
+++ b/bin/dsh
@@ -1,7 +1,7 @@
 #!/bin/sh
-# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE with this checkout's
-# tsx, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the
-# current working tree — code changes apply on the next launch, no build step.
+# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through the tsx ESM
+# hook, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the
+# current working tree without a build step.
 set -eu
 
 # Resolve symlink chains without readlink -f (not on every macOS).
@@ -15,7 +15,11 @@ while [ -L "$script" ]; do
 done
 root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd)
 
-# tsx is imported by absolute path because bare `--import tsx` resolves from
-# the invoking cwd, which is usually outside this repository.
-export TSX_TSCONFIG_PATH="$root/tsconfig.json"
-exec node --import "$root/node_modules/tsx/dist/loader.mjs" "$root/apps/cli/src/bin.ts" "$@"
+# The ESM-only tsx hook transforms TypeScript and projects this checkout's
+# tsconfig paths into Node resolution (the CJS hook stays off: the graph is
+# ESM-only and the CJS resolver costs ~0.4s of startup). Absolute paths keep
+# both the hook and the tsconfig anchored to this checkout when the launcher
+# runs from any cwd, where bare `tsx/esm` would not resolve.
+TSX_TSCONFIG_PATH="$root/tsconfig.json" \
+  exec node --import "$root/node_modules/tsx/dist/esm/index.mjs" \
+  "$root/apps/cli/src/bin.ts" "$@"
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index e56a3f91bb..6cbb040158 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: 054985ac5ea32a44b9daca3c1abfd58dcdc5d897
-architecture.zh.md: 84876faf2ae27069ba8bd026bcfbc56e32f65574
+architecture.md: 1fe5c1dfa4aee8c3bfe5ac634f47bb68f36afe9f
+architecture.zh.md: d754c6a2ea5bcd38524d31d02bc4f38ca2074942
diff --git a/docs/architecture.md b/docs/architecture.md
index 054985ac5e..1fe5c1dfa4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -25,27 +25,29 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services,
 
 | ctx key | Package family | Role |
 |---|---|---|
-| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls |
-| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request and surface pressure |
+| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry, streaming model calls |
+| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | replay-aware request and surface pressure |
 | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution |
-| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for the bash executors, the LSP host, and the ACP subagent backend |
+| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for bash, LSP, and ACP subagent backends |
 | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions |
 | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement through argv wrapping and per-call policy |
 | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home |
 | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution |
 | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events |
 | `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | semantic navigation registry |
-| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure |
+| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure |
 | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
-| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction and optional model-free result pruning |
+| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning |
 | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers |
 | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state |
-| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry and generic `task_*` controls |
+| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls |
 | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
 | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals |
 | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage |
-| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace interface, SQLite FTS backend, workspace-authorized model tools |
-| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks and one optional asynchronous provider |
+| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools |
+| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks, one optional asynchronous provider |
+| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) |
+| `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas |
 | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks |
 
 ## Event
@@ -96,10 +98,10 @@ forever:
       'assistant/chunk'
       'assistant/message'
       schedule tool calls by ctx.tools.executionMode:
-        exclusive -> one-call barrier
-        parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start
-        each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
-        each model-order result -> ordered tools/post-execute -> 'tool/result'
+        exclusive -> barrier
+        parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches
+        start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
+        model-order result -> ordered tools/post-execute -> 'tool/result'
       drain accepted tool context and steering
       'step/end'
       continue for tools or steering unless a result concluded the turn
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 84876faf2a..d754c6a2ea 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -28,7 +28,7 @@
 | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 |
 | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力与表面压力 |
 | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 |
-| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash 执行器、LSP host 与 ACP subagent 后端使用的受管子进程树 |
+| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash、LSP 与 ACP subagent 后端使用的受管子进程树 |
 | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 |
 | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 通过 argv 包装和逐调用策略限制同一执行环境内的进程 |
 | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 |
@@ -44,8 +44,10 @@
 | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 |
 | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 |
 | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 |
-| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索/过滤/追踪接口、SQLite 全文搜索后端、经工作区授权的模型工具 |
+| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 |
 | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 |
+| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) |
+| `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 |
 | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 |
 
 ## 事件
@@ -96,10 +98,10 @@ forever:
       'assistant/chunk'
       'assistant/message'
       schedule tool calls by ctx.tools.executionMode:
-        exclusive -> one-call barrier
-        parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start
-        each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
-        each model-order result -> ordered tools/post-execute -> 'tool/result'
+        exclusive -> barrier
+        parallel -> rolling pool, <= maxParallelToolCalls; reclassify-at-start; scheduler failure -> stop starts, drain dispatches
+        start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
+        model-order result -> ordered tools/post-execute -> 'tool/result'
       drain accepted tool context and steering
       'step/end'
       continue for tools or steering unless a result concluded the turn
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 87efb87c87..65c0ed1842 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -29,6 +29,9 @@ flowchart LR
   pkg_invariants["invariants"]
   svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] + pkg_typert_registry["typert-registry"] + svc_typert["ctx.typert
Runtime type registry"] + pkg_typert_loader["typert-loader"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] @@ -77,6 +80,8 @@ flowchart LR pkg_session_projection["session-projection"] svc_sessionProjections["ctx.sessionProjections
Session projection units"] pkg_host_apiproxy["host-apiproxy"] + pkg_session_projection_cache["session-projection-cache"] + svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] svc_tui["ctx.tui
Mounted-terminal interaction service"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] @@ -139,6 +144,10 @@ flowchart LR svc_spillStore["ctx.spillStore
Spill storage seam"] pkg_spill_local["spill-local"] pkg_spill_policy["spill-policy"] + pkg_directory_picker["directory-picker"] + svc_directoryPicker["ctx.directoryPicker
Workspace-directory picking seam"] + pkg_directory_picker_native["directory-picker-native"] + pkg_directory_picker_browse["directory-picker-browse"] pkg_webserver["webserver"] svc_httpServer["ctx.httpServer
HTTP route registration"] pkg_connection["connection"] @@ -162,6 +171,9 @@ flowchart LR pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_compact_tool_result_prune --> svc_toolResultPrune + pkg_directory_picker --> svc_directoryPicker + pkg_directory_picker_browse --> svc_directoryPicker + pkg_directory_picker_native --> svc_directoryPicker pkg_fs --> svc_fs pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs @@ -184,6 +196,7 @@ flowchart LR pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence pkg_session_projection --> svc_sessionProjections + pkg_session_projection_cache --> svc_sessionProjectionCache pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences @@ -214,6 +227,7 @@ flowchart LR pkg_tools --> svc_tools pkg_tui --> svc_tui pkg_tui --> svc_userInteraction + pkg_typert_registry --> svc_typert pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web pkg_web_fetch_local --> svc_web @@ -239,6 +253,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_commands --> pkg_tui svc_compact --> pkg_compact_basic + svc_directoryPicker --> pkg_apiproxy svc_fs --> pkg_tool_fs svc_httpServer --> pkg_connection svc_httpServer --> pkg_hmr @@ -261,6 +276,7 @@ flowchart LR svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash + svc_sessionProjectionCache --> pkg_host_apiproxy svc_sessionProjections --> pkg_host_apiproxy svc_sessionProjections --> pkg_session_title svc_sessionProjections --> pkg_tool_todo @@ -306,6 +322,7 @@ flowchart LR svc_tools --> pkg_tool_subagent svc_tools --> pkg_tool_todo svc_tools --> pkg_tool_web + svc_typert --> pkg_typert_loader svc_userInteraction --> pkg_tool_ask_user svc_userInteraction --> pkg_tui svc_web --> pkg_tool_web @@ -322,6 +339,7 @@ flowchart LR | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | +| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | @@ -336,6 +354,7 @@ flowchart LR | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui) | - | Plugins register direct human commands; TUI consumes the effective per-agent catalog without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | +| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | @@ -356,6 +375,7 @@ flowchart LR | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | +| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | | `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 676054c215..191d96b255 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -270,6 +270,27 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts) +## `@deepseek-ai/dsh-client-connection` + +Requires: `httpServer` · `apiProxy` + +```ts config-catalog +/** Plugin config: the deployment's non-loopback serving authorities. */ +export interface ConnectionConfig { + /** + * Authorities this deployment serves beyond loopback: exact `host:port`, or + * port-less `host` matching any port. The /api trust fence refuses any + * request whose Host is neither loopback nor listed here, so a + * non-loopback (`0.0.0.0`) deployment must declare the names it is reached + * by (the dsh CLI derives the machine's LAN IP literals itself). An entry + * that is not a bare, canonical authority fails the plugin load. + */ + trustedHosts?: string[] +} +``` + +Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) + ## `@deepseek-ai/dsh-client-hmr` Requires: `clientModuleHost` · `httpServer` @@ -421,7 +442,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:56`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:118`](../packages/goal/goal/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -486,7 +507,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agents` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace` +Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace` ```ts config-catalog /** Gateway plugin config: host-level agent routing and Workspace creation root. */ @@ -502,6 +523,18 @@ export interface Config { Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) +## `@deepseek-ai/dsh-host-directory-picker-browse` + +```ts config-catalog +/** Validated plugin configuration. */ +export interface Config { + /** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */ + maxEntries: number +} +``` + +Source: [`packages/host/directory-picker-browse/src/index.ts:181`](../packages/host/directory-picker-browse/src/index.ts) + ## `@deepseek-ai/dsh-host-webserver` ```ts config-catalog @@ -832,7 +865,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/ui/permission/src/index.ts:83`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:130`](../packages/ui/permission/src/index.ts) ## `@deepseek-ai/dsh-plan-mode` @@ -846,7 +879,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:58`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:68`](../packages/plan/plan-mode/src/index.ts) ## `@deepseek-ai/dsh-pty-local` @@ -1048,6 +1081,27 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +## `@deepseek-ai/dsh-session-projection-cache` + +Requires: `storageDomain` · `sessionProjections` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** + * Plugin config. Both throttle triggers are deployment choices with no + * universally correct value, so the composition states them explicitly + * (cordis.yml); the two mandatory write points (`turn/end` and session + * disposal) are policy, not tunables, and always fire. + */ +export interface Config { + /** Committed events per session that force a durable checkpoint write between mandatory points. */ + writeEveryEvents: number + /** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */ + writeIntervalMs: number +} +``` + +Source: [`packages/session-projection/session-projection-cache/src/index.ts:42`](../packages/session-projection/session-projection-cache/src/index.ts) + ## `@deepseek-ai/dsh-session-query-sqlite` Requires: `sessions` @@ -1149,7 +1203,7 @@ export interface Config { } ``` -Source: [`packages/session-title/session-title/src/index.ts:75`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:79`](../packages/session-title/session-title/src/index.ts) ## `@deepseek-ai/dsh-session-title-all-messages-llm` @@ -1187,7 +1241,7 @@ export interface Config { } ``` -Source: [`packages/skill/skill/src/index.ts:113`](../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:170`](../packages/skill/skill/src/index.ts) ## `@deepseek-ai/dsh-skill-local` @@ -1202,12 +1256,24 @@ export interface Config { agentsHome?: string /** Additional skill roots scanned after project roots and before user roots. */ customSkillDirs?: string[] + /** Whether host-local skill roots are watched for catalog changes. */ + watch?: boolean + /** Whether Chokidar uses polling instead of native filesystem events. */ + watchUsePolling?: boolean + /** Milliseconds a changed skill entry must remain stable before it is observed. */ + watchStabilityThresholdMs?: number + /** Milliseconds between Chokidar stability or polling probes. */ + watchPollIntervalMs?: number + /** Maximum distinct project roots whose skill directories remain watched. */ + watchMaxProjects?: number + /** Whether watched symbolic links follow their target files. */ + watchFollowSymlinks?: boolean /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ bundledSkillDir?: string } ``` -Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts) +Source: [`packages/skill/skill-local/src/index.ts:49`](../packages/skill/skill-local/src/index.ts) ## `@deepseek-ai/dsh-spill-local` @@ -1487,6 +1553,20 @@ export interface Config { Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) +## `@deepseek-ai/dsh-tmux-context` + +Requires: `agents` + +```ts config-catalog +/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ +export interface Config { + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ + refreshIntervalMs?: number +} +``` + +Source: [`packages/context/tmux-context/src/index.ts:34`](../packages/context/tmux-context/src/index.ts) + ## `@deepseek-ai/dsh-token-meter` ```ts config-catalog @@ -1658,7 +1738,7 @@ Source: [`packages/session-query/tool-session-query/src/index.ts:29`](../package ## `@deepseek-ai/dsh-tool-skill` -Requires: `tools` · `skills` +Requires: `agents` · `tools` · `skills` ```ts config-catalog /** Model-facing skill catalog configuration. */ @@ -1668,7 +1748,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:21`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:30`](../packages/skill/tool-skill/src/index.ts) ## `@deepseek-ai/dsh-tool-subagent` @@ -1824,12 +1904,12 @@ export interface Config extends TuiConfig { /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ sessionId?: string /** - * Shell command fallback printed on exit or after selecting a session when - * the host cannot hand off in place. Every `{session}` becomes the selected - * id; the TUI never executes this text. Absent disables only the fallback, - * not the interactive selector. + * Skill name auto-invoked as this session's first user turn, exactly as if + * the user typed `/skill:`. Set only by a launcher for a fresh + * skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first + * turn to the user. */ - resumeCommand?: string + initialSkill?: string } /** Interaction and presentation settings for the pi-tui terminal mode. */ @@ -1942,6 +2022,20 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) +## `@deepseek-ai/dsh-typert-loader` + +Requires: `typert` · `loader` + +```ts config-catalog +/** Additional package artifacts whose owning plugins are nested behind another Loader entry. */ +export interface Config { + /** Exact npm package names that must resolve and export `./typert`. */ + packages?: string[] +} +``` + +Source: [`packages/typert/loader/src/index.ts:47`](../packages/typert/loader/src/index.ts) + ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog @@ -1971,7 +2065,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:198`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:202`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` @@ -2145,15 +2239,17 @@ Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) @@ -2168,6 +2264,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) @@ -2181,6 +2278,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)) +- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) - `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts)) - `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts)) @@ -2192,6 +2290,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) @@ -2209,6 +2308,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts)) - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) +- `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) @@ -2218,6 +2318,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) +- `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) @@ -2229,3 +2330,4 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) +- `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) diff --git a/docs/cordis-catalog/core/context.md b/docs/cordis-catalog/core/context.md index f6b249c738..c0c1690c8f 100644 --- a/docs/cordis-catalog/core/context.md +++ b/docs/cordis-catalog/core/context.md @@ -256,7 +256,7 @@ Read a service from the store without the inject requirement. **Returns** the service value, or `undefined` when not (yet) provided. -[Source](../../../vendor/cordis/src/reflect.ts#L16) +[Source](../../../vendor/cordis/src/reflect.ts#L17) ### ctx.set(name, value) @@ -281,7 +281,7 @@ Only the fiber that provided the service may set it; setting an unprovided name - `name` — the service name. - `value` — the new service value. -[Source](../../../vendor/cordis/src/reflect.ts#L28) +[Source](../../../vendor/cordis/src/reflect.ts#L29) ### ctx.provide(name, value) @@ -311,7 +311,7 @@ The service becomes visible to dependents in the same isolation scope once the f **Returns** a disposer that unregisters the service. -[Source](../../../vendor/cordis/src/reflect.ts#L43) +[Source](../../../vendor/cordis/src/reflect.ts#L44) ### ctx.accessor(name, options) @@ -335,7 +335,7 @@ The accessor is removed when the current fiber unloads. Throws if the name is al - `name` — the context property name. - `options` — the `get` hook and optional `set` hook. -[Source](../../../vendor/cordis/src/reflect.ts#L55) +[Source](../../../vendor/cordis/src/reflect.ts#L56) ### ctx.mixin(name, mixins) @@ -361,4 +361,4 @@ Each mixed-in key becomes an accessor that forwards to the service (binding meth - `name` — the context property holding the source service. - `mixins` — keys to forward, or a source-key → ctx-key map. -[Source](../../../vendor/cordis/src/reflect.ts#L66) +[Source](../../../vendor/cordis/src/reflect.ts#L67) diff --git a/docs/cordis-catalog/core/events.md b/docs/cordis-catalog/core/events.md index 2fb64e78a2..68cd109e8e 100644 --- a/docs/cordis-catalog/core/events.md +++ b/docs/cordis-catalog/core/events.md @@ -26,7 +26,7 @@ Dispatch an event, running all listeners concurrently. **Returns** a promise resolving once every listener has settled. -[Source](../../../vendor/cordis/src/events.ts#L43) +[Source](../../../vendor/cordis/src/events.ts#L44) ### ctx.emit(name, ...args) @@ -46,7 +46,7 @@ Dispatch an event synchronously, ignoring listener return values. - `name` — the event name. - `args` — arguments passed to every listener. -[Source](../../../vendor/cordis/src/events.ts#L52) +[Source](../../../vendor/cordis/src/events.ts#L53) ### ctx.serial(name, ...args) @@ -69,7 +69,7 @@ Dispatch an event, awaiting listeners in order until one bails. **Returns** the first bail value (non-null, non-false, non-undefined), if any. -[Source](../../../vendor/cordis/src/events.ts#L62) +[Source](../../../vendor/cordis/src/events.ts#L63) ### ctx.bail(name, ...args) @@ -92,7 +92,7 @@ Dispatch an event, calling listeners in order until one bails. **Returns** the first bail value (non-null, non-false, non-undefined), if any. -[Source](../../../vendor/cordis/src/events.ts#L72) +[Source](../../../vendor/cordis/src/events.ts#L73) ### ctx.waterfall(name, ...args) @@ -120,7 +120,7 @@ Each listener wraps the rest of the chain: calling `next()` invokes the next lis **Returns** the outermost listener's return value. -[Source](../../../vendor/cordis/src/events.ts#L85) +[Source](../../../vendor/cordis/src/events.ts#L86) ### ctx.on(name, listener, options?) @@ -144,7 +144,7 @@ Register an event listener owned by the current fiber. **Returns** a disposer removing the listener; `true` if it was still registered. -[Source](../../../vendor/cordis/src/events.ts#L96) +[Source](../../../vendor/cordis/src/events.ts#L97) ### ctx.once(name, listener, options?) @@ -168,7 +168,7 @@ Same as `on()`, but the listener disposes itself after its first call. **Returns** a disposer removing the listener; `true` if it was still registered. -[Source](../../../vendor/cordis/src/events.ts#L105) +[Source](../../../vendor/cordis/src/events.ts#L106) ## EventOptions @@ -184,7 +184,7 @@ interface EventOptions { } ``` -[Source](../../../vendor/cordis/src/events.ts#L111) +[Source](../../../vendor/cordis/src/events.ts#L112) ## DispatchMode @@ -204,4 +204,4 @@ Event dispatch strategy used by the event service. type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' ``` -[Source](../../../vendor/cordis/src/events.ts#L31) +[Source](../../../vendor/cordis/src/events.ts#L32) diff --git a/docs/cordis-catalog/core/fiber.md b/docs/cordis-catalog/core/fiber.md index d865ce01fc..3cca4e8b86 100644 --- a/docs/cordis-catalog/core/fiber.md +++ b/docs/cordis-catalog/core/fiber.md @@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../../vendor/cordis/src/fiber.ts#L419) +[Source](../../../vendor/cordis/src/fiber.ts#L420) ### ctx.fiber @@ -45,7 +45,7 @@ fiber: Fiber The fiber (plugin runtime instance) that owns this context. -[Source](../../../vendor/cordis/src/fiber.ts#L11) +[Source](../../../vendor/cordis/src/fiber.ts#L12) ## The Fiber class @@ -53,7 +53,7 @@ Runtime instance of one plugin application. A fiber tracks dependency state, validated config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`. -[Source](../../../vendor/cordis/src/fiber.ts#L183) +[Source](../../../vendor/cordis/src/fiber.ts#L184) ### fiber.uid @@ -64,7 +64,7 @@ public uid: number | null Unique id within the registry; 0 for the root fiber, `null` once disposed. -[Source](../../../vendor/cordis/src/fiber.ts#L185) +[Source](../../../vendor/cordis/src/fiber.ts#L186) ### fiber.ctx @@ -75,7 +75,7 @@ public readonly ctx: Context The context this fiber's plugin runs in (extends the parent context). -[Source](../../../vendor/cordis/src/fiber.ts#L187) +[Source](../../../vendor/cordis/src/fiber.ts#L188) ### fiber.config @@ -86,7 +86,7 @@ public config: any The validated plugin config (updated by `update()`). -[Source](../../../vendor/cordis/src/fiber.ts#L189) +[Source](../../../vendor/cordis/src/fiber.ts#L190) ### fiber.state @@ -97,7 +97,7 @@ public state Current lifecycle state; transitions emit `internal/status`. -[Source](../../../vendor/cordis/src/fiber.ts#L191) +[Source](../../../vendor/cordis/src/fiber.ts#L192) ### fiber.dispose @@ -108,7 +108,7 @@ public readonly dispose: () => Promise Dispose this fiber: unload the plugin, then settle once cleanup finished. -[Source](../../../vendor/cordis/src/fiber.ts#L193) +[Source](../../../vendor/cordis/src/fiber.ts#L194) ### fiber.store @@ -119,7 +119,7 @@ public store: Dict | undefined Snapshot of required service implementations while loaded; `undefined` otherwise. -[Source](../../../vendor/cordis/src/fiber.ts#L195) +[Source](../../../vendor/cordis/src/fiber.ts#L196) ### fiber.inertia @@ -130,7 +130,7 @@ public inertia: Promise | undefined The in-flight load/unload transition, if one is currently running. -[Source](../../../vendor/cordis/src/fiber.ts#L197) +[Source](../../../vendor/cordis/src/fiber.ts#L198) ### fiber.name @@ -141,7 +141,7 @@ get name() The plugin's display name, inherited from the nearest named ancestor, else `'root'`. -[Source](../../../vendor/cordis/src/fiber.ts#L340) +[Source](../../../vendor/cordis/src/fiber.ts#L341) ### fiber.assertActive() @@ -159,7 +159,7 @@ Throw if the fiber has already been disposed. **Returns** nothing when the fiber is still active. -[Source](../../../vendor/cordis/src/fiber.ts#L355) +[Source](../../../vendor/cordis/src/fiber.ts#L356) ### fiber.effect(execute, label?) @@ -190,7 +190,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../../vendor/cordis/src/fiber.ts#L419) +[Source](../../../vendor/cordis/src/fiber.ts#L420) ### fiber.getEffects() @@ -207,7 +207,7 @@ Return metadata for currently registered effects. **Returns** one `EffectMeta` tree per labeled live effect. -[Source](../../../vendor/cordis/src/fiber.ts#L572) +[Source](../../../vendor/cordis/src/fiber.ts#L573) ### fiber.await() @@ -225,7 +225,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../../vendor/cordis/src/fiber.ts#L701) +[Source](../../../vendor/cordis/src/fiber.ts#L702) ### fiber.restart() @@ -243,7 +243,7 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../../vendor/cordis/src/fiber.ts#L715) +[Source](../../../vendor/cordis/src/fiber.ts#L716) ### fiber.update(config, noSave?) @@ -271,7 +271,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** nothing; the restart runs behind the `internal/update` waterfall. -[Source](../../../vendor/cordis/src/fiber.ts#L733) +[Source](../../../vendor/cordis/src/fiber.ts#L734) ## Effect @@ -292,7 +292,7 @@ type Effect = | AsyncEffect ``` -[Source](../../../vendor/cordis/src/fiber.ts#L82) +[Source](../../../vendor/cordis/src/fiber.ts#L83) ## Disposable @@ -310,7 +310,7 @@ Disposers run in reverse registration order when the owning fiber unloads; they type Disposable = () => T ``` -[Source](../../../vendor/cordis/src/fiber.ts#L73) +[Source](../../../vendor/cordis/src/fiber.ts#L74) ## EffectMeta @@ -326,7 +326,7 @@ interface EffectMeta { } ``` -[Source](../../../vendor/cordis/src/fiber.ts#L95) +[Source](../../../vendor/cordis/src/fiber.ts#L96) ## CordisError @@ -352,7 +352,7 @@ namespace CordisError { } ``` -[Source](../../../vendor/cordis/src/fiber.ts#L156) +[Source](../../../vendor/cordis/src/fiber.ts#L157) ## ValidationError @@ -372,4 +372,4 @@ class ValidationError extends TypeError { } ``` -[Source](../../../vendor/cordis/src/fiber.ts#L18) +[Source](../../../vendor/cordis/src/fiber.ts#L19) diff --git a/docs/cordis-catalog/core/registry.md b/docs/cordis-catalog/core/registry.md index 2772dca723..6b2e298c72 100644 --- a/docs/cordis-catalog/core/registry.md +++ b/docs/cordis-catalog/core/registry.md @@ -30,7 +30,7 @@ Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback is unloade **Returns** the fiber; awaiting it settles once loading finished. -[Source](../../../vendor/cordis/src/registry.ts#L175) +[Source](../../../vendor/cordis/src/registry.ts#L176) ### ctx.plugin(plugin, ...args) @@ -53,7 +53,7 @@ Load a plugin in the current context. **Returns** the fiber; awaiting it settles once loading finished (rejecting on config or startup errors). -[Source](../../../vendor/cordis/src/registry.ts#L184) +[Source](../../../vendor/cordis/src/registry.ts#L185) ## Plugin @@ -118,7 +118,7 @@ namespace Plugin { } ``` -[Source](../../../vendor/cordis/src/registry.ts#L91) +[Source](../../../vendor/cordis/src/registry.ts#L92) ## Inject @@ -149,4 +149,4 @@ namespace Inject { } ``` -[Source](../../../vendor/cordis/src/registry.ts#L18) +[Source](../../../vendor/cordis/src/registry.ts#L19) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 62f5ddfeb2..3b6c3c1ac4 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -9,7 +9,7 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. The event-dispatch methods themselves are generated in the [Cordis core Events API](core/events.md). -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`), **bail** (synchronous in-order dispatch until one listener returns a bail value; the scoped input-mutation events use it for an applied/not-applied answer). +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../cordis-primer.md#cordis-waterfall-semantics)), **parallel** (awaited fan-out; all listeners run), **serial** (awaited in registration order until one returns a bail value — anything other than `null`, `false`, or `undefined`). ## `agent/*` @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:221`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:403`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -119,7 +119,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -142,7 +142,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -164,7 +164,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -187,7 +187,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -211,7 +211,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -241,7 +241,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:361`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -263,7 +263,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -288,7 +288,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:387`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:390`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -308,7 +308,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -332,7 +332,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:329`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -358,7 +358,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:376`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -524,7 +524,7 @@ Goal mutation accepted by one live agent. The matching context event is already Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/types.ts:169`](../../packages/goal/goal/src/types.ts) +Source: [`packages/goal/goal/src/domain.ts:135`](../../packages/goal/goal/src/domain.ts) ## `llm/*` @@ -642,74 +642,24 @@ Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-stru Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts) -## `slash/*` +## `skills/*` -### `slash/input-begin-command` — bail +### `skills/change` — emit -Applies one command claim to the scoped Input. Dispatched with the session's scope carrier; the owning session's input listener returns `true` only after the phase and span CAS checks pass and the machine actually mutated — producers treat anything else as "not applied". +A skill provider, runtime contribution, or provider-backed catalog may have changed. This is an unfiltered invalidation notification; consumers refetch the catalog for their own lookup options. Listener failures are contained and cannot veto the registry mutation. ```ts cordis-catalog /** - * Applies one command claim to the scoped Input. Dispatched with the - * session's scope carrier; the owning session's input listener returns - * `true` only after the phase and span CAS checks pass and the machine - * actually mutated — producers treat anything else as "not applied". - * @param request - Claim and menu-time span CAS. - * @mode bail + * A skill provider, runtime contribution, or provider-backed catalog may + * have changed. This is an unfiltered invalidation notification; consumers + * refetch the catalog for their own lookup options. Listener failures are + * contained and cannot veto the registry mutation. + * @mode emit */ -'slash/input-begin-command'(request: BeginCommandRequest): true | undefined +'skills/change'(): void ``` -Source: [`packages/client/ui-slash/src/types.ts:230`](../../packages/client/ui-slash/src/types.ts) - -### `slash/input-consume-token` — bail - -Consumes one command token after business success (popup settle / menu-pick execute). Same carrier routing and applied-truth contract. - -```ts cordis-catalog -/** - * Consumes one command token after business success (popup settle / - * menu-pick execute). Same carrier routing and applied-truth contract. - * @param request - Exact span or bare-token guard. - * @mode bail - */ -'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined -``` - -Source: [`packages/client/ui-slash/src/types.ts:244`](../../packages/client/ui-slash/src/types.ts) - -### `slash/input-insert-reference` — bail - -Inserts one reference into the scoped Input (same carrier routing and applied-truth contract as begin-command). - -```ts cordis-catalog -/** - * Inserts one reference into the scoped Input (same carrier routing and - * applied-truth contract as begin-command). - * @param request - Reference and menu-time span CAS. - * @mode bail - */ -'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined -``` - -Source: [`packages/client/ui-slash/src/types.ts:237`](../../packages/client/ui-slash/src/types.ts) - -### `slash/input-insert-text` — bail - -Replaces the trigger token span with literal text — the plain-text reference path (decision 21). Same carrier routing and applied-truth contract; the draft gains ordinary characters, no occurrence entry. - -```ts cordis-catalog -/** - * Replaces the trigger token span with literal text — the plain-text - * reference path (decision 21). Same carrier routing and applied-truth - * contract; the draft gains ordinary characters, no occurrence entry. - * @param request - Replacement text and menu-time span CAS. - * @mode bail - */ -'slash/input-insert-text'(request: InsertTextRequest): true | undefined -``` - -Source: [`packages/client/ui-slash/src/types.ts:252`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/skill/skill/src/index.ts:188`](../../packages/skill/skill/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ac556aaf08..93e7821075 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:220`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:215`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -242,11 +242,18 @@ Approval service that applies session policy before answerers and logs every ask * append commit point. */ async request(req: ApprovalRequest): Promise + +/** + * Read the session override without applying the configured default. + * @param session - session whose log supplies the override. + * @returns the last logged policy, or `undefined` without one. + */ +overrideOf(session: Session): ApprovalPolicy | undefined ``` -Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) +Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalPolicy](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [Session](../core-data-structures/session.md) -Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:217`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -481,6 +488,20 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts) +## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam) + +Abstract directory-picking service. Subclass, implement `capability()`, and load the subclass as a plugin — it registers as `ctx.directoryPicker` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). The capability object must be stable for the service lifetime: consumers may capture it across calls. + +```ts cordis-catalog +/** + * The backend's interaction capability. + * @returns the discriminated capability consumers switch on. + */ +abstract capability(): DirectoryPickerCapability +``` + +Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) + ## `ctx.fs` — `FileSystem` (abstract seam) Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. @@ -668,7 +689,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:135`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:197`](../../packages/goal/goal/src/index.ts) ## `ctx.httpServer` — `HttpServerService` @@ -815,6 +836,14 @@ Owns the deployment's permission presets and their write path. Requires a confin */ current(events: readonly SessionEvent[]): string +/** + * Build the whole select value for one folded knob state: every table + * option in declaration order, `custom` appended exactly while derived. + * @param state - the folded knob overrides. + * @returns the `permissions` projection payload. + */ +selectFor(state: KnobState): PermissionSelect + /** * Resolve a preset's knob bundle. * @param name - the preset name to resolve. @@ -843,7 +872,7 @@ set(session: Session, name: string): void Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:144`](../../packages/ui/permission/src/index.ts) ## `ctx.planMode` — `PlanModeService` @@ -859,18 +888,27 @@ Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/ get(agent: Agent): { active: boolean; pending?: boolean } /** - * Select whether plan mode should be active from the next request boundary. - * Repeated selection of the current or already-pending state is a no-op. + * Select whether plan mode should be active. Between turns the change + * commits immediately — no request boundary would arrive until the next + * prompt, so a queued intent would hang (the open-turn fold is the idle + * signal: agent status stays `running` through post-turn checkpointing, + * where a boundary equally never comes). During an open turn the + * selection is held as pending intent for the next in-turn request + * boundary. Repeated selection of the current or already-pending state is + * a no-op. * * @param agent The agent to switch. * @param active Whether plan mode should be active. + * @returns what happened: `committed` (logged now), `queued` (awaiting the + * next boundary), `cancelled` (an opposite pending selection was cleared; + * the logged state already matches), or `noop` (already in that state). */ -set(agent: Agent, active: boolean): void +set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/plan/plan-mode/src/index.ts:142`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:179`](../../packages/plan/plan-mode/src/index.ts) ## `ctx.pty` — `PtyService` @@ -940,7 +978,7 @@ signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise +async kill(owner: Agent, id: PtySessionId, reason: string = 'model request'): Promise /** * List fresh snapshots for exactly one owner. @@ -992,9 +1030,16 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * @returns the fully resolved per-call mode and absolute workspace root. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy + +/** + * Read the session override without applying the deployment default. + * @param session - session whose log supplies the override. + * @returns the last logged mode, or `undefined` without one. + */ +overrideOf(session: Session): SandboxMode | undefined ``` -Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) +Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxMode](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) · [Session](../core-data-structures/session.md) Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts) @@ -1042,7 +1087,9 @@ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise * A coordinator-backed cold load reserves the identity across storage awaits, * so concurrent publication of a same-id live Session rejects. * Returned events are detached, and every identified message is deeply - * frozen; malformed identified messages reject before any stored event is returned. + * frozen. Coordinator-backed implementations upgrade supported pre-identity + * message events before validation; other malformed messages reject before + * any stored event is returned. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ @@ -1052,14 +1099,34 @@ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEven * Inspect a header and its valid contiguous stored prefix without repairing * a torn tail, closing an interrupted turn, or publishing coordinator state. * This read is serialized with writes for the same id and returns detached - * values with deeply frozen identified messages, so observers cannot mutate message - * identity/content or backend-owned state. Malformed identified messages reject. + * values with upgraded, deeply frozen identified messages, so observers + * cannot mutate message identity/content or backend-owned state. Other + * malformed messages reject. * @param id - the persisted session to inspect. * @param signal - optional cancellation for queued and backend read work. * @returns the header and valid stored event prefix exactly as observed. */ abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +/** + * Read the stored events from `fromSeq` onward — the read-from-seq + * primitive for read models that resume from a watermark (e.g. a persisted + * projection cache folding only the tail past its checkpoint). Like + * {@link inspect} it is non-mutating and detached: no torn-tail truncation, + * no synthetic closers, no coordinator-state publication; only events from + * the valid contiguous stored prefix are returned, so a torn fragment never + * reaches the caller. `fromSeq` at or beyond the stored prefix returns an + * empty event list (never an error). Backends whose medium can seek by seq + * (SQLite) read only the suffix; sequential media (JSONL, both encodings) + * still parse the whole artifact and skip forward — the primitive bounds + * what is RETURNED and refolded, not every backend's physical read. + * @param id - the persisted session to read. + * @param fromSeq - first event seq to include; a non-negative safe integer. + * @param signal - optional cancellation for queued and backend read work. + * @returns the header and the stored events with `seq >= fromSeq`. + */ +abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** * Lightweight listing from metadata, without a full-log parse. * @param signal - optional cancellation for backend listing work. @@ -1084,6 +1151,54 @@ Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../cor Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) +## `ctx.sessionProjectionCache` — `SessionProjectionCache` + +The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read. + +```ts cordis-catalog +/** + * The zero-I/O listing read: whole values viewed straight from the stored + * rows (version-matching keys only), each cut carried with its watermark + * so a client value store can seed under its higher-seq-wins rule — as + * stale as the last durable checkpoint but never wrong, and never from an + * unrelated log (the caller's header is the identity witness). Fresher + * paths (the history tail baseline, {@link coldSnapshot}) supersede these + * values whenever a session is actually opened. + * @param meta - the listed session's header (identity witness; no log read). + * @returns the cut (`asOfSeq` = lowest served-row watermark), or + * `undefined` when no usable row exists for this lifecycle. + */ +cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined + +/** + * Durably checkpoint one live session NOW (both mandatory points call + * this; tests and carriers may too). The registry cut is snapshotted at + * this boundary (states are live references), then the whole record is + * replaced. NOT fail-soft — callers on the fail-soft paths contain it. + * @param session - the live session to checkpoint. + * @returns resolution after durability and event emission. + */ +async write(session: Session): Promise + +/** + * Cold-read one persisted session's projections with zero full-log load: + * cached rows + a persistence `readFrom` tail from the registry's restore + * floor, refolded by the registry and written back (fail-soft) so the next + * cold read starts closer. A cache row invalidated by a shrunk log + * (crash-repair truncation) triggers one full re-read from seq 0 — the + * ladder's slow rung, still no crash. Rejects when the session has no + * persisted log (`not found` from the persistence seam). + * @param id - the persisted session to read. + * @param signal - optional cancellation for the persistence reads. + * @returns the snapshot cut at the stored log end. + */ +async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise +``` + +Types: [Session](../core-data-structures/session.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) + +Source: [`packages/session-projection/session-projection-cache/src/index.ts:71`](../../packages/session-projection/session-projection-cache/src/index.ts) + ## `ctx.sessionProjections` — `SessionProjectionRegistry` `ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. @@ -1116,11 +1231,81 @@ onChanged(listener: ProjectionChangeListener): () => void * @returns the snapshot; `values` is empty when no unit is registered. */ snapshot(session: Session): ProjectionSnapshot + +/** + * State-level checkpoint of every registered unit for one session, read + * from the watermark cache (missing cells fold lazily over the in-memory + * log). This is the write side of the persisted projection cache: the + * returned rows are the `(key → {ver, seq, val})` part of the durable + * `(sessionId, key, ver, seq, val)` + * rows. Every `val` is a DETACHED structured clone — never the live + * cell reference: the watermark cache is this registry's authoritative + * mutable state, and a caller reaching the live reference could corrupt + * every subsequent snapshot and frame through it (plain JSON by the unit + * contract, so the clone is total). + * @param session - the session whose unit states are checkpointed. + * @returns one row per registered key; empty when no unit is registered. + */ +checkpoint(session: Session): ProjectionCheckpoint + +/** + * The stored seq a {@link restore} tail read over `checkpoint` must start + * at: one event BELOW the lowest usable watermark (a row is usable when + * its `ver` matches the live unit's `stateVersion`; an absent or mismatched row + * pulls the floor to `0` — that key must refold the full log). The + * one-below anchor is load-bearing: the tail then proves how far the + * stored log still extends, so {@link restore} can detect a log that + * shrank below a row's watermark (crash-repair truncation) instead of + * serving the stale row as current — an empty tail read from the anchor + * yields an end below every watermark and the restore rejects for a full + * re-read. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns the seq to hand the persistence `readFrom`, or `undefined` + * when no unit is registered (no read needed — {@link restore} would + * serve empty values regardless). + */ +restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined + +/** + * View a checkpoint's rows without any log read: for every registered + * unit whose row's `ver` matches, serve the schema-validated + * `view` of the stored state; mismatched or absent rows leave their key + * absent (a cold or listing consumer treats it as not-yet-available and a + * fuller read path refolds it). The zero-I/O rung of the read ladder — + * values are as stale as their rows, never wrong. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns whole values per key with a usable row; empty when none. + */ +viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial + +/** + * Cold read: fold every registered unit over a stored log suffix, seeding + * each from its checkpoint row when usable — the one read recipe (cached + * state + forward tail replay + `view`) applied without a live `Session`. + * Call with the events returned by a persistence + * `readFrom(id, restoreFloor(checkpoint))` and that same floor as + * `baseSeq`; the floor's one-below anchor makes the supplied end honest, + * so a shrunk log is detected here. A row is usable iff its + * `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq` + * (`seq >= baseSeq - 1`), and it does not claim events past the + * supplied end (`seq <= endSeq`); an unusable row is discarded + * and its key refolds from `init` — which is only sound over the full + * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads + * from seq 0, e.g. after a crash-repair truncation shrank the log below + * a row's watermark). + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @param events - the stored events with `seq >= baseSeq`, in seq order. + * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). + * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last + * supplied event's seq, `baseSeq - 1` for an empty tail) plus the + * refreshed checkpoint rows at that cut, ready for a durable write-back. + */ +restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } ``` -Types: [Session](../core-data-structures/session.md) +Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-projection/session-projection/src/index.ts:136`](../../packages/session-projection/session-projection/src/index.ts) +Source: [`packages/session-projection/session-projection/src/index.ts:156`](../../packages/session-projection/session-projection/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) @@ -1262,7 +1447,7 @@ Exact-read consumer that prepares immutable cross-session message context. * @param signal - optional cancellation boundary for host autocomplete teardown. * @returns candidates labeled by latest title or, when absent, session id. */ -async listCandidates( agent: Agent, query = '', limit = this.config.candidateLimit, signal?: AbortSignal, ): Promise +async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise /** * Snapshot all references before enqueue and return one aggregated durable context. @@ -1405,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:694`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:695`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` @@ -1419,6 +1604,19 @@ Log-backed title fold plus asynchronous fallback generation. */ get(session: Session): SessionTitleSnapshot | undefined +/** + * Accept an explicit user title. Appends a `session/title` event with the + * `user` source, which pins the title: in-flight automatic generation is + * superseded and later user messages schedule none (an explicit + * {@link SessionTitleService.refresh} remains the deliberate unpin). + * @param session - exact live session to rename. + * @param title - raw user input; normalized before acceptance. + * @returns the accepted title snapshot. + * @throws {SessionTitleInvalidError} when the title normalizes to empty. + * @throws {Error} when the session is not live or the service is disposed. + */ +rename(session: Session, title: string): SessionTitleSnapshot + /** * Explicitly retry the registered provider, or materialize the built-in * fallback when no provider is registered. @@ -1439,41 +1637,51 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:240`](../../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:261`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` -Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. +Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted invocation-neutral summaries, and loads full skill bodies on demand. ```ts cordis-catalog /** * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters * the provider and invalidates catalog caches. - * @param provider - the provider to register by `provider.name`. + * @param create - synchronous factory receiving this registration's lifecycle and invalidation control. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. */ -registerProvider(provider: SkillProvider): () => void +registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void /** * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and * receives a no-op disposer so it cannot remove the winner. - * @param skill - the complete skill definition to expose for discovery. + * @param skill - the skill definition input; omitted invocation and provider fields receive defaults. * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ register(skill: SkillRegistration): () => void /** - * List model-invocable skill summaries for a workspace. Lookup options and - * provider candidates are readonly same-process values borrowed throughout - * discovery. + * List invocation-neutral skill summaries for a workspace. Consumers apply + * model or user invocation policy at their operational boundary. Lookup + * options and provider candidates are readonly same-process values borrowed + * throughout discovery. * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. - * @returns sorted summaries, excluding skills disabled for model invocation. + * @returns all sorted winning summaries. */ async list(options: SkillLookupOptions = {}): Promise +/** + * Observe the current invocation-neutral catalog and whether discovery completed within a stable revision. + * Incomplete observations are never cached, allowing consumers to retain last-good state and + * retry on their next request boundary. + * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @returns sorted summaries plus discovery-completeness state. + */ +async snapshot(options: SkillLookupOptions = {}): Promise + /** * Load and validate the winning candidate, passing its opaque discovery locator back to the * provider. Cancellation is rechecked after selection, including cache hits, and raced against @@ -1485,9 +1693,9 @@ async list(options: SkillLookupOptions = {}): Promise async get(name: string, options: SkillLookupOptions = {}): Promise ``` -Types: [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) +Types: [SkillCatalogSnapshot](../core-data-structures/skills.md) · [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillProviderControl](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) -Source: [`packages/skill/skill/src/index.ts:141`](../../packages/skill/skill/src/index.ts) +Source: [`packages/skill/skill/src/index.ts:209`](../../packages/skill/skill/src/index.ts) ## `ctx.spillStore` — `SpillStore` (abstract seam) @@ -1989,7 +2197,68 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:187`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:247`](../../packages/ui/tui/src/index.ts) + +## `ctx.typert` — `TypertRegistry` + +Registry of generated schemas and package reflection. + +```ts cordis-catalog +/** + * Register one generated contribution atomically for the calling fiber. + * Duplicate package-face identities or schema keys reject the whole batch. + * @param contribution - generated schemas and package metadata. + * @returns the exact effect disposer that removes this contribution. + */ +register(contribution: TypertContribution): () => void + +/** + * Look up one schema by `#`. + * @param key - global schema key. + * @returns the live schema record, or `undefined` when absent. + */ +get(key: string): TypertSchemaRecord | undefined + +/** + * Resolve one required schema. + * @param key - global schema key. + * @returns the live schema record. + * @throws when the key is malformed, the package face is absent, or the schema is not contributed. + */ +resolve(key: string): TypertSchemaRecord + +/** + * Enumerate live schemas in registration order. + * @param filter - optional package and face restriction. + * @returns matching schema records. + */ +list(filter: TypertSchemaFilter = {}): TypertSchemaRecord[] + +/** + * Look up generated reflection for one package face. + * @param packageName - exact npm package name. + * @param face - face to query; defaults to the host runtime. + * @returns the live package record, or `undefined` when absent. + */ +getPackage(packageName: string, face: TypertFace = 'host'): TypertPackageRecord | undefined + +/** + * Enumerate generated package reflection in registration order. + * @param filter - optional package and face restriction. + * @returns matching package records. + */ +listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] + +/** + * Project a live Zod schema to JSON Schema without caching the result. + * @param key - global schema key. + * @param params - Zod projection parameters. + * @returns a fresh JSON Schema document. + */ +toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema +``` + +Source: [`packages/typert/registry/src/index.ts:67`](../../packages/typert/registry/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index 94b6bd4c5d..89783b5d16 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/compaction.md -compaction.md: 3ba5edd96c509e064ac7033b175b7ddd3c972452 -compaction.zh.md: d082b0d0545802500278e96ca41a273bce275f53 +compaction.md: 911b71d00fa4b42e9cdfa67f67d4e9b29e354a4a +compaction.zh.md: 643a116ff2edbbb53d300b4f5ff0ad36d401130b diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 3ba5edd96c..911b71d00f 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -13,7 +13,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de | Event | Payload | Role | |---|---|---| | `compact/start` | `{ turn }` | acquires the log-recorded lock | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens? }` | provenance: the summary blocks, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note) | +| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note) | | `compact/end` | `{ turn, error? }` | releases the lock (`error` set when summarization threw) | The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished. @@ -22,7 +22,7 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b ## `CompactionResult` -What a successful compaction returns to its caller: the bookkeeping-event seqs, raw summary, shadowed range and seqs, and estimated token count. +What a successful compaction returns to its caller: the bookkeeping-event seqs, safe summary projection, shadowed range and seqs, and estimated token count. ```ts type-equiv /** Result of a successful compaction operation. */ diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index d082b0d054..643a116ff2 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -13,7 +13,7 @@ | 事件 | 载荷 | 作用 | |---|---|---| | `compact/start` | `{ turn }` | 获取日志记录的锁 | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens? }` | provenance:摘要块、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) | +| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) | | `compact/end` | `{ turn, error? }` | 释放锁(摘要调用抛出异常时设置 `error`) | 锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。 @@ -22,7 +22,7 @@ ## `CompactionResult` -成功压缩向调用方返回:记账事件 seq、原始摘要、被遮蔽的范围与 seq,以及估算 token 数。 +成功压缩向调用方返回:记账事件 seq、安全摘要投影、被遮蔽的范围与 seq,以及估算 token 数。 ```ts type-equiv /** Result of a successful compaction operation. */ diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 9f6970d88d..251ff9926e 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: b9df539136c2661537775ba9a425bdf7ef1fd958 -core.zh.md: 1c75e8484dd1184077fe0194b2b6088230d1bbf5 +core.md: 0ab58864bf70a52554d0c4b9da10fa3fc49e9dc2 +core.zh.md: 5719c603d0d7576e9fc030e73fb9a6857fef458c diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b9df539136..0ab58864bf 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -477,7 +477,10 @@ type AgentCancelCause = `Agent` is an interface over the public live-agent contract. Concrete drivers own the `followup`/`steer`/`inject` aliases and route them through `send`'s (`target` × `wakeup`) matrix. ```ts type-equiv -/** Public live-agent handle with aliases over the unified delivery primitive. */ +/** + * Public live-agent handle with aliases over the unified delivery primitive. + * @typert object + */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 1c75e8484d..5719c603d0 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -485,7 +485,10 @@ type AgentCancelCause = `Agent` 是覆盖公开活跃 agent 契约的接口。具体驱动器拥有 `followup`/`steer`/`inject` 别名方法,并将它们经由 `send` 的(`target` × `wakeup`)矩阵路由。 ```ts type-equiv -/** Public live-agent handle with aliases over the unified delivery primitive. */ +/** + * Public live-agent handle with aliases over the unified delivery primitive. + * @typert object + */ interface Agent { /** The single identity shared with {@link session}. */ readonly id: SessionId diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index ea83035f92..87b7587d30 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/persistence.md -persistence.md: 5a660e17d6f498213564ca7d68dc4d7a615ba1de -persistence.zh.md: b5477cfc8242f9db47c2c6e40bd63f1b3683ace9 +persistence.md: 0280093883b9fbd4c4bbc788bce7193d59bb7182 +persistence.zh.md: d32274bbc2e96e3754a8061ba37427e34b6205f6 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 5a660e17d6..0280093883 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -77,7 +77,7 @@ interface SessionHeader { ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. ```ts type-equiv /** @@ -86,7 +86,7 @@ Creating a `Session` through the store takes a `seed` (replay/fork an existing e * store folds into a {@link SessionHeader}. */ interface CreateSessionOptions { - /** Events to seed the new session with (replay/fork). */ + /** Initial replay or fork history supplied at construction. */ readonly seed?: readonly SessionEvent[] /** * Storage metadata read once before publication. `seedLength` is explicit diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index b5477cfc82..d32274bbc2 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -77,7 +77,7 @@ interface SessionHeader { ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(回放/fork 现有事件日志)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 +通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 ```ts type-equiv /** @@ -86,7 +86,7 @@ interface SessionHeader { * store folds into a {@link SessionHeader}. */ interface CreateSessionOptions { - /** Events to seed the new session with (replay/fork). */ + /** Initial replay or fork history supplied at construction. */ readonly seed?: readonly SessionEvent[] /** * Storage metadata read once before publication. `seedLength` is explicit diff --git a/docs/core-data-structures/session-title.i18n.yaml b/docs/core-data-structures/session-title.i18n.yaml index 64b3d8f342..ab368c4c37 100644 --- a/docs/core-data-structures/session-title.i18n.yaml +++ b/docs/core-data-structures/session-title.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session-title.md -session-title.md: 33efc911c0ca1ae94dc4ded74676e5c32a73bdd5 -session-title.zh.md: a4b95a726d2bc89a13d14f1daa2f825cd5aa91b1 +session-title.md: fff1aa1f6be45d0cfc4d7f6a9527ccb93561618f +session-title.zh.md: 73821b07c6be40d10d0961dd79b7c06bcadb7d0b diff --git a/docs/core-data-structures/session-title.md b/docs/core-data-structures/session-title.md index 33efc911c0..fff1aa1f6b 100644 --- a/docs/core-data-structures/session-title.md +++ b/docs/core-data-structures/session-title.md @@ -34,6 +34,10 @@ type SessionTitleSource = readonly provider: SessionTitleProviderId readonly model?: SessionTitleModelProvenance } + | { + /** Explicit user rename: pins the title — automatic generation stops scheduling. */ + readonly kind: 'user' + } ``` ```ts type-equiv @@ -41,9 +45,9 @@ type SessionTitleSource = interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string - /** Exact human `user/message` seqs used to derive this title. */ + /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ readonly messageSeqs: number[] - /** Built-in fallback or registered-provider provenance. */ + /** Built-in fallback, registered-provider, or explicit-user provenance. */ readonly source: SessionTitleSource } ``` diff --git a/docs/core-data-structures/session-title.zh.md b/docs/core-data-structures/session-title.zh.md index a4b95a726d..73821b07c6 100644 --- a/docs/core-data-structures/session-title.zh.md +++ b/docs/core-data-structures/session-title.zh.md @@ -34,6 +34,10 @@ type SessionTitleSource = readonly provider: SessionTitleProviderId readonly model?: SessionTitleModelProvenance } + | { + /** Explicit user rename: pins the title — automatic generation stops scheduling. */ + readonly kind: 'user' + } ``` ```ts type-equiv @@ -41,9 +45,9 @@ type SessionTitleSource = interface SessionTitleEventData { /** Normalized non-empty title text. */ readonly title: string - /** Exact human `user/message` seqs used to derive this title. */ + /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ readonly messageSeqs: number[] - /** Built-in fallback or registered-provider provenance. */ + /** Built-in fallback, registered-provider, or explicit-user provenance. */ readonly source: SessionTitleSource } ``` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 4f6391518d..3e35c3a051 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/session.md -session.md: 6ae0ab79b5c7bc3bc1859bf819ce25679672a7f0 -session.zh.md: 79ed40f7eee7a8cae05a366d646f85580c73d5d2 +session.md: fd8285eebd76e8bd7723ee86ae15427f4923f4d6 +session.zh.md: 1033bfda117b5693421f0bdf4ec3fc136039f223 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 6ae0ab79b5..fd8285eebd 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -302,6 +302,7 @@ The body-stripped declaration keeps the plain class's public constructor, state * * Plain class (not a Service) — create instances via `ctx.sessions.create()`. * Seeding with an existing event log replays/forks a session. + * @typert object */ declare class Session { /** The ordered surface over this session's event log. */ diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 79ed40f7ee..1033bfda11 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -304,6 +304,7 @@ interface SurfaceFoldResult { * * Plain class (not a Service) — create instances via `ctx.sessions.create()`. * Seeding with an existing event log replays/forks a session. + * @typert object */ declare class Session { /** The ordered surface over this session's event log. */ diff --git a/docs/core-data-structures/skills.i18n.yaml b/docs/core-data-structures/skills.i18n.yaml index d4b20a7fbb..f5c134815e 100644 --- a/docs/core-data-structures/skills.i18n.yaml +++ b/docs/core-data-structures/skills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/skills.md -skills.md: 2f47881ba5b694ab5affa43add60f340c4d17dbb -skills.zh.md: b5a212f81cc17975cb203738f2636bd5f5695f4f +skills.md: d4b41845bea009444653739abad712e9ce3afb13 +skills.zh.md: 8d6793129080487836b2e2471b8659df5a402974 diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index 2f47881ba5..d4b41845be 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -2,7 +2,7 @@ English | [中文](skills.zh.md) -The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the session-prefix catalog and model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). +The [skill capability family](../../packages/skill) is split across three packages: the registry ([dsh-skill](../../packages/skill/skill), `ctx.skills`) merges provider catalogs; the local provider ([dsh-skill-local](../../packages/skill/skill-local)) scans and watches project/custom/user directories; the consumer ([dsh-tool-skill](../../packages/skill/tool-skill)) owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). @@ -10,7 +10,19 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind `ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated. -Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast. +Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and omitted from an incomplete observation, while an explicit incomplete observation contributes usable candidates without making the result cacheable; malformed candidates fail fast. Each provider factory receives a registration-scoped control whose `invalidate()` clears completed catalogs only while that exact registration remains active and whose signal aborts on failed registration or disposal. An in-flight discovery retries once when its provider generation changes; a second change returns the latest candidates incomplete and uncached. Provider and runtime mutations emit the unfiltered `skills/change` invalidation event; it carries no diff, so consumers refetch `snapshot()` with their own lookup options. + +An array returned by `SkillProvider.list()` is complete-discovery shorthand. `SkillProviderObservation` lets a provider expose candidates that remain directly loadable while reporting that the observation is not authoritative. + +```ts type-equiv +/** Provider candidates plus whether the current discovery is authoritative. */ +interface SkillProviderObservation { + /** Candidates available from the current provider discovery. */ + readonly candidates: readonly SkillCandidate[] + /** Whether discovery completed and these candidates may be cached. */ + readonly complete: boolean +} +``` ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -23,9 +35,10 @@ interface SkillProvider { * authentication, and discovery are awaited inside this method. Implementations * should settle promptly when `options.signal` aborts. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. - * @returns provider candidates with precedence ranks and opaque locators. + * @returns provider candidates as a complete-array shorthand, or an explicit + * observation when usable candidates came from incomplete discovery. */ - readonly list: (options: SkillLookupOptions) => Promise + readonly list: (options: SkillLookupOptions) => Promise /** * Load a complete skill body for a previously listed candidate. * @param candidate - the winning candidate originally returned by this provider. @@ -36,6 +49,16 @@ interface SkillProvider { } ``` +```ts type-equiv +/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */ +interface SkillProviderControl { + /** Aborts if registration fails or when the exact provider registration is disposed. */ + readonly signal: AbortSignal + /** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */ + readonly invalidate: () => void +} +``` + ## Local discovery priority The shipped local provider scans roots in rank order: @@ -51,6 +74,8 @@ The shipped local provider scans roots in rank order: The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, the git-root walk probes `.git` through the filesystem service so remote or sandboxed workspaces do not fall back to the host filesystem boundary. The user DSH root skips its `.system` child. The local provider does not ship built-in system skills; deployments supply built-ins through another provider. +Chokidar watches existing roots for direct bundle/flat-entry additions and removals plus direct skill-entry changes. A missing root is followed one absent path segment at a time from its nearest existing ancestor until Chokidar can attach. Resource files below a bundle are not catalog changes. Model-facing `write` and `edit` observations synchronously invalidate the provider when their target is catalog-relevant, while the host watcher covers IDE, Git, shell, and external-process mutations. Watcher failures make the current observation incomplete without hiding readable candidates from direct loads; project-scoped watchers use a configured bounded LRU. + ## Skill identity Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider accepts directory bundles (`/SKILL.md`) and flat Markdown files (`.md`). Nested recursive `**/SKILL.md` discovery is intentionally outside v1. @@ -62,19 +87,29 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | ' ## Summaries, candidates, and complete definitions -`SkillSummary` is the registry's model-invocable summary shape. Consumers choose which fields to render; the session catalog uses only `name` and `description`, never the body or absolute file path. `disableModelInvocation` hides a skill from model listings while allowing trusted code to load it by name. +`SkillSummary` is the registry's invocation-neutral summary shape. Consumers choose which entries and fields to render; the model session catalog uses only model-invocable `name` and `description`, never the body or absolute file path. `SkillInvocationPolicy` normalizes the two independent invocation controls into positive booleans, and every resolved summary, candidate, and definition carries it without turning arbitrary frontmatter into the domain model. ```ts type-equiv -/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */ +/** Invocation controls shared by skill discovery consumers. */ +interface SkillInvocationPolicy { + /** Whether model-facing catalogs and loaders include this skill. */ + readonly modelInvocable: boolean + /** Whether human-facing command catalogs and loaders include this skill. */ + readonly userInvocable: boolean +} +``` + +```ts type-equiv +/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */ interface SkillSummary { - /** Kebab-case identifier used with the `skill` tool. */ + /** Kebab-case identifier used to address the skill. */ readonly name: string - /** Short routing description shown to the model. */ + /** Short routing description shown by discovery consumers. */ readonly description: string - /** Optional extra routing guidance shown to the model. */ + /** Optional extra routing guidance. */ readonly whenToUse?: string - /** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */ - readonly disableModelInvocation?: boolean + /** Resolved model and user invocation controls. */ + readonly invocation: SkillInvocationPolicy /** Discovery source that produced this winning skill. */ readonly source: SkillSource /** Provider that owns this skill body. */ @@ -84,6 +119,20 @@ interface SkillSummary { } ``` +`ctx.skills.list()` preserves all four policy combinations. `isModelInvocable(skill)` and `isUserInvocable(skill)` read the corresponding required field. A model-only skill sets `{ modelInvocable: true, userInvocable: false }`, a user-only skill sets `{ modelInvocable: false, userInvocable: true }`, and setting both fields to `false` keeps the skill available only through trusted `ctx.skills.get()` callers. The local provider reads the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`, defaults omitted fields to `true`, and projects every parsed skill into this normalized policy. + +`SkillCatalogSnapshot` distinguishes authoritative absence from transient provider failure or a catalog that kept changing during discovery. `skills` contains the sorted invocation-neutral summaries collected in that observation; `complete` is true only when every registered provider completed without a concurrent catalog revision. Incomplete snapshots are not cached, allowing each consumer to retain its last-good filtered catalog and retry. + +```ts type-equiv +/** One catalog observation plus whether discovery completed within a stable catalog revision. */ +interface SkillCatalogSnapshot { + /** Sorted invocation-neutral summaries collected in this observation. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed without a concurrent catalog revision. */ + readonly complete: boolean +} +``` + `SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`. ```ts type-equiv @@ -122,17 +171,24 @@ interface SkillDefinition extends SkillSummary { } ``` -Runtime skills use the same complete shape and participate in the same first-wins collection order. The returned disposer removes the contribution and invalidates discovery caches. +Runtime skill inputs may omit invocation controls and the provider label. The registry resolves both defaults once, then uses the same complete definition shape and first-wins collection order as providers. The returned disposer removes the contribution and invalidates discovery caches. ```ts type-equiv /** Runtime skill contribution accepted by `ctx.skills.register()`. */ -type SkillRegistration = Omit & { readonly provider?: string } +type SkillRegistration = Omit & { + /** Invocation controls; omission permits both model and user surfaces. */ + readonly invocation?: SkillInvocationPolicy + /** Provider label; omission uses the registry-owned runtime provider. */ + readonly provider?: string +} ``` ## Lookup and configuration Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. +Full definitions are not cached by the registry. Each `get()` calls the winning provider with the selected candidate, so the local provider rereads the current body. A definition whose name no longer matches that candidate is rejected and invalidates the exact provider for rediscovery. + ```ts type-equiv /** Caller context used for cwd-sensitive and abortable provider work. */ interface SkillLookupOptions { @@ -143,7 +199,7 @@ interface SkillLookupOptions { } ``` -The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`). The consumer owns its catalog description bound. +The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, `customSkillDirs`, and optional `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`) plus watcher enablement, polling, stability, symlink, and project-capacity controls. The consumer owns its catalog description bound. Exact defaults and validation are in the generated [config catalog](../config-catalog.md). ```ts type-equiv /** Skill registry configuration. */ @@ -155,6 +211,8 @@ interface Config { ## Session catalog and tool contract -`dsh-tool-skill` injects a durable user-role `` at the first `agent/step` of a live session. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. +`dsh-tool-skill` injects the initial durable user-role `` at the first `agent/step` of a live session that observes a non-empty complete view. The catalog contains sorted skill `name` and normalized, XML-escaped `description` only; it omits bodies, paths, sources, providers, and routing hints. Discovery forwards the step's abort signal through `SkillLookupOptions`. `catalogDescriptionMaxLength` is the consumer config for the description bound, with default `500` and integer minimum `3`. -The model-facing `skill({ name })` tool validates the kebab-case name, loads the complete definition for the calling agent cwd, reports an unresolved skill as unknown or no longer available, rejects `disableModelInvocation` skills, and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. The tool result is the model-visible path for complete instructions. +Before each later model step, the consumer applies exact tool visibility and digests the exact rendered entries between the `` tags from a complete snapshot. It derives the comparison baseline from the same entries in the newest recognizable visible catalog message sourced by the plugin. A changed digest appends a durable full replacement through `agent.inject()`; deleting every skill appends an explicit empty replacement. Incomplete snapshots preserve the last-good model view. If compaction hides every historical catalog message, the next complete snapshot re-establishes the current catalog; an empty view with no prior catalog emits nothing. These catalog messages are session history, not World State. + +The model-facing `skill({ name })` tool validates the kebab-case name, finds the summary in the invocation-neutral catalog, rejects it before loading unless `isModelInvocable` permits access, then rereads the complete definition for the calling agent cwd and rechecks the policy before returning content. It reports an unresolved skill as unknown or no longer available and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results. diff --git a/docs/core-data-structures/skills.zh.md b/docs/core-data-structures/skills.zh.md index b5a212f81c..8d67931290 100644 --- a/docs/core-data-structures/skills.zh.md +++ b/docs/core-data-structures/skills.zh.md @@ -2,7 +2,7 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有会话前缀目录和面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 +[skill(技能)能力族](../../packages/skill)拆分为三个包(package):注册表([dsh-skill](../../packages/skill/skill),`ctx.skills`)合并各提供方的目录;本地提供方([dsh-skill-local](../../packages/skill/skill-local))扫描并监视项目/自定义/用户目录;消费方([dsh-tool-skill](../../packages/skill/tool-skill))拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.md)。 源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-local/src/index.ts`](../../packages/skill/skill-local/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 @@ -10,7 +10,19 @@ `ctx.skills` 组合本地、内嵌、远程或其他提供方。注册是同步的;远程初始化与发现属于 `list()` 的 await 阶段。提供方对象、选项与候选项以只读方式借用,语义字段会被校验。 -重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时记录日志并跳过,不缓存降级后的目录;格式错误的候选项快速失败。 +重名按 rank、提供方顺序、本地顺序依次解决;摘要按名称排序。`list()` 拒绝时会记录日志并从不完整观测中省略;显式的不完整观测会提供可用候选项,但不会使结果变得可缓存;格式错误的候选项快速失败。每个提供方工厂都会接收一项注册作用域内的控制能力;仅当该精确注册仍处于活动状态时,其 `invalidate()` 才会清除已完成目录;注册失败或释放时,其信号会中止。若提供方代次在发现进行期间发生变化,该发现会重试一次;若再次变化,则返回最新候选项,并将结果标为不完整且不予缓存。提供方和运行时变更会发出不带过滤条件的 `skills/change` 失效事件;该事件不携带 diff,因此消费方会使用自身的查找选项重新获取 `snapshot()`。 + +`SkillProvider.list()` 返回的数组是完整发现的简写形式。`SkillProviderObservation` 允许提供方公开仍可直接加载的候选项,同时报告该观测不具权威性。 + +```ts type-equiv +/** Provider candidates plus whether the current discovery is authoritative. */ +interface SkillProviderObservation { + /** Candidates available from the current provider discovery. */ + readonly candidates: readonly SkillCandidate[] + /** Whether discovery completed and these candidates may be cached. */ + readonly complete: boolean +} +``` ```ts type-equiv /** Provider interface for one source of skills, such as local directories or a remote registry. */ @@ -23,9 +35,10 @@ interface SkillProvider { * authentication, and discovery are awaited inside this method. Implementations * should settle promptly when `options.signal` aborts. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. - * @returns provider candidates with precedence ranks and opaque locators. + * @returns provider candidates as a complete-array shorthand, or an explicit + * observation when usable candidates came from incomplete discovery. */ - readonly list: (options: SkillLookupOptions) => Promise + readonly list: (options: SkillLookupOptions) => Promise /** * Load a complete skill body for a previously listed candidate. * @param candidate - the winning candidate originally returned by this provider. @@ -36,6 +49,16 @@ interface SkillProvider { } ``` +```ts type-equiv +/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */ +interface SkillProviderControl { + /** Aborts if registration fails or when the exact provider registration is disposed. */ + readonly signal: AbortSignal + /** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */ + readonly invalidate: () => void +} +``` + ## 本地发现优先级 内置的本地提供方按 rank 顺序扫描各根目录: @@ -51,6 +74,8 @@ interface SkillProvider { 项目根目录为包含 `.git` 的最近祖先目录;找不到时使用当前 cwd。当 `ctx.fs` 可用时,git-root 向上查找通过文件系统服务探测 `.git`,使远程或沙箱工作区不会回退到宿主文件系统边界。用户 DSH 根目录会跳过其 `.system` 子目录。本地提供方不附带内置系统 skill;部署方通过另一个提供方提供内置 skill。 +Chokidar 会监视现有根目录中直属 bundle 和平铺条目的添加与移除,以及直属 skill 条目的变更。缺失的根目录会从最近的现有祖先开始,逐个跟踪缺失路径段,直至 Chokidar 可以附加。bundle 下的资源文件变更不属于目录变更。面向模型的 `write` 和 `edit` 观测会在目标路径相关时同步使提供方目录失效,而宿主 watcher 覆盖 IDE、Git、shell 和外部进程产生的变更。watcher 失败会使当前观测不完整,但不会在直接加载时隐藏可读候选项;项目作用域 watcher 使用按配置设限的 LRU。 + ## Skill 身份 skill 名称为 kebab-case(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)。本地提供方接受目录包(`/SKILL.md`)和扁平 Markdown 文件(`.md`)。嵌套递归的 `**/SKILL.md` 发现有意不在 v1 范围内。 @@ -62,19 +87,29 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | ' ## 摘要、候选项与完整定义 -`SkillSummary` 是注册表中可供模型调用的摘要形状。消费方自行选择渲染哪些字段;会话目录仅使用 `name` 和 `description`,从不使用 body 或绝对文件路径。`disableModelInvocation` 将 skill 从模型列表中隐藏,但允许受信代码按名称加载。 +`SkillSummary` 是注册表中与调用策略无关的摘要形状。消费方自行选择渲染哪些条目和字段;模型会话目录仅使用模型可调用 skill 的 `name` 和 `description`,从不使用正文或绝对文件路径。`SkillInvocationPolicy` 将两个独立调用控制规范化为正向布尔值,且每个已解析的摘要、候选项和定义都携带该策略,而不会把任意 frontmatter 纳入领域模型。 ```ts type-equiv -/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */ +/** Invocation controls shared by skill discovery consumers. */ +interface SkillInvocationPolicy { + /** Whether model-facing catalogs and loaders include this skill. */ + readonly modelInvocable: boolean + /** Whether human-facing command catalogs and loaders include this skill. */ + readonly userInvocable: boolean +} +``` + +```ts type-equiv +/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */ interface SkillSummary { - /** Kebab-case identifier used with the `skill` tool. */ + /** Kebab-case identifier used to address the skill. */ readonly name: string - /** Short routing description shown to the model. */ + /** Short routing description shown by discovery consumers. */ readonly description: string - /** Optional extra routing guidance shown to the model. */ + /** Optional extra routing guidance. */ readonly whenToUse?: string - /** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */ - readonly disableModelInvocation?: boolean + /** Resolved model and user invocation controls. */ + readonly invocation: SkillInvocationPolicy /** Discovery source that produced this winning skill. */ readonly source: SkillSource /** Provider that owns this skill body. */ @@ -84,6 +119,20 @@ interface SkillSummary { } ``` +`ctx.skills.list()` 保留全部四种策略组合。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别读取对应的必填字段。仅供模型调用的 skill 设置 `{ modelInvocable: true, userInvocable: false }`,仅供用户调用的 skill 设置 `{ modelInvocable: false, userInvocable: true }`,两个字段均设为 `false` 后,该 skill 只能由受信的 `ctx.skills.get()` 调用方获取。本地提供方读取名称完全匹配的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`,将省略的字段默认为 `true`,并为每个解析出的 skill 生成这个规范化策略。 + +`SkillCatalogSnapshot` 用于区分已确定的不存在与提供方的瞬时失败或发现期间持续变化的目录。`skills` 包含该次观测中收集、排序且与调用策略无关的摘要;只有每个已注册提供方都在没有并发目录修订时完成发现,`complete` 才为 true。不完整快照不会缓存,因此每个消费方可以保留上一份经过自身过滤的可用目录并重试。 + +```ts type-equiv +/** One catalog observation plus whether discovery completed within a stable catalog revision. */ +interface SkillCatalogSnapshot { + /** Sorted invocation-neutral summaries collected in this observation. */ + readonly skills: SkillSummary[] + /** Whether every registered provider completed without a concurrent catalog revision. */ + readonly complete: boolean +} +``` + `SkillCandidate` 是提供方到注册表的形状。`locator` 是提供方的不透明状态;注册表只存储它并在调用获胜提供方的 `get()` 时传回。 ```ts type-equiv @@ -122,17 +171,24 @@ interface SkillDefinition extends SkillSummary { } ``` -运行时 skill 使用相同的完整形状,参与相同的先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。 +运行时 skill 输入可以省略调用控制和提供方标签。注册表会一次性补全这两项默认值,随后使用与提供方相同的完整定义形状和先到先得收集顺序。返回的 disposer 移除该贡献并使发现缓存失效。 ```ts type-equiv /** Runtime skill contribution accepted by `ctx.skills.register()`. */ -type SkillRegistration = Omit & { readonly provider?: string } +type SkillRegistration = Omit & { + /** Invocation controls; omission permits both model and user surfaces. */ + readonly invocation?: SkillInvocationPolicy + /** Provider label; omission uses the registry-owned runtime provider. */ + readonly provider?: string +} ``` ## 查找与配置 skill 查找对 cwd 敏感,因为提供方可能暴露工作区本地的 skill;可选的 signal 为调用方取消提供方的工作。提供方接收与缓存标识和加载相同的只读选项对象。取消在目录选择前后(包括缓存命中时)都会检查,并与发现和完整定义加载竞争。如果找不到 git root,本地提供方将所提供的 cwd 本身视为项目根目录。 +注册表不缓存完整定义。每次调用 `get()` 都会携所选候选项调用胜出提供方,因此本地提供方会重新读取当前正文。名称与该候选项不再匹配的定义会被拒绝,并使该提供方实例失效以便重新发现。 + ```ts type-equiv /** Caller context used for cwd-sensitive and abortable provider work. */ interface SkillLookupOptions { @@ -143,7 +199,7 @@ interface SkillLookupOptions { } ``` -注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`)。消费方拥有其目录描述上限。 +注册表只拥有其发现缓存上限。本地提供方拥有文件系统根目录(`dshHome`、`agentsHome`、`customSkillDirs`,以及可选的 `bundledSkillDir`/`DSH_BUNDLED_SKILL_DIR`),以及 watcher 启用、轮询、稳定性、符号链接和项目容量控制。消费方拥有其目录描述上限。确切的默认值和校验规则见自动生成的[插件配置目录](../config-catalog.md)。 ```ts type-equiv /** Skill registry configuration. */ @@ -155,6 +211,8 @@ interface Config { ## 会话目录与工具契约 -`dsh-tool-skill` 在存活会话的第一个 `agent/step` 注入一条持久的 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。 +`dsh-tool-skill` 在存活会话中第一个观察到非空完整视图的 `agent/step` 注入初始的持久 user-role ``。目录只包含已排序的 skill `name` 和规范化、经 XML 转义的 `description`;不包含正文、路径、来源、提供方或路由提示。发现通过 `SkillLookupOptions` 转发该步骤的 abort signal。`catalogDescriptionMaxLength` 是消费方用于 description 上限的配置,默认值为 `500`,整数最小值为 `3`。 -面向模型的 `skill({ name })` 工具校验 kebab-case 名称,为调用方 agent 的 cwd 加载完整定义,将未解析的 skill 报告为 unknown 或 no longer available,拒绝 `disableModelInvocation` 的 skill,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。工具结果是模型获取完整指令的可见路径。 +在后续每个模型步骤之前,消费方都会应用精确的工具可见性,并对完整快照中 `` 标签之间精确渲染的条目计算 digest。它以该插件所发布、最新一条可识别且仍可见的目录消息中的相同条目作为比较基线。digest 发生变化时,会通过 `agent.inject()` 追加一条持久的完整目录替换;删除所有 skill 时会追加一条显式的空替换。不完整快照会保留上一份可用模型视图。如果压缩(compaction)隐藏了所有历史目录消息,下一份完整快照会重新建立当前目录;如果视图为空且从未发布目录,则不发送任何内容。这些目录消息属于会话历史,而非 World State。 + +面向模型的 `skill({ name })` 工具校验 kebab-case 名称,在与调用策略无关的目录中查找摘要,并在加载前通过 `isModelInvocable` 拒绝无权访问的 skill;随后它为调用方 agent 的 cwd 重新读取完整定义,并在返回内容前再次检查策略。该工具将未解析的 skill 报告为 unknown 或 no longer available,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。 diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 96c938a74f..674cd63efe 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.i18n.yaml @@ -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 -defensive-patterns.md: c69094db461048f5dbca5f8bdd1fb5581b08a962 -defensive-patterns.zh.md: eb57f035ad0bd67e62e285d451502d41e4efc2bc +# pnpm run verify-translation-pairing --write docs/defensive-patterns.md +defensive-patterns.md: cc34877fb0d6a2e1740d8fa138f879363c8e69a3 +defensive-patterns.zh.md: 21b0977d8167ffecc21cdfab3c778efceefd8f03 diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index c69094db46..cc34877fb0 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -26,4 +26,4 @@ A user-supplied listener that throws must not reject the promise it runs inside ## Never hand untrusted output the ambient environment or predictable paths -Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. +Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index eb57f035ad..21b0977d81 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -26,4 +26,4 @@ ## 绝不将环境变量或可预测路径暴露给不可信输出 -spawn 的命令应获得一份经过清洗的 env(去除 `*KEY*`/`*SECRET*`/`*TOKEN*`),使 harness 凭证无法泄漏到输出、`env` 或溢出文件中。临时/溢出文件应使用私有(0700)目录、随机文件名和排他的仅所有者可访问打开方式(`'wx'`、`0o600`)——可预测的全局可读路径会招致符号链接竞争和信息泄露。 +spawn 的命令应获得一份经过清洗的 env(去除 `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`),使 harness 凭证无法泄漏到输出、`env` 或溢出文件中。临时/溢出文件应使用私有(0700)目录、随机文件名和排他的仅所有者可访问打开方式(`'wx'`、`0o600`)——可预测的全局可读路径会招致符号链接竞争和信息泄露。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 74d9dfbea5..311f872b4a 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 32339fa2af8c1b6005d9e0b8165d57966a4145ca -development.zh.md: c74a81346639c6f95568cbd86b401d134d5eb7fc +development.md: 0a18e29d3da4f694707521e230017e6b22cad740 +development.zh.md: 885b51c701267215cc50d31ecd1694ae2c9af9ca diff --git a/docs/development.md b/docs/development.md index 32339fa2af..0a18e29d3d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,7 @@ If hooks are missing because dependencies were restored from cache or `postinsta node scripts/install-lefthook.mjs ``` -The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly. +The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly. Before enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files. diff --git a/docs/development.zh.md b/docs/development.zh.md index c74a813466..885b51c701 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -27,7 +27,7 @@ pnpm install node scripts/install-lefthook.mjs ``` -包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。 +包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。 启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index dabfeb2517..5a7eaa5d17 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,37 +8,34 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:358`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:221`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:403`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:279`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:342`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:361`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:390`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:329`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`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-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:252`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -63,12 +60,16 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `commands/changed` | `runtime` (`emit`) | - | -| `connection/reset` | `runtime` (`emit`) | - | -| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `hmr`, `modules`, `webserver` | +| `commands/changed` | `runtime` (`emit`) | `ui-command` | +| `connection/reset` | `runtime` (`emit`) | `ui-command` | +| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | +| `slash/input-begin-command` | - | `ui-conversation` | +| `slash/input-consume-token` | - | `ui-conversation` | +| `slash/input-insert-reference` | - | `ui-conversation` | +| `slash/input-insert-text` | - | `ui-conversation` | | `slots/changed` | `runtime` (`emit`) | - | | `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index e4fb87a322..5ad0a7b7dd 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/i18n/README.md -README.md: 9fe01174909f0400b94c70b95759526aec783d2e -README.zh.md: 2a9a20ae4702e205879f0fbfb6d76d3a761d2446 +README.md: d1fff61a1d3db7fbe93d3c95b144598c2ca41c8b +README.zh.md: 9f1d61f1b854ead0ef5851157cce2e9a6de83d89 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 9fe0117490..d1fff61a1d 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -15,7 +15,7 @@ This repo's documentation is read by people and agents both inside and outside t foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hashes also recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). + Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief ` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). - **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`. - **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 2a9a20ae47..9f1d61f1b8 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -15,7 +15,7 @@ foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 - **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。 - **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 3a40629372..5072c2684d 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -54,7 +54,7 @@ | Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 | | schema | schema | | | | | schema DSL | schema DSL | | | | -| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | +| seam | seam | | 接缝 | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | | skill | skill | skill(技能) | | | | spawn | spawn | | | | | steering | steering | steering(中途引导) | | | @@ -74,21 +74,24 @@ | adapter | 适配器 | | | | | adapter contract | 适配器契约 | 适配器契约(adapter contract) | | | | append-only | 仅追加 | | | | -| artifact | 产物 | | | | +| artifact | 产物 | | 制品 | | | backend | 后端 | | | | | background task | 后台任务 | | | | | block | 块 | | | | | build target | 构建目标 | | | | | cancel | 取消 | | | | +| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` | +| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库接口、实现与消费方分离的命名架构概念;普通 `seam` 仍按其词条处理 | | feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 | | feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 | | checkpoint | 检查点 | | | | | chunk | 分片 | | | | | compaction | 压缩 | 压缩(compaction) | | | | companion tool | 配套工具 | | | | +| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` | | Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 | | config key | 配置键 | | | Cordis 插件配置中的单个字段 | -| consumer | 消费方 | | | | +| consumer | 消费方 | | 消费者 | | | content block | 内容块 | | | | | Cookbook | 实操手册 | | | 文档标题用语 | | context | 上下文 | | | | @@ -145,9 +148,10 @@ | persistence | 持久化 | | | | | pipeline | 流水线 | | | | | plugin | 插件 | | | | +| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 | | prompt | 提示词 | | | | | provider | 提供方 | | | | -| provider-neutral | 提供方无关 | | | | +| provider-neutral | 提供方无关 | | 提供方中立 | | | quality gate | 质量门禁 | | | | | quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 | | reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 | @@ -165,7 +169,7 @@ | sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 | | smoke test | 冒烟测试 | | | | | snapshot | 快照 | | | | -| source of truth | 真源 | | | | +| source of truth | 真源 | | 事实来源、唯一来源 | | | spine | 主干 | | | | | staged | 暂存 | | | 沿用 git 官方中文翻译 | | stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 729745f7ad..95d2d3bb0f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -9,6 +9,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri flowchart TD subgraph group_util["packages/util"] pkg_brand["brand"] + pkg_native_command["native-command"] pkg_paths["paths"] pkg_retention["retention"] pkg_timeout["timeout"] @@ -141,11 +142,15 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_runtime["client-runtime"] + pkg_client_test_runtime["client-test-runtime"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] + pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] pkg_client_ui_models["client-ui-models"] + pkg_client_ui_permission["client-ui-permission"] + pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -168,6 +173,7 @@ flowchart TD subgraph group_context["packages/context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] + pkg_tmux_context["tmux-context"] pkg_workspace_context["workspace-context"] end subgraph group_examples["packages/examples"] @@ -182,6 +188,9 @@ flowchart TD end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] + pkg_host_directory_picker["host-directory-picker"] + pkg_host_directory_picker_browse["host-directory-picker-browse"] + pkg_host_directory_picker_native["host-directory-picker-native"] pkg_host_webserver["host-webserver"] end subgraph group_lsp["packages/lsp"] @@ -211,6 +220,7 @@ flowchart TD end subgraph group_session_projection["packages/session-projection"] pkg_session_projection["session-projection"] + pkg_session_projection_cache["session-projection-cache"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -231,6 +241,11 @@ flowchart TD pkg_session_telemetry["session-telemetry"] pkg_session_telemetry_otel["session-telemetry-otel"] end + subgraph group_typert["packages/typert"] + pkg_typert_generator["typert-generator"] + pkg_typert_loader["typert-loader"] + pkg_typert_registry["typert-registry"] + end subgraph group_workflow["packages/workflow"] pkg_tool_ralph["tool-ralph"] pkg_tool_workflow["tool-workflow"] @@ -241,6 +256,7 @@ flowchart TD pkg_workspace["workspace"] end pkg_brand --> pkg_invariants + pkg_native_command --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants pkg_timeout --> pkg_invariants @@ -254,15 +270,17 @@ flowchart TD pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_question --> pkg_invariants pkg_client_ui_slots --> pkg_invariants - pkg_client_ui_trajectory --> pkg_invariants pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants pkg_code_runtime --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants + pkg_host_directory_picker --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants + pkg_typert_generator --> pkg_invariants + pkg_typert_registry --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_llm --> pkg_timeout @@ -275,6 +293,10 @@ flowchart TD pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots pkg_client_locale --> pkg_invariants + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_invariants pkg_client_ui_models --> pkg_client_runtime pkg_client_ui_models --> pkg_client_ui_slots pkg_client_ui_models --> pkg_invariants @@ -286,9 +308,8 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_invariants pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots @@ -307,6 +328,8 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess + pkg_typert_loader --> pkg_invariants + pkg_typert_loader --> pkg_typert_registry pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -322,31 +345,32 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_runtime pkg_client_ui_settings_general --> pkg_client_ui_primitives pkg_client_ui_settings_general --> pkg_client_ui_settings pkg_client_ui_settings_general --> pkg_client_ui_slots pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants pkg_client_ui_theme --> pkg_client_locale pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -396,17 +420,25 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -436,6 +468,7 @@ flowchart TD pkg_goal --> pkg_llm pkg_goal --> pkg_scope pkg_goal --> pkg_session + pkg_goal --> pkg_session_projection pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess @@ -486,22 +519,30 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm - pkg_client_ui_model --> pkg_client_connection - pkg_client_ui_model --> pkg_client_runtime - pkg_client_ui_model --> pkg_client_ui_command - pkg_client_ui_model --> pkg_client_ui_conversation - pkg_client_ui_model --> pkg_client_ui_primitives - pkg_client_ui_model --> pkg_client_ui_slash - pkg_client_ui_model --> pkg_client_ui_slots - pkg_client_ui_model --> pkg_invariants + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants pkg_scripts --> pkg_app_boot pkg_scripts --> pkg_invariants + pkg_session_projection_cache --> pkg_invariants + pkg_session_projection_cache --> pkg_session + pkg_session_projection_cache --> pkg_session_persistence + pkg_session_projection_cache --> pkg_session_projection + pkg_session_projection_cache --> pkg_storage_domain pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -562,11 +603,28 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_permission --> pkg_bash + pkg_permission --> pkg_commands pkg_permission --> pkg_invariants pkg_permission --> pkg_sandbox pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session + pkg_permission --> pkg_session_projection pkg_permission --> pkg_user_approval + pkg_client_ui_goal --> pkg_client_connection + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_model --> pkg_client_connection + pkg_client_ui_model --> pkg_client_runtime + pkg_client_ui_model --> pkg_client_ui_command + pkg_client_ui_model --> pkg_client_ui_conversation + pkg_client_ui_model --> pkg_client_ui_primitives + pkg_client_ui_model --> pkg_client_ui_slash + pkg_client_ui_model --> pkg_client_ui_slots + pkg_client_ui_model --> pkg_invariants pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -661,7 +719,9 @@ flowchart TD pkg_plan_mode --> pkg_agent pkg_plan_mode --> pkg_commands pkg_plan_mode --> pkg_invariants + pkg_plan_mode --> pkg_llm pkg_plan_mode --> pkg_session + pkg_plan_mode --> pkg_session_projection pkg_plan_mode --> pkg_system_prompt pkg_plan_mode --> pkg_tools pkg_plan_mode --> pkg_user_interaction @@ -712,6 +772,11 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction + pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_invariants + pkg_client_ui_permission --> pkg_permission pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -749,6 +814,7 @@ flowchart TD pkg_tool_pty --> pkg_tools pkg_tool_tasks --> pkg_agent pkg_tool_tasks --> pkg_invariants + pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks @@ -768,10 +834,12 @@ flowchart TD pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm + pkg_subagent_inprocess --> pkg_sandbox_policy pkg_subagent_inprocess --> pkg_session pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools + pkg_subagent_inprocess --> pkg_user_approval pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm @@ -799,10 +867,17 @@ flowchart TD pkg_tui --> pkg_session_reference pkg_tui --> pkg_session_title pkg_tui --> pkg_skill + pkg_tui --> pkg_subprocess pkg_tui --> pkg_system_prompt pkg_tui --> pkg_token_meter pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction + pkg_client_ui_plan --> pkg_client_connection + pkg_client_ui_plan --> pkg_client_runtime + pkg_client_ui_plan --> pkg_client_ui_conversation + pkg_client_ui_plan --> pkg_client_ui_slots + pkg_client_ui_plan --> pkg_invariants + pkg_client_ui_plan --> pkg_plan_mode pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_goal @@ -914,6 +989,7 @@ flowchart TD | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | +| [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | @@ -927,23 +1003,26 @@ flowchart TD | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | +| [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | +| [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | +| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | @@ -951,16 +1030,17 @@ flowchart TD | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | +| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`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-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -977,15 +1057,17 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`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-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`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-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | +| [`goal`](../packages/goal/goal) | `goal` | [`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-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -999,10 +1081,12 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`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-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`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) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | +| [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1015,7 +1099,9 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`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) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | @@ -1030,7 +1116,7 @@ flowchart TD | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`tools`](../packages/core/tools) | -| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `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), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | `session-persistence` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | @@ -1040,19 +1126,21 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`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), [`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), [`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), [`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) | +| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1174dcd791..dea3c4816b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -130,11 +130,15 @@ Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approv * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the prompt section and the narrator's notices). The LAST such - * event is the session's override ({@link effectiveApprovalPolicy}); - * who asked for it is derivable from position (an event after the log's - * last `request/header` was a runtime switch by the user). + * event is the session's override ({@link effectiveApprovalPolicy}). + * `source: 'delegation'` marks an override seeded into a child; an absent + * source is a runtime switch. */ -'approval/policy': { policy: ApprovalPolicy } +'approval/policy': { + policy: ApprovalPolicy + /** Marks an override seeded into a child at delegation. */ + source?: 'delegation' +} ``` Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approval/src/index.ts) @@ -209,7 +213,7 @@ Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/in 'compact/end': { turn: number; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:40`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:44`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -231,6 +235,8 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact */ 'compact/summary': { summary: ContentBlock[] + /** Complete provider output before the backend's safe summary projection. */ + rawOutput?: ContentBlock[] shadowedRange: { start: number; end: number } shadowedSeqs: number[] shadowedTokenCount: number @@ -245,10 +251,12 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact model: string /** The generation cap the summarize call sent, when one applied. */ maxTokens?: number + /** Provider-reported token usage for the summarization request, when emitted. */ + usage?: TokenUsage } ``` -Types: [ContentBlock](core-data-structures/core.md) +Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts) @@ -342,7 +350,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src 'permission/preset': { preset: string } ``` -Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:49`](../packages/ui/permission/src/index.ts) ### `plan/*` @@ -357,7 +365,7 @@ Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -382,14 +390,17 @@ Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/ * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}); who asked for it is derivable - * from position (an event after the log's last `request/header*` was a - * runtime switch by the user; see the tool layer's narrator). + * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * an override seeded into a child; an absent source is a runtime switch. */ -'sandbox/mode': { mode: SandboxMode } +'sandbox/mode': { + mode: SandboxMode + /** Marks an override seeded into a child at delegation. */ + source?: 'delegation' +} ``` -Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/sandbox/sandbox-policy/src/session-mode.ts) +Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/sandbox/sandbox-policy/src/session-mode.ts) ### `session/*` @@ -405,7 +416,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [SessionTitleEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:96`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:100`](../packages/session-title/session-title/src/index.ts) #### `session/title-llm-request` — log-only diff --git a/docs/postmortem/README.i18n.yaml b/docs/postmortem/README.i18n.yaml index e68d3a1a07..58b6521577 100644 --- a/docs/postmortem/README.i18n.yaml +++ b/docs/postmortem/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write docs/postmortem/README.md README.md: df0e2fcb8540aeed005153dbecc451d781ca5ff1 -README.zh.md: 2ce6de475c705b02cd9dabfb2181929d81478e2c +README.zh.md: e364ef30e342f8484a40f35e3970dea0f2f86ef3 diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index 2ce6de475c..e364ef30e3 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -事故复盘:一个 bug 到达了它不该到达的地方(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。 +事故复盘记录的是:一个 bug 流入了不该流入的环节(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。 -事故复盘不是 [Agent Note(agent 决策记录)](../../.agents/notes/README.md)(Agent Note 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体的防护措施使同类 bug 下次能被显式暴露。 +事故复盘不是 [Agent Note(agent 决策记录)](../../.agents/notes/README.md)(Agent Note 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及为此新增了哪些具体防护措施,以确保同类 bug 下次出现时会明确报错。 当一个 bug 满足以下条件时,请撰写事故复盘:**隐蔽**(机制不显而易见,即使是细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性的笔误)、**重新发现的代价高**(它消耗了真实的调试时间,且下次还会如此)。请链接该事故复盘所推动建立的防护措施(测试、AGENTS.md 规则、ADR)。 -每篇事故复盘以一段**摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、持久的教训是什么——然后才是后续的详细「概述 / 时间线 / 根因 / 防护措施」各节。 +每篇事故复盘以一段**摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、可长期沿用的教训是什么——然后才是后续的详细「概述 / 时间线 / 根因 / 防护措施」各节。 | # | 标题 | |---|---| diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 995a8c669d..368f92ae70 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -26,7 +26,7 @@ This table connects model-visible tool names to the plugin package and service s | `@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. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | -| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | +| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | diff --git a/docs/typert-catalog-integration-design.i18n.yaml b/docs/typert-catalog-integration-design.i18n.yaml new file mode 100644 index 0000000000..a7290fae68 --- /dev/null +++ b/docs/typert-catalog-integration-design.i18n.yaml @@ -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 docs/typert-catalog-integration-design.md +typert-catalog-integration-design.md: c7d601730655f61f3b875ad5ad6997d3c888bfea +typert-catalog-integration-design.zh.md: abaddfe1f740d4bd7cff5b2db8fd91e34626aab8 diff --git a/docs/typert-catalog-integration-design.md b/docs/typert-catalog-integration-design.md new file mode 100644 index 0000000000..c7d6017306 --- /dev/null +++ b/docs/typert-catalog-integration-design.md @@ -0,0 +1,133 @@ +# Typert Catalog Integration Design + +English | [中文](typert-catalog-integration-design.zh.md) + +## Current State and Problem + +Typert already provides separate host/client `FaceModel` instances, a `TypeGraph` with explicit cross-face references, and analysis support for services, events, `@typert object`, generics, inheritance, and External types. The TypeScript compiler API should only translate source code into this standard model; downstream consumers should not traverse the TypeScript AST again. + +The repository currently has two catalog pipelines that analyze TypeScript source directly: the static API catalog consumed by `tool-cordis`, and the generation and freshness gate for `docs/cordis-catalog/events.md` and `docs/cordis-catalog/services.md`. They analyze the same services, events, and related types, but maintain separate collection and rendering logic, so they cannot prove that the Typert model is sufficient to represent the existing domain semantics. + +The first phase makes both pipelines consume the Typert model while keeping the three committed artifacts character-for-character identical to their pre-migration versions: + +- `docs/cordis-catalog/events.md` +- `docs/cordis-catalog/services.md` +- `packages/cordis/tool-cordis/src/api-catalog.ts` + +This phase does not require product plugins to publish Typert subpaths, example applications to load Typert, or changes to the runtime dependencies of `tool-cordis`. + +## Options + +### Drive `tool-cordis` from the Runtime Registry + +Each plugin publishes and loads Typert artifacts, then `tool-cordis` reads the current runtime model from `ctx.typert`. This path reflects the set of plugins actually loaded, but it requires every product package represented in the catalog to add package exports, generated artifacts, registry contributions, and application assembly. That integration surface is much larger than the analysis capability being validated now. + +### Publish Typert Artifacts Repository-Wide, Then Aggregate Them Statically + +All product packages generate host/client JS and DTS during the normal build/typecheck process, then the catalog generator aggregates those artifacts. This path establishes the complete publication protocol up front, but it also changes many package manifests and the build topology at once, coupling catalog migration to repository-wide Typert publication. + +### Analyze at Build Time, Then Project the Catalog + +`WorkspaceAnalyzer` builds a `WorkspaceModel` and `TypeGraph` from the host TypeScript project. The repository-specific `CordisCatalogProjector` consumes only that model and generates the three texts. `tool-cordis` continues to import the committed static `api-catalog.ts`, so the runtime does not need the Typert service. + +This phase uses build-time projection. It directly verifies that the standard Typert model can replace the existing AST collector while leaving runtime publication and automatic loading to separate follow-up decisions. + +## Phase-One Architecture + +```text +tsconfig.host.json + │ + ▼ +WorkspaceAnalyzer ── TypeScript compiler API 的唯一边界 + │ + ▼ +WorkspaceModel + TypeGraph + │ + ▼ +CordisCatalogProjector ── 不依赖 TypeScript AST + ├── docs/cordis-catalog/events.md + ├── docs/cordis-catalog/services.md + └── packages/cordis/tool-cordis/src/api-catalog.ts +``` + +The objects have the following responsibilities: + +- `WorkspaceAnalyzer` analyzes packages, exports, services, events, type declarations, and reference relationships, and produces a compiler-independent model. +- `WorkspaceModel` and `TypeGraph` are the standard data structures shared by all generation and scanning analyses. They preserve developer-authored generics, inheritance, and type trees without retaining the TypeScript AST. +- The root entry point of `@deepseek-ai/dsh-typert-generator` exports `CordisCatalogProjector`, which performs model-driven selection, sorting, summary extraction, source location handling, JSDoc completeness checks, type-link closure, and rendering in three text formats. Its implementation remains in a dedicated Cordis catalog file, but it does not create another package subpath or embed a list of repository type names. +- `scripts/gen-cordis-catalog.ts` provides `LINK_MAP`, `FOUNDATION_TYPE_NAMES`, `TYPE_LINK_EXEMPTIONS`, and the inherited Cordis list, injects them explicitly into the projector through `CordisCatalogPolicy`, and owns the write/check CLI behavior. The vendor Cordis core pages continue to be generated by a separate pinned-source projector. +- `tool-cordis` imports only the static `api-catalog.ts` and does not depend on `typert-registry` or `typert-loader`. + +`CordisCatalogProjector` is a repository-specific downstream consumer and is not part of Typert's general-purpose model. When adding another category, first extend the standard model, then add the corresponding projector. The Typert analyzer must not absorb Cordis documentation formats or `tool-cordis` presentation logic. + +## Model Additions + +In addition to type structure, the catalog's character-for-character projection needs the declaration forms written by developers and exact source locations. The standard model therefore retains event/service locations, body-free text for events and members, parameter initializers, and the export status and canonical text of type declarations. `SourceDeclarationModel` also indexes top-level exported declarations for ambiguity checks and static type closure, without promoting them to domain graph roots. + +```ts +interface SourceLocation { + readonly file: string + readonly line: number + readonly column: number +} + +interface EventModel { + readonly location: SourceLocation + readonly text: string +} +``` + +Repository-wide analysis supports building bounded `ts.Program` instances in package batches, then merging them through source-location-stable graph ids into a face model equivalent to monolithic analysis. This capability changes only the memory boundary of the compiler program; it does not change package, declaration, or type graph semantics. + +All information required by the projector must come from `WorkspaceModel` or `TypeGraph`. If a fact required for character-for-character compatibility cannot be expressed by the model, extend the standard model; do not reintroduce `ts.Node`, `ts.Symbol`, or `ts.TypeChecker` in the projector or script. + +## Character-for-Character Migration Oracle + +Before migration, retain the three texts produced by the old generator against the same source state. After migration, run the new analyzer and projector and require the three outputs to be byte-for-byte identical. Newlines, spaces, ordering, JSDoc, source pointers, and generated headers are all part of the comparison. + +`pnpm run verify-cordis-catalog` retains its `--check` mode, which reads the three committed artifacts and compares them directly with the newly computed results. A missing file or any differing character makes the artifact stale, and the error points to the single `pnpm run gen-cordis-catalog` repair command. + +Tests pin both of the following layers: + +- Typert fixture snapshots pin the `WorkspaceModel`, `TypeGraph`, JS, DTS, and Zod outputs, proving the behavior of the standard model and general-purpose emitters. +- Cordis catalog tests or snapshots pin the projector's three complete texts, proving that the repository-specific product projection does not bypass the standard model and providing directly reviewable textual evidence. + +The three committed artifacts are the migration oracle between the old and new implementations and the continuing freshness oracle after migration. The old `gen-cordis-api` AST collector is removed. The scripts and commands with that name remain only as compatibility entry points for the unified projector because the generated file header itself contains the command; retaining the entry point preserves the character-for-character oracle without creating a second source of truth. + +## Exact Change List + +### Typert Generator + +- Add the locations, authored declaration text, parameter initializers, export status, and top-level source declaration index needed for character-for-character projection, with coverage in analyzer and model snapshots. +- Support bounded package-batch analysis and prove that direct and batched models are equivalent. +- Confirm that the catalog's required service declarations, public instance members, JSDoc, generics, inheritance, and referenced types are all available from the model. +- Keep the TypeScript compiler API encapsulated within the analyzer; the public model and projector inputs do not expose compiler objects. + +### Cordis Catalog Projector + +- Select the complete set of Cordis services and events from the host `WorkspaceModel`. +- Preserve the old generator's JSDoc rules: events must have `@mode` and payload `@param` tags; service methods must have a matching `@param` for every parameter; non-void returns must have `@returns`. +- Compute the type links used by signatures and the transitive public type closure required by `tool-cordis` from the type graph. +- Receive caller-maintained type classifications and the inherited surface through an explicit `CordisCatalogPolicy`; do not maintain the repository documentation taxonomy inside the generator package. +- Preserve the existing output rules for source pointers, signatures, summaries, ordering, declaration truncation, and the inherited context catalog. +- Project once and render the events Markdown, services Markdown, and TypeScript API catalog, preventing drift between documentation and tool data. + +### Commands and Consumers + +- `scripts/gen-cordis-catalog.ts` maintains repository policy data, assembles the analyzer and projector, and writes/checks all three artifacts together. Parsing, validation, and rendering logic lives in the generator's dedicated Cordis source file and is exported uniformly from the package root entry point. +- Narrow `scripts/gen-cordis-api.ts` to a logic-free compatibility entry point for the unified CLI; the root `gen-cordis-api` and `verify-cordis-api` aliases point to that entry point. +- Restore the static catalog default in `tool-cordis` and remove its dependencies on `ctx.typert`, `typert-registry`, and runtime package-model completeness. +- `gen-doc-graphs` obtains the projector's model-level result once and reuses its services and events; it must not continue to import the AST collector or analyze the repository again. + +### Narrow the Scope of Phase-One Changes + +- Remove the newly added `./typert` and `./client/typert` exports and `lib/typert.*` files from product plugin package.json files. +- Remove `typert-registry` and `typert-loader` assembly from examples. +- Normal build/typecheck does not run repository-wide `gen-typert` or require product-package Typert artifacts to exist before it runs on a clean tree. +- Retain `packages/typert/generator`, `packages/typert/registry`, and `packages/typert/loader`, along with their independent fixture, emitter, and runtime registration tests. + +## Future Extensions + +The runtime registry remains the receiving and query layer for generated JS/Zod, and the loader remains the automatic loading mechanism; neither supplies data to the first-phase static catalog. When product packages need runtime reflection, they can opt in by publishing `package/typert` and `package/client/typert`, which the loader then registers with `ctx.typert`. + +Future integration does not change the phase-one layering: only the analyzer handles TypeScript, the standard model serves both static generation and scan analysis, and the emitter produces runtime artifacts from that same model. Whether to extend publication to more packages, enable the loader by default, or extend the runtime registry's query capabilities are separate review decisions and remain decoupled from the Cordis catalog migration. diff --git a/docs/typert-catalog-integration-design.zh.md b/docs/typert-catalog-integration-design.zh.md new file mode 100644 index 0000000000..abaddfe1f7 --- /dev/null +++ b/docs/typert-catalog-integration-design.zh.md @@ -0,0 +1,133 @@ +# Typert catalog 接入设计 + +[English](typert-catalog-integration-design.md) | 中文 + +## 现状与问题 + +Typert 已经具备独立的 host/client `FaceModel`、可显式跨 face 引用的 `TypeGraph`,以及 service、event、`@typert object`、泛型、继承和 External 类型的分析能力。TypeScript compiler API 只应负责把源码转换成这套标准模型;后续消费者不应再次遍历 TypeScript AST。 + +仓库目前有两条直接分析 TypeScript 源码的 catalog 链路:`tool-cordis` 使用的静态 API catalog,以及 `docs/cordis-catalog/events.md`、`docs/cordis-catalog/services.md` 的生成与 freshness gate。它们分析的是同一批 service、event 和相关类型,却分别维护收集与渲染逻辑,不能证明 Typert 模型足以承载现有业务语义。 + +第一阶段的目标是让这两条链路共同消费 Typert 模型,并保持三份已提交产物与迁移前字符级一致: + +- `docs/cordis-catalog/events.md` +- `docs/cordis-catalog/services.md` +- `packages/cordis/tool-cordis/src/api-catalog.ts` + +本阶段不要求业务插件发布 Typert 子路径,不要求示例应用加载 Typert,也不改变 `tool-cordis` 的运行时依赖关系。 + +## 可选路径 + +### 运行时 registry 驱动 `tool-cordis` + +每个插件发布并加载 Typert 产物,`tool-cordis` 再从 `ctx.typert` 读取当前运行时模型。这条路径可以反映实际加载的插件集合,但会要求所有参与 catalog 的业务包增加 package exports、生成产物、registry contribution 和应用装配,接入面远大于当前要验证的分析能力。 + +### 全仓发布 Typert 产物后静态汇总 + +所有业务包在普通 build/typecheck 中生成 host/client JS 与 DTS,再由 catalog 生成器汇总这些产物。这条路径能够提前建立完整的发布协议,但会同时修改大量 package manifest 和构建拓扑,使 catalog 迁移与 Typert 的全仓发布绑定。 + +### 构建期分析后投影 catalog + +`WorkspaceAnalyzer` 从 host TypeScript project 构建 `WorkspaceModel` 与 `TypeGraph`,仓库专用的 `CordisCatalogProjector` 只消费该模型并生成三份文本。`tool-cordis` 继续导入已提交的静态 `api-catalog.ts`,运行时不需要 Typert service。 + +本阶段采用构建期投影。它直接验证 Typert 标准模型能否替代现有 AST collector,同时把运行时 publication 和自动加载留在独立的后续决策中。 + +## 第一阶段架构 + +```text +tsconfig.host.json + │ + ▼ +WorkspaceAnalyzer ── TypeScript compiler API 的唯一边界 + │ + ▼ +WorkspaceModel + TypeGraph + │ + ▼ +CordisCatalogProjector ── 不依赖 TypeScript AST + ├── docs/cordis-catalog/events.md + ├── docs/cordis-catalog/services.md + └── packages/cordis/tool-cordis/src/api-catalog.ts +``` + +各对象的职责如下: + +- `WorkspaceAnalyzer` 负责 package、export、service、event、类型声明和引用关系的分析,并产生 compiler-independent model。 +- `WorkspaceModel` 与 `TypeGraph` 是所有生成和扫描分析共用的标准数据结构,保留开发者写出的泛型、继承和类型树,不保存 TypeScript AST。 +- `@deepseek-ai/dsh-typert-generator` 根入口导出的 `CordisCatalogProjector` 负责模型驱动的选择、排序、摘要、源位置、JSDoc 完整性、类型链接闭包和三种文本格式;实现仍单独放在 Cordis catalog 专用文件中,但不形成额外的 package subpath,也不内置仓库类型名单。 +- `scripts/gen-cordis-catalog.ts` 提供 `LINK_MAP`、`FOUNDATION_TYPE_NAMES`、`TYPE_LINK_EXEMPTIONS` 和 inherited Cordis 清单,通过 `CordisCatalogPolicy` 显式注入 projector,并负责 write/check 的命令行行为;vendor Cordis core 页面仍由独立的 pinned-source projector 生成。 +- `tool-cordis` 只导入静态 `api-catalog.ts`,不依赖 `typert-registry` 或 `typert-loader`。 + +`CordisCatalogProjector` 是仓库业务消费者,不进入 Typert 通用模型。新增其他类别时,先扩展标准模型,再增加对应 projector;Typert analyzer 不吸收 Cordis 文档格式或 `tool-cordis` 展示逻辑。 + +## 模型补充 + +Catalog 的字符级投影除了类型结构,还需要开发者写下的声明形式和精确源码位置。标准模型因此保留 event/service location、event/member 的 body-free text、parameter initializer,以及 type declaration 的 export 状态和 canonical text;`SourceDeclarationModel` 另外索引顶层导出声明,供歧义检查和静态类型闭包使用,但不把它们提升为业务 graph root。 + +```ts +interface SourceLocation { + readonly file: string + readonly line: number + readonly column: number +} + +interface EventModel { + readonly location: SourceLocation + readonly text: string +} +``` + +全仓分析支持按 package 分批构建有界 `ts.Program`,再依靠源码位置稳定的 graph id 合并为与一次性分析等价的 face model。该能力只改变 compiler program 的内存边界,不改变 package、declaration 或 type graph 语义。 + +projector 所需信息必须来自 `WorkspaceModel` 或 `TypeGraph`。如果字符级兼容需要的事实无法从模型表达,应补充标准模型;不得在 projector 或脚本中重新引入 `ts.Node`、`ts.Symbol` 或 `ts.TypeChecker`。 + +## 字符级迁移 oracle + +迁移前,在同一份源码状态下保留旧生成器产生的三份文本。迁移后运行新的 analyzer 与 projector,要求三份输出逐字节相等;换行、空格、排序、JSDoc、source pointer 和生成头都属于比较内容。 + +`pnpm run verify-cordis-catalog` 的 `--check` 模式继续读取三份 committed artifact,并与本次计算结果直接比较。任一文件缺失或任一字符不同都视为 stale,错误信息指向统一的 `pnpm run gen-cordis-catalog` 修复命令。 + +测试同时固定以下两层: + +- Typert fixture snapshots 固定 `WorkspaceModel`、`TypeGraph`、JS、DTS 与 Zod 输出,证明标准模型和通用 emitter 的行为。 +- Cordis catalog 测试或 snapshot 固定 projector 的三份完整文本,证明仓库业务投影没有绕过标准模型,并给出可直接评审的文本证据。 + +三份 committed artifact 是旧实现与新实现的迁移 oracle,也是迁移完成后的持续 freshness oracle。旧 `gen-cordis-api` AST collector 被删除;同名脚本和命令只作为统一 projector 的兼容入口保留,因为生成文件头本身包含该命令,保留入口可以维持字符级 oracle 而不产生第二套真源。 + +## 精确改造清单 + +### Typert generator + +- 补齐字符级投影所需的 location、authored declaration text、parameter initializer、export 状态和顶层 source declaration index,并在 analyzer 与 model snapshots 中覆盖。 +- 支持有界 package batch 分析,并证明 direct 与 batched model 等价。 +- 确认 catalog 所需的 service 声明、public instance member、JSDoc、泛型、继承和引用类型均可从 model 读取。 +- 保持 TypeScript compiler API 封装在 analyzer 内;公共 model 和 projector 输入不暴露 compiler 对象。 + +### Cordis catalog projector + +- 从 host `WorkspaceModel` 选择完整的 Cordis service/event 集合。 +- 保留旧生成器的 JSDoc 规则:event 必须有 `@mode` 和 payload `@param`,service method 必须有参数对应的 `@param`,非 void 返回必须有 `@returns`。 +- 从 type graph 计算签名涉及的类型链接和 `tool-cordis` 所需的传递 public type closure。 +- 通过显式 `CordisCatalogPolicy` 接收调用方维护的类型分类和 inherited surface,不在 generator 包内维护仓库文档 taxonomy。 +- 保留 source pointer、签名、摘要、排序、声明截断和 inherited context catalog 的既有输出规则。 +- 一次投影并渲染 events Markdown、services Markdown 与 TypeScript API catalog,避免文档和工具数据漂移。 + +### 命令与消费方 + +- `scripts/gen-cordis-catalog.ts` 维护仓库 policy 数据、组装 analyzer/projector,并同时 write/check 三份产物;解析、校验和渲染逻辑位于 generator 的 Cordis 专用源文件,并统一从 package 根入口导出。 +- 将 `scripts/gen-cordis-api.ts` 收窄为统一 CLI 的无逻辑兼容入口;根目录的 `gen-cordis-api`、`verify-cordis-api` aliases 指向该入口。 +- `tool-cordis` 恢复静态 catalog 默认值,移除对 `ctx.typert`、`typert-registry` 和运行时 package model 完整性的依赖。 +- `gen-doc-graphs` 一次取得 projector 的 model-level 结果并复用 services/events,不能继续导入 AST collector 或重复分析全仓。 + +### 收窄本阶段改动面 + +- 撤销业务插件 package.json 中新增的 `./typert`、`./client/typert` exports 和 `lib/typert.*` files。 +- 撤销 examples 中的 `typert-registry`、`typert-loader` 装配。 +- 普通 build/typecheck 不运行全仓 `gen-typert`,也不要求 clean tree 预先存在业务包 Typert artifact。 +- 保留 `packages/typert/generator`、`packages/typert/registry`、`packages/typert/loader` 及其独立 fixture、emitter 和 runtime registration 测试。 + +## 后续扩展 + +Runtime registry 继续作为生成 JS/Zod 后的接收与查询层,loader 继续作为自动装载机制;两者不承担第一阶段静态 catalog 的数据来源。业务包需要运行时反射时,可以按 package opt-in 发布 `package/typert` 与 `package/client/typert`,再由 loader 注册到 `ctx.typert`。 + +后续接入不改变本阶段的分层:TypeScript 只进入 analyzer,标准模型同时服务静态生成与扫描分析,runtime artifact 由 emitter 从同一模型产生。是否把更多 package 接入 publication、是否默认启用 loader,以及 runtime registry 最终提供哪些查询能力,分别评审,不与 Cordis catalog 迁移捆绑。 diff --git a/eslint.config.mjs b/eslint.config.mjs index 696b082828..7098d8b9c3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -185,4 +185,17 @@ export default tseslint.config( '@stylistic/max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }], }, }, + + // TypeGraph coverage must retain source-authored syntax that production lint rules forbid. + { + files: ['packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts'], + rules: { + '@stylistic/quotes': 'off', + '@typescript-eslint/no-deprecated': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-mixed-enums': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unnecessary-type-parameters': 'off', + }, + }, ) diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index f5b73d9b6d..647d829b4f 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/README.md README.md: 7f12178d1b67f1ebfac6f4f0e31403c54106e98f -README.zh.md: 72ab92602d0a53cabdbfa8bc34838061df25d1c7 +README.zh.md: c7c1bf76593661616464558e554d57340d7c03b1 diff --git a/examples/README.zh.md b/examples/README.zh.md index 72ab92602d..c7c1bf7659 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 +展示 harness 如何组装的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 ## headless-agent -非交互式 agent(智能体)演示:接受一个位置任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 +非交互式 agent(智能体)演示:接受一个位置参数形式的任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。 @@ -18,18 +18,18 @@ ## jsonrpc-agent -通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill 和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 +通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill(技能)和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 ## cordis-agent -**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时 Plugin(事件监听器、一个全新工具,或一个供另一临时 Plugin 注入的服务),并再次卸载它们。这些 Plugin 只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 +**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时插件(事件监听器、一个全新工具,或一个供另一个临时插件注入的服务),并再次卸载它们。这些插件只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 -使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note(agent 决策记录)](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## acp-agent -作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 +一个通过 JSON-RPC stdio 公开、作为 **Agent Client Protocol (ACP)** 自动化服务器运行的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode acp` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。 -默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;范围更广的重试会通过 ACP 成为一次性机器权限请求。 +默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;请求更广泛沙箱权限的重试会通过 ACP 触发一次性的机器权限请求。 diff --git a/examples/acp-agent/README.i18n.yaml b/examples/acp-agent/README.i18n.yaml index 391967d91c..3b208eb9b5 100644 --- a/examples/acp-agent/README.i18n.yaml +++ b/examples/acp-agent/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write examples/acp-agent/README.md README.md: 0d63ec1f2d9165b9faf0817bd94fbe15b97fa961 -README.zh.md: 0c5f8866ea640843513fd9a4c15a17ed4db59d3b +README.zh.md: 84482aad8352ab38527dcf4d9e1bfefc8d496c91 diff --git a/examples/acp-agent/README.zh.md b/examples/acp-agent/README.zh.md index 0c5f8866ea..84482aad83 100644 --- a/examples/acp-agent/README.zh.md +++ b/examples/acp-agent/README.zh.md @@ -2,29 +2,29 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 提供的自动化导向 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。它面向父 agent(智能体)、subagent 提供方和其他程序化客户端,而非产品 UI。 +通过 JSON-RPC stdio 提供的面向自动化的 [Agent Client Protocol(ACP)](https://agentclientprotocol.com) 服务器。它面向 parent agent(父智能体)、subagent 提供方和其他程序化客户端,而非产品 UI。 ```sh pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport ``` -该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK,[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture 服务器。 +该叶节点加载 ACP 应用、DeepSeek 适配器、受沙箱限制的 bash 与文件系统栈、一次性批准策略、压缩(compaction)、subagent、工作流、钩子、派生会话查询索引和重复守卫。应用为每次 `session/new` 创建一个新 agent,将会话持久化到 JSONL,并保持 stdout 只含协议内容。[`session-query.cordis.yml`](session-query.cordis.yml) 为其专用快照显式选用 workspace 授权的查询工具和通用超时/溢出策略;[`fs.cordis.yml`](fs.cordis.yml) 为文件系统场景添加溢出存储,[`code-mode.cordis.yml`](code-mode.cordis.yml) 添加 `run_code` 及其生成的 TypeScript SDK,[`web.cordis.yml`](web.cordis.yml) 则为 web-fetch 快照添加 web seam、本地抓取提供方、`web_fetch` 与一个回环 HTML fixture(测试前置数据)服务器。 ## 协议通道 -Stdout 只携带以换行分隔的 ACP JSON-RPC。`@deepseek-ai/dsh-acp-demo` 不安装 stdout logger;叶节点的附加项必须使用 stderr 输出诊断信息。 +Stdout 只携带以换行分隔的 ACP JSON-RPC。`@deepseek-ai/dsh-acp-demo` 不安装 stdout logger;该叶节点新增的组件必须使用 stderr 输出诊断信息。 自动化契约(支持的方法、基线提示词内容、已提交文本输出,以及有意缺少的 UI 界面)位于 [`@deepseek-ai/dsh-acp`](../../packages/acp/acp/README.md)。 ## 会话 workspace 与权限 -每次 `session/new` 都提供一个绝对 `cwd`。受沙箱限制的 bash 与文件系统变更会根据该会话 cwd 解析 `workspace-write`,因此并发会话可以使用不同的项目根目录;平台临时根目录仍是共享可写暂存空间(参见[沙箱契约](../../packages/sandbox/sandbox/README.md))。`DSH_PERMISSION_MODE` 在部署和测试中选择 `workspace-write` 或 `danger-full-access`。 +每次 `session/new` 都提供一个绝对 `cwd`。受沙箱限制的 bash 和文件系统修改会以该会话 cwd 为基准应用 `workspace-write`,因此并发会话可以使用不同的项目根目录;平台临时根目录仍是共享可写暂存空间(参见[沙箱契约](../../packages/sandbox/sandbox/README.md))。`DSH_PERMISSION_MODE` 在部署和测试中选择 `workspace-write` 或 `danger-full-access`。 -在 `workspace-write` 下,模型请求扩大沙箱权限的重试会触发 `session/request_permission`,选项为 `allow_once` 和 `reject_once`。客户端以程序方式决策;解除对话框或答案不可用时会失败闭合。选定结果仅适用于该次重试,并通过常规工具结果/审计路径记录。服务器绝不公开权限选择器,也不持久化客户端策略。 +在 `workspace-write` 下,如果模型重试请求更广泛的沙箱访问权限,就会触发 `session/request_permission`,选项为 `allow_once` 和 `reject_once`。客户端以程序方式决策;客户端放弃选择或无法给出答复时,系统会按拒绝处理。选定结果仅适用于该次重试,并通过常规工具结果/审计路径记录。服务器绝不公开权限选择器,也不持久化客户端策略。 ## 快照测试 -此示例拥有 ACP 快照套件。它会启动真实自动化服务器,通过 `dsh-llm-replay` 回放已提交的模型流,并比较规范化后的协议输出与重新持久化的会话日志。录制使用真实模型;刷新会复用已提交的回放输入。覆盖场景包括抛出/挂起行为,可选 `workspace/` fixture(测试前置数据)则为外部状态检查预置环境。 +此示例拥有 ACP 快照套件。它会启动真实自动化服务器,通过 `dsh-llm-replay` 回放已提交的模型流,并比较规范化后的协议输出与重新持久化的会话日志。录制使用真实模型;刷新会复用已提交的回放输入。覆盖配置涵盖抛错/挂起行为,可选的 `workspace/` fixture 则为环境状态检查预置状态。 大多数场景锁定后端行为,而非 ACP 专用行为;[仅面向自动化的 ACP 决策](../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)说明了为何该覆盖仍与传输层耦合。 diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl index c8da831f95..ac4615c377 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.jsonl @@ -1 +1 @@ -{"type":"session","version":0,"id":"goal-session-placeholder","createdAt":0,"cwd":"/tmp/goal-session-placeholder"} +{"type":"session","version":0,"id":"goal-session-placeholder","createdAt":0,"cwd":"{{cwd}}"} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 1add24f9e4..7ca8f10e3e 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"64837546-93f0-46bd-83ec-2649c2497663"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 2fd6a59148..c41e5a26b8 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"043ede8b-08c4-4148-8bca-e2e82337c799"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index 464bf95da6..f30effe77c 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"4e4ce615-aa57-45de-8dd5-971a72d988ac"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl index 32d20f3d98..2b01cee430 100644 --- a/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/tmp/acp-snap-cwd-mrFUuk","delegationDepth":0} +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"798335c8-fbbf-4eef-a5af-de47d230b7eb"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352050753,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index 05c2bc0213..2088815247 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"2e3b6a68-ed7b-4263-93a8-e9ffbf77b457","createdAt":1785014504343,"cwd":"/tmp/acp-snap-cwd-gRpiz3","delegationDepth":0} +{"type":"session","version":0,"id":"2e3b6a68-ed7b-4263-93a8-e9ffbf77b457","createdAt":1785014504343,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785014504349,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014504350,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"},"role":"user","id":"87f8c6e9-fdbb-4b1a-b94d-f155aae58149"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014504359,"data":{"title":"Call the run_code tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl index 2e76031380..0c9b180e65 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"cafeb691-a146-424a-8016-52f51b0aaaa4","createdAt":1785014439563,"cwd":"/tmp/acp-snap-cwd-as7fsu","delegationDepth":0} +{"type":"session","version":0,"id":"cafeb691-a146-424a-8016-52f51b0aaaa4","createdAt":1785014439563,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785014439576,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014439577,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"},"role":"user","id":"41779665-2808-4d84-a0a6-0ee5cb76fb06"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014439584,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl index 3e82feaa13..84d7253a2d 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b1e35a14-a592-44e6-bf23-b2496ad2bf7b","createdAt":1785014475001,"cwd":"/tmp/acp-snap-cwd-muJYhO","delegationDepth":0} +{"type":"session","version":0,"id":"b1e35a14-a592-44e6-bf23-b2496ad2bf7b","createdAt":1785014475001,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785014475014,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014475015,"data":{"content":[{"type":"text","text":"Using ONE run_code program, call tools.read on nested/task.txt. After the program finishes, answer the workspace handshake question using the newly discovered instructions: What is the Code Mode workspace handshake?"}],"source":{"kind":"user"},"role":"user","id":"6d0020b8-1a0e-489d-a2a2-7e820a403324"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014475022,"data":{"title":"Using ONE run_code program, call","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -16,8 +16,8 @@ {"type":"assistant/message","seq":101,"time":1785122256269,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read the file `nested/task.txt` using a `run_code` program, and then answer the question \"What is the Code Mode workspace handshake?\" based on the contents of that file."},{"type":"tool-call","id":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"497356eb-0561-4849-8d2a-02bebadcd432"},"usage":{"inputTokens":6200,"outputTokens":130,"cacheReadTokens":0,"reasoningTokens":46}},"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,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100],"surfaceOp":"append"} {"type":"tool/call","seq":102,"time":1785122256269,"data":{"turn":1,"step":1,"callId":"call_00_hD8d0VcXXFVMtn64GSoC9264","name":"run_code","arguments":"{\"code\": \"\\nconst result = await tools.read({ file_path: \\\"nested/task.txt\\\" });\\nreturn result;\\n\", \"description\": \"Read nested/task.txt\"}"}} {"type":"tool/code-dispatch-start","seq":103,"time":1785122256332,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"}}} -{"type":"tool/code-dispatch","seq":104,"time":1785122256336,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"/tmp/acp-snap-cwd-muJYhO/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} -{"type":"tool/result","seq":105,"time":1785122256338,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hD8d0VcXXFVMtn64GSoC9264"},"content":[{"type":"tool-result","toolCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"/tmp/acp-snap-cwd-muJYhO/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"f4e7e1b2-b629-4719-b7bd-86c896c69363"}},"sourceEventSeqs":[102],"surfaceOp":"append"} +{"type":"tool/code-dispatch","seq":104,"time":1785122256336,"data":{"parentCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","subCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264:code:1","name":"read","arguments":{"file_path":"nested/task.txt"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: Touch this file to discover the nested workspace instruction.\n\n(End of file - total 1 lines)\n"}]}} +{"type":"tool/result","seq":105,"time":1785122256338,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hD8d0VcXXFVMtn64GSoC9264"},"content":[{"type":"tool-result","toolCallId":"call_00_hD8d0VcXXFVMtn64GSoC9264","content":[{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}],"isError":false}],"role":"user","id":"f4e7e1b2-b629-4719-b7bd-86c896c69363"}},"sourceEventSeqs":[102],"surfaceOp":"append"} {"type":"user/message","seq":106,"time":1785122256338,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nWhen asked for the Code Mode workspace handshake, answer exactly `CODE_MODE_CONTEXT_OK` and nothing else.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"ae22936ed26dc76b7107005ed6d5e2482a88668a"}]},"role":"user","id":"90d60955-ebee-408a-8d12-41a305b3bf99"},"surfaceOp":"append"} {"type":"step/end","seq":107,"time":1785122256338,"data":{"turn":1,"step":1}} {"type":"step/start","seq":108,"time":1785122256347,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 1d41f4aa7e..042753f1a7 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"/tmp/cordis-inspect-jsdoc","delegationDepth":0} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784449176717,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784449176718,"data":{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"},"role":"user","id":"48efc8f5-a397-491b-b7a1-179a1185ac2f"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784449176718,"data":{"title":"Inspect the exact tools service","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 5233644d6e..0985cbd3de 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-cbBLh2","delegationDepth":0} +{"type":"session","version":0,"id":"f3cbd087-fb45-4b32-b0f2-3082d65bfcb4","createdAt":1783860675270,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860675271,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784821261714,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will approve the permission prompt. After the result, reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"8fcf378f-b720-4a86-be32-95ddec1651c3"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784821261714,"data":{"title":"The sandbox already denied writing","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 85eda59e85..35aebd255b 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-Hn29Od","delegationDepth":0} +{"type":"session","version":0,"id":"d692fe7f-7079-4ee4-8b06-f44fd026d4ea","createdAt":1783860679475,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783860679476,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784821263241,"data":{"content":[{"type":"text","text":"The sandbox already denied writing /tmp/dsh-escalated.txt earlier (it is outside this workspace). Retry it now exactly once: one single bash call with the command printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt, with sandbox_permissions set to danger-full-access and the justification 'the user asked to write a file outside the workspace'. Do not run it without sandbox_permissions first. I will reject the permission prompt; after the rejection, do not retry and do not work around it — explain in one short sentence and stop."}],"source":{"kind":"user"},"role":"user","id":"1f206016-2423-4b51-80bb-df15468298c5"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784821263241,"data":{"title":"The sandbox already denied writing","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index ae51a7db46..784b6c17c4 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"736c4bd8-41bd-43fb-9030-b4df3b2a4f83","createdAt":1783352084735,"cwd":"/tmp/acp-snap-cwd-0BxHdV","delegationDepth":0} +{"type":"session","version":0,"id":"736c4bd8-41bd-43fb-9030-b4df3b2a4f83","createdAt":1783352084735,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352084740,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352084740,"data":{"content":[{"type":"text","text":"First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"6b1ee31e-9c1a-41f3-9647-153d6d98e1a5"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352084740,"data":{"title":"First use the read tool","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":68,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":69,"time":1783352086059,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"36ebf262-429c-4398-abbc-a197e2522f1d"},"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"} {"type":"tool/call","seq":70,"time":1783352086059,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} -{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"/tmp/acp-snap-cwd-0BxHdV/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"}},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"}},"sourceEventSeqs":[70],"surfaceOp":"append"} {"type":"step/end","seq":72,"time":1783352086065,"data":{"turn":1,"step":1}} {"type":"step/start","seq":73,"time":1783352086066,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":74,"time":1783352086901,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -27,7 +27,7 @@ {"type":"assistant/chunk","seq":128,"time":1783352087469,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":129,"time":1783352087469,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"c9b571e1-3a63-4a97-af3e-41ac1bdc8e24"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128],"surfaceOp":"append"} {"type":"tool/call","seq":130,"time":1783352087469,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_vOytneZ0XpsLslEEJAxR6398"},"content":[{"type":"tool-result","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /private/tmp/acp-snap-cwd-0BxHdV/config.txt has been updated successfully."}],"isError":false}],"role":"user","id":"79abf084-e65e-468c-84aa-2d3550cb50b8"},"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} +{"type":"tool/result","seq":131,"time":1783352087476,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_vOytneZ0XpsLslEEJAxR6398"},"content":[{"type":"tool-result","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file {{cwd}}/config.txt has been updated successfully."}],"isError":false}],"role":"user","id":"79abf084-e65e-468c-84aa-2d3550cb50b8"},"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[130],"surfaceOp":"append"} {"type":"step/end","seq":132,"time":1783352087477,"data":{"turn":1,"step":2}} {"type":"step/start","seq":133,"time":1783352087477,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":134,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 52084c3db7..79ef6a1131 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd","delegationDepth":0} +{"type":"session","version":0,"id":"977a4820-f609-4b48-9039-adcdd921c5fe","createdAt":1784045702340,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784045702342,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784821264846,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create escalated.md in the current directory containing exactly the single line: escalated. An equivalent write was denied earlier, so make this one single write call with sandbox_permissions set to danger-full-access and the justification 'the user asked to escalate this write'. Do not call write without sandbox_permissions first. I will approve the permission prompt. After the result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"e4d528b4-0dd8-4aa9-853e-3d00f25b31aa"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784821264846,"data":{"title":"Use the write tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -16,7 +16,7 @@ {"type":"tool/call","seq":87,"time":1784821264893,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} {"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"aecaceb0-23b7-4cd5-b7a1-17bc431dc35a","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} {"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"aecaceb0-23b7-4cd5-b7a1-17bc431dc35a","outcome":"allowed-once"}} -{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"830d87a2-e325-430d-a463-0911e9512bab"},"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} +{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"{{cwd}}/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"830d87a2-e325-430d-a463-0911e9512bab"},"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"} {"type":"step/end","seq":91,"time":1784821264911,"data":{"turn":1,"step":1}} {"type":"step/start","seq":92,"time":1784821264912,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":93,"time":1784821264916,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 65ebc70d21..d934a2d7be 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b3292503-2c3d-4677-804d-1ed6802a4bc5","createdAt":1783611702544,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB","delegationDepth":0} +{"type":"session","version":0,"id":"b3292503-2c3d-4677-804d-1ed6802a4bc5","createdAt":1783611702544,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783611702550,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611702550,"data":{"content":[{"type":"text","text":"Do NOT use the read tool and do NOT use bash or shell commands. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"c367f2cd-f9b5-44a4-a363-fdb97d469ad2"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783611702550,"data":{"title":"Do NOT use the read","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":76,"time":1783611703969,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":77,"time":1783611703972,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"db6924d3-7ca0-4a50-9bec-9f976b1f493d"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76],"surfaceOp":"append"} {"type":"tool/call","seq":78,"time":1783611703972,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\" first"}],"isError":true}],"role":"user","id":"787330b6-f223-41d6-831e-ce2b14d0e820"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} +{"type":"tool/result","seq":79,"time":1783611703978,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"787330b6-f223-41d6-831e-ce2b14d0e820"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[78],"surfaceOp":"append"} {"type":"step/end","seq":80,"time":1783611703978,"data":{"turn":1,"step":1}} {"type":"step/start","seq":81,"time":1783611703978,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":82,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -27,7 +27,7 @@ {"type":"assistant/chunk","seq":143,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":144,"time":1783611705573,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."},{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"de588e3c-b10c-4eee-93a5-26e9a665dcbc"},"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143],"surfaceOp":"append"} {"type":"tool/call","seq":145,"time":1783611705573,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}} -{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"}},"sourceEventSeqs":[145],"surfaceOp":"append"} +{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"{{cwd}}/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"}},"sourceEventSeqs":[145],"surfaceOp":"append"} {"type":"step/end","seq":147,"time":1783611705579,"data":{"turn":1,"step":2}} {"type":"step/start","seq":148,"time":1783611705579,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":149,"time":1783611706200,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -40,7 +40,7 @@ {"type":"assistant/chunk","seq":224,"time":1783611707096,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":225,"time":1783611707097,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0e331a4c-4e8d-4daf-a909-80d78a03bdf7"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224],"surfaceOp":"append"} {"type":"tool/call","seq":226,"time":1783611707097,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /private/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-QzoqnB/settings.txt has been updated successfully."}],"isError":false}],"role":"user","id":"e431a509-587b-49fa-8c84-7a6c92e2a014"},"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} +{"type":"tool/result","seq":227,"time":1783611707114,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file {{cwd}}/settings.txt has been updated successfully."}],"isError":false}],"role":"user","id":"e431a509-587b-49fa-8c84-7a6c92e2a014"},"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[226],"surfaceOp":"append"} {"type":"step/end","seq":228,"time":1783611707114,"data":{"turn":1,"step":3}} {"type":"step/start","seq":229,"time":1783611707114,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":230,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl index de2066dc05..72cb3a5200 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b5639b9d-99a9-49e4-83da-77e6caa702be","createdAt":1783352099834,"cwd":"/tmp/acp-snap-cwd-N9HCkt","delegationDepth":0} +{"type":"session","version":0,"id":"b5639b9d-99a9-49e4-83da-77e6caa702be","createdAt":1783352099834,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352099838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352099839,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"80cf70ac-0b37-401a-96d2-c54056300cd4"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352099839,"data":{"title":"Use the read tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":90,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":91,"time":1783352101348,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."},{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5620412c-8fae-4d17-aac4-0801f3b02461"},"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"} {"type":"tool/call","seq":92,"time":1783352101348,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} -{"type":"tool/result","seq":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"/tmp/acp-snap-cwd-N9HCkt/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}],"isError":false}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"}},"sourceEventSeqs":[92],"surfaceOp":"append"} +{"type":"tool/result","seq":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"{{cwd}}/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}],"isError":false}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"}},"sourceEventSeqs":[92],"surfaceOp":"append"} {"type":"step/end","seq":94,"time":1783352101353,"data":{"turn":1,"step":1}} {"type":"step/start","seq":95,"time":1783352101354,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":96,"time":1783352102021,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index 5f19c1b74b..82adec999d 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"a57f852d-d476-4716-a380-8a1116e4d905","createdAt":1783352072464,"cwd":"/tmp/acp-snap-cwd-PEETkS","delegationDepth":0} +{"type":"session","version":0,"id":"a57f852d-d476-4716-a380-8a1116e4d905","createdAt":1783352072464,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352072468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352072469,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"7396fa9a-4068-42a6-b153-2b5ade098d32"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352072469,"data":{"title":"Use the read tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":52,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":53,"time":1783352073708,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."},{"type":"tool-call","id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"5452254c-4843-458c-9732-12fe8b7c1468"},"usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"} {"type":"tool/call","seq":54,"time":1783352073709,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"/tmp/acp-snap-cwd-PEETkS/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"}},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"}},"sourceEventSeqs":[54],"surfaceOp":"append"} {"type":"step/end","seq":56,"time":1783352073718,"data":{"turn":1,"step":1}} {"type":"step/start","seq":57,"time":1783352073719,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":58,"time":1783352074666,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index 6da33759a6..e46bcfa17c 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e04cc262-6c89-4586-88d7-3e919240d735","createdAt":1783352092215,"cwd":"/tmp/acp-snap-cwd-hH2sGY","delegationDepth":0} +{"type":"session","version":0,"id":"e04cc262-6c89-4586-88d7-3e919240d735","createdAt":1783352092215,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352092220,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352092221,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"5e890158-f455-445a-b265-e0cd1b18af36"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352092221,"data":{"title":"First use the read tool","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":64,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":65,"time":1783352093617,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""},{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"00272d0c-8ed0-436a-8d10-4a7091447dfe"},"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64],"surfaceOp":"append"} {"type":"tool/call","seq":66,"time":1783352093617,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} -{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"}},"sourceEventSeqs":[66],"surfaceOp":"append"} +{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"}},"sourceEventSeqs":[66],"surfaceOp":"append"} {"type":"step/end","seq":68,"time":1783352093624,"data":{"turn":1,"step":1}} {"type":"step/start","seq":69,"time":1783352093625,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":70,"time":1783352094455,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -27,7 +27,7 @@ {"type":"assistant/chunk","seq":113,"time":1783352094988,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":114,"time":1783352094988,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"17d77822-5c76-4c09-acb6-8ff891129da8"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":115,"time":1783352094988,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_N23EvXjDo4c8enyWpIUq4043"},"content":[{"type":"tool-result","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/private/tmp/acp-snap-cwd-hH2sGY/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"2b85c946-b10f-4317-bbf1-e86e5072a4d0"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} +{"type":"tool/result","seq":116,"time":1783352094995,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_N23EvXjDo4c8enyWpIUq4043"},"content":[{"type":"tool-result","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"2b85c946-b10f-4317-bbf1-e86e5072a4d0"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[115],"surfaceOp":"append"} {"type":"step/end","seq":117,"time":1783352094995,"data":{"turn":1,"step":2}} {"type":"step/start","seq":118,"time":1783352094995,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":119,"time":1783352096090,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 1dcc0af155..8d58e22ecc 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"fdcab4d0-e5e4-4a06-9195-be8f7049d67e","createdAt":1783352078749,"cwd":"/tmp/acp-snap-cwd-sNvn5N","delegationDepth":0} +{"type":"session","version":0,"id":"fdcab4d0-e5e4-4a06-9195-be8f7049d67e","createdAt":1783352078749,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352078754,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352078754,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"f11a6473-4b11-4205-a73a-edd879e1ec56"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352078754,"data":{"title":"Use the write tool (NOT","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":61,"time":1783352079886,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":62,"time":1783352079888,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"6a58e768-f922-4850-832e-bfb43d4ab4fc"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61],"surfaceOp":"append"} {"type":"tool/call","seq":63,"time":1783352079888,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/private/tmp/acp-snap-cwd-sNvn5N/notes.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"f5031700-edf6-4f15-9dd2-1ebeecaeb762"},"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} +{"type":"tool/result","seq":64,"time":1783352079897,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"f5031700-edf6-4f15-9dd2-1ebeecaeb762"},"meta":{"diffs":[]}},"sourceEventSeqs":[63],"surfaceOp":"append"} {"type":"step/end","seq":65,"time":1783352079898,"data":{"turn":1,"step":1}} {"type":"step/start","seq":66,"time":1783352079899,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":67,"time":1783352080825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index b7fd854b5a..a43c898146 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-WxJGUY","delegationDepth":0} +{"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783962504115,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783962504117,"data":{"content":[{"type":"text","text":"Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop."}],"source":{"kind":"user"},"role":"user","id":"775ddb99-fdd1-404f-ba14-4cc37b6ac2c8"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783962504117,"data":{"title":"Call the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl index 5cb9143a71..8bcd0df48f 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"0a862642-6652-4916-b88d-b058954ab0c6","createdAt":1783352196657,"cwd":"/tmp/acp-snap-cwd-LEetSL","delegationDepth":0} +{"type":"session","version":0,"id":"0a862642-6652-4916-b88d-b058954ab0c6","createdAt":1783352196657,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352196662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352196662,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"b3957310-0893-4e41-88b2-715c102b5a9a"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352196662,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 33df927a74..5e3bdb0217 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"/tmp/acp-snap-cwd-iKVciS","delegationDepth":0} +{"type":"session","version":0,"id":"f688431c-01a8-4326-a5c5-1b5f0fd08483","createdAt":1783352171511,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352171519,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352171520,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"40085b3d-6b87-4b86-859e-b34786c9a12f"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352171520,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl index b5d38ede9e..b956a3f054 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"/tmp/acp-snap-cwd-wDnkVo","delegationDepth":0} +{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"57df50c1-78e1-4b8a-857a-c2ae2192dadd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352165196,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index 62f310d322..0aeb20331c 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"d03c3a83-1238-4e2e-ad9a-b86a61840a40","createdAt":1783352160541,"cwd":"/tmp/acp-snap-cwd-QUDqlk","delegationDepth":0} +{"type":"session","version":0,"id":"d03c3a83-1238-4e2e-ad9a-b86a61840a40","createdAt":1783352160541,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352160545,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785122243327,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"7911469c-1e33-4741-9d32-49ecc6a01f0b"},"surfaceOp":"append"} {"type":"user/message","seq":2,"time":1785122243327,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"},"role":"user","id":"7b887c49-97bd-46f9-aea4-c462d385a8ee"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl index 92c0dc44da..51068cd22e 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"eda79fbc-8a1b-4226-b74a-f5f297484747","createdAt":1784522140642,"cwd":"/var/folders/4j/54c8wb496zxfrs1ny_21jbb00000gn/T/acp-snap-cwd-r6rWZp","delegationDepth":0} +{"type":"session","version":0,"id":"eda79fbc-8a1b-4226-b74a-f5f297484747","createdAt":1784522140642,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784522140646,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784522140647,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"c63da2f2-916d-42cc-8e6f-c9520e1641cd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784522140647,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index 2ede4564c3..67f277d2cd 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-x67BsP","delegationDepth":0} +{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783986962235,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783986962235,"data":{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"},"role":"user","id":"5a3821d5-de5b-4b9c-85b7-d53dca51af5c"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783986962235,"data":{"title":"Call the bash tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl index 1cec7b19c9..b1d297049d 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"39d8aabe-6457-4a0e-83b7-ee33125a3666","createdAt":1783352228436,"cwd":"/tmp/acp-snap-cwd-VGFtPi","delegationDepth":0} +{"type":"session","version":0,"id":"39d8aabe-6457-4a0e-83b7-ee33125a3666","createdAt":1783352228436,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352228441,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352228442,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"7d8954d3-d4e7-4ca6-ba3d-0c5de95a3ace"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352228442,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl index 3ebaa26b9a..97e59bbe8f 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"57a74aed-99fc-43bc-a875-6dddebf64d69","createdAt":1783352214599,"cwd":"/tmp/acp-snap-cwd-7Hbu0m","delegationDepth":0} +{"type":"session","version":0,"id":"57a74aed-99fc-43bc-a875-6dddebf64d69","createdAt":1783352214599,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352214604,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352214605,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"8aab0b74-e7e0-4c3c-90a3-19a81f2b9c6a"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352214605,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index 45d09acac1..5ffc12a991 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"0bebc0f4-a089-4fde-9b6e-db9532cfd4de","createdAt":1783352209682,"cwd":"/tmp/acp-snap-cwd-aopaZV","delegationDepth":0} +{"type":"session","version":0,"id":"0bebc0f4-a089-4fde-9b6e-db9532cfd4de","createdAt":1783352209682,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352209686,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785122250005,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"ea34d65f-e154-4b2a-bea8-3345fdd96658"},"surfaceOp":"append"} {"type":"user/message","seq":2,"time":1785122250006,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"},"role":"user","id":"174d8732-a32f-4eb0-8471-d8b3291a34f2"},"surfaceOp":"append"} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl index 1a9903ce2a..18d6740b2b 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"eb17be12-ca8c-46c8-b500-0977e8400208","createdAt":1784522152392,"cwd":"/var/folders/4j/54c8wb496zxfrs1ny_21jbb00000gn/T/acp-snap-cwd-ESgqLu","delegationDepth":0} +{"type":"session","version":0,"id":"eb17be12-ca8c-46c8-b500-0977e8400208","createdAt":1784522152392,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784522152397,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784522152397,"data":{"content":[{"type":"text","text":"Reply with the single word FIRST and stop."}],"source":{"kind":"user"},"role":"user","id":"c76f1de4-cf89-4f0f-a861-bc699f579f78"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784522152397,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl index 2425d401ad..2cc17bdcb1 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"/tmp/acp-snap-cwd-aN2GRR","delegationDepth":0} +{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"77c88536-5dcd-423c-b2f1-c432d5f057fd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352113765,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl b/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl index 602b78691d..76f43e17c4 100644 --- a/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl +++ b/examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"/tmp/acp-snap-cwd-wDnkVo","delegationDepth":0} +{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"},"role":"user","id":"a597583b-7e90-4d4d-9b6a-bb1ab7617417"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352165196,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl index d3bd535ce1..4c2edbcf5d 100644 --- a/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"session-title-after-turn","createdAt":0,"cwd":"/tmp/session-title-after-turn","delegationDepth":0} +{"type":"session","version":0,"id":"session-title-after-turn","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785222848166,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785222848166,"data":{"content":[{"type":"text","text":"Reply with exactly TITLE_DONE. Do not use tools."}],"source":{"kind":"user"},"role":"user","id":"00000000-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785222848166,"data":{"title":"Reply with exactly TITLE_DONE. Do","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index bab6f80e1c..6fc71b0dd0 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -1,8 +1,8 @@ -{"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"/var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-DhYwNW","delegationDepth":0} +{"type":"session","version":0,"id":"9eb4181f-2d05-49d3-98fc-3711fe2f5664","createdAt":1783654655599,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"9c670f1c-3508-4b98-9cae-21f363652d6e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783654655603,"data":{"title":"Load the snapshot-skill skill with","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"4f537803-7424-41eb-887f-f39676b89187"},"surfaceOp":"append"} +{"type":"user/message","seq":3,"time":1784903324926,"data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}],"source":{"kind":"plugin","plugin":"dsh-tool-skill"},"role":"user","id":"4f537803-7424-41eb-887f-f39676b89187"},"surfaceOp":"append"} {"type":"step/start","seq":4,"time":1784903324927,"data":{"turn":1,"step":1}} {"type":"request/header","seq":5,"time":1784903324928,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1784903324935,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1784903324935,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cc7d430d-d011-4428-8572-0274c6082277"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[6,7,8,9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1784903324936,"data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"snapshot-skill\"}"}} -{"type":"tool/result","seq":16,"time":1784903324944,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: /var/folders/_g/59jgff8x2gqd39f5vy1wnbfc0000gn/T/acp-snap-cwd-DhYwNW/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}],"isError":false}],"role":"user","id":"57ec1e09-b3ba-44df-8da0-bb16e7a33bd8"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1784903324944,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}],"isError":false}],"role":"user","id":"57ec1e09-b3ba-44df-8da0-bb16e7a33bd8"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1784903324944,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1784903324952,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1783654655611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/model-only-skill/SKILL.md b/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/model-only-skill/SKILL.md new file mode 100644 index 0000000000..ac7c02e29f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/model-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: model-only-skill +description: Prove user-disabled skills remain available to the model. +user-invocable: false +--- + +Follow these model-only snapshot instructions. diff --git a/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/user-only-skill/SKILL.md b/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/user-only-skill/SKILL.md new file mode 100644 index 0000000000..4b02550d15 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/skill-load/workspace/.dsh/skills/user-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: user-only-skill +description: Prove model-disabled skills stay outside the model catalog. +disable-model-invocation: true +--- + +Follow these user-only snapshot instructions. diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index c682fec096..9559b5b378 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"/tmp/subagent-depth-two","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1784540790312,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784540790312,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"e1664eb5-480b-4987-a0a3-4fcd85ccb04d"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784540790312,"data":{"title":"Call subagent once. Ask that","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 16394c6e65..a9493cbac8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"/tmp/subagent-depth-two","parentSession":"22222222-2222-4222-8222-222222222222","delegationDepth":2} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"{{cwd}}","parentSession":"22222222-2222-4222-8222-222222222222","delegationDepth":2} {"type":"turn/start","seq":0,"time":1784540790319,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784540790319,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"9299d7d1-85e0-4e05-93e4-34d2cf6bafc8"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784540790319,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl index 9561b6cc4c..ab2d26180c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1000,"cwd":"/tmp/subagent-depth-two","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1784540790290,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1784540790291,"data":{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"},"role":"user","id":"f74eb6a3-3869-4b1c-ba3c-5b6db530ac67"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784540790291,"data":{"title":"Delegate through two child generations.","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index 98299cec2a..6d5b0d9163 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"/tmp/acp-snap-cwd-0HLtcD","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1} +{"type":"session","version":0,"id":"ada8966c-9fa3-441b-8721-37ff1e795e6a","createdAt":1783352137161,"cwd":"{{cwd}}","parentSession":"96cf59c9-b347-48b9-b234-a5200913ad05","seedLength":38,"delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"917c2f1a-be80-4f54-86e8-c94fe6859bdd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl index debea23a22..a0b09e9478 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"96cf59c9-b347-48b9-b234-a5200913ad05","createdAt":1783352134832,"cwd":"/tmp/acp-snap-cwd-0HLtcD","delegationDepth":0} +{"type":"session","version":0,"id":"96cf59c9-b347-48b9-b234-a5200913ad05","createdAt":1783352134832,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352134837,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352134838,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"917c2f1a-be80-4f54-86e8-c94fe6859bdd"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352134838,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index 5b1c5fcf61..fe94af0f52 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"/tmp/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","delegationDepth":1} +{"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352145224,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352145224,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"214ad816-8421-48ff-b501-ca51716d761f"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352145224,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index ce3fd5980d..1b9127889d 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"/tmp/acp-snap-cwd-i43JSF","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1} +{"type":"session","version":0,"id":"02b3a8dd-1d5e-4866-825f-5fbf5000a632","createdAt":1783352147504,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","seedLength":32,"delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"867b46b8-e2fa-4257-a2b1-a8fa12abe782"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl index 65320dbaaf..0ee3d0a595 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"959ffdf5-03e2-465e-9482-009b704632dc","createdAt":1783352142830,"cwd":"/tmp/acp-snap-cwd-i43JSF","delegationDepth":0} +{"type":"session","version":0,"id":"959ffdf5-03e2-465e-9482-009b704632dc","createdAt":1783352142830,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352142834,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352142834,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"867b46b8-e2fa-4257-a2b1-a8fa12abe782"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352142834,"data":{"title":"Remember this fact for later:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index ef7d8fcbd5..ae13a1f327 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"/tmp/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} +{"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352127670,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352127670,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"4088c6ea-4806-4d0a-a5a7-b430ba9fcb7e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352127670,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index f1c2813380..6939aef6c0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"/tmp/acp-snap-cwd-28z5Of","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} +{"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352129662,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352129662,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"804b9ed3-e2ed-495e-9840-8e0f657661fe"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352129662,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl index 3179edb205..303ceb6e6b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"14dda109-5728-45ba-a002-7db9543fe50e","createdAt":1783352126247,"cwd":"/tmp/acp-snap-cwd-28z5Of","delegationDepth":0} +{"type":"session","version":0,"id":"14dda109-5728-45ba-a002-7db9543fe50e","createdAt":1783352126247,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352126251,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352126251,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"26ff1621-20b5-4c1e-b546-ed4c6f6ec99e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352126251,"data":{"title":"Use the subagent tool TWICE,","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 6b107d1c1c..38534a09cf 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"/tmp/acp-snap-cwd-rbeWyt","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","delegationDepth":1} +{"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"{{cwd}}","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783352120856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352120856,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f3a2e52a-cfc3-4f9a-b25a-cb48f61e598e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352120856,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl index 51f0899888..f35595a402 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"5138ed0d-e86e-4a7d-b75b-803307e92b17","createdAt":1783352119267,"cwd":"/tmp/acp-snap-cwd-rbeWyt","delegationDepth":0} +{"type":"session","version":0,"id":"5138ed0d-e86e-4a7d-b75b-803307e92b17","createdAt":1783352119267,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352119273,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352119274,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"36c0b82b-ab96-4985-9b44-8895eeedd725"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352119274,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 56aa46d2fa..348c891312 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-ka5r8w","delegationDepth":0} +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"2da6fcd7-2410-460a-bb8f-bc6491f7b0b0"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl index df70a431bd..429c76226e 100644 --- a/examples/acp-agent/tests/snapshots/todo-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-write/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7","delegationDepth":0} +{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"18c389cb-ab26-4a60-96aa-a1314eab3759"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352057655,"data":{"title":"Use the todo_write tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl index 9107893db5..24ea03494f 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e9421ff4-baae-4807-a7ea-fd8a65f2c897","createdAt":1783352044766,"cwd":"/tmp/acp-snap-cwd-OwUkBh","delegationDepth":0} +{"type":"session","version":0,"id":"e9421ff4-baae-4807-a7ea-fd8a65f2c897","createdAt":1783352044766,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352044771,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352044771,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo SNAPSHOT_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"033e6f20-6021-4ecc-a80f-de758a3dc877"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352044771,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl index f92f59bc11..396860773e 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl +++ b/examples/acp-agent/tests/snapshots/web-fetch/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"c12fa9af-1042-4a92-9ba4-4a968ff23495","createdAt":1785078727712,"cwd":"/tmp/acp-snap-cwd-hqkZWE","delegationDepth":0} +{"type":"session","version":0,"id":"c12fa9af-1042-4a92-9ba4-4a968ff23495","createdAt":1785078727712,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785078727718,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785078727719,"data":{"content":[{"type":"text","text":"Use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly DONE. Do not describe the content."}],"source":{"kind":"user"},"role":"user","id":"6c8e9279-bb26-4369-b425-951cd33d6b15"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785078727721,"data":{"title":"Use the web_fetch tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index 4c00ccc7c1..268c7db0f6 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{cwd}}","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"660a2954-67fc-4406-8703-189f3c0ee81e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600636316,"data":{"title":"Reply with exactly the word","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 700410718c..aa29969cf5 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","delegationDepth":0} +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"7752d242-0fc3-421c-ad28-60333479140c"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783600631838,"data":{"title":"Use the workflow tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl index e5a133dea3..eaee6bd14b 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"48aca674-000a-4583-810b-01f8785cef13","createdAt":1783352264076,"cwd":"/tmp/acp-snap-cwd-rxbEpP","delegationDepth":0} +{"type":"session","version":0,"id":"48aca674-000a-4583-810b-01f8785cef13","createdAt":1783352264076,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352264080,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352264081,"data":{"content":[{"type":"text","text":"A file named greeting.txt in the current directory contains one word. Use the bash tool to append a second line containing the word WORLD to it (so it has two lines), then read the file back with `cat greeting.txt` to confirm, and reply with the single word DONE. Use a single bash call per action."}],"source":{"kind":"user"},"role":"user","id":"77ac6781-b796-4060-b670-63baa39a986b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783352264081,"data":{"title":"A file named greeting.txt in","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -14,7 +14,7 @@ {"type":"assistant/chunk","seq":78,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":79,"time":1783352265491,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."},{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3f154ea9-6cf0-4d0a-a478-503962bfe8e1"},"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"} {"type":"tool/call","seq":80,"time":1783352265491,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} -{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"/tmp/acp-snap-cwd-rxbEpP/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"}},"sourceEventSeqs":[80],"surfaceOp":"append"} +{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"}},"sourceEventSeqs":[80],"surfaceOp":"append"} {"type":"step/end","seq":82,"time":1783352265504,"data":{"turn":1,"step":1}} {"type":"step/start","seq":83,"time":1783352265505,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":84,"time":1783352266385,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/cordis-agent/README.i18n.yaml b/examples/cordis-agent/README.i18n.yaml index f9a03c46fa..4cbfbb67cb 100644 --- a/examples/cordis-agent/README.i18n.yaml +++ b/examples/cordis-agent/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/cordis-agent/README.md README.md: 55970e932bc16d8361932daa9ea55af83ef73d33 -README.zh.md: c2873b6de96a8b47ad8ea4fb2cf03a7501406300 +README.zh.md: a8ec332d8b3673d6656663eb3bfd7d37a4e328f6 diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md index c2873b6de9..a8ec332d8b 100644 --- a/examples/cordis-agent/README.zh.md +++ b/examples/cordis-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时 Plugin,并再次卸载它们。临时 Plugin 可跨 turn 保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他 session。`ctx.fs` 和 `ctx.web` 是这些 Plugin 可用的 provider-only 能力。设计详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时插件,并卸载它们。临时插件可跨轮次保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他会话。`ctx.fs` 和 `ctx.web` 仅以能力提供方形式加载,供这些插件使用。设计详见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## 运行 @@ -15,7 +15,7 @@ pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 pnpm run demo:cordis acp # ACP server ``` -预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身: +预期演示分阶段进行:先验证监听器链路,再让 agent(智能体)扩展自身: ``` > Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. @@ -30,8 +30,8 @@ pnpm run demo:cordis acp # ACP server [tool call] cordis_unmount({"id": "dyn-1"}) ``` -请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写 Plugin 代码所用的生成服务/事件资料。还可挂载两个协作临时 Plugin(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 +请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写插件代码所用的生成服务/事件资料。还可挂载两个协作临时插件(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 ## 端到端测试 -`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态 listener,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时 Plugin。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 +`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析,以及收到 EOF 后正常退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态监听器,测试验证其带标记的控制台输出行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时插件。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 包含相关单元测试,并受逐文件 100% 覆盖率门禁约束。 diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index 7c4dbaf463..c4cfb23631 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write examples/headless-agent/README.md README.md: 445804a2611e5e8093eadf345ad10a2a7984c012 -README.zh.md: 68ec718afe0b2aca276be2689cbae74167ee1c7b +README.zh.md: 956bc82e77f79c3f05e4b51297fd5365e6e89be1 diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md index 68ec718afe..956bc82e77 100644 --- a/examples/headless-agent/README.zh.md +++ b/examples/headless-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -无头单次 agent(智能体)接线:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与新 agent Ralph 迭代 + `todo_write` + JSONL 持久化,并以 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) 作为应用入口。 +无头单次 agent(智能体)接线:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与全新 agent Ralph 迭代 + `todo_write` + JSONL 持久化,并以 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) 作为应用入口。 ## 运行 @@ -15,12 +15,12 @@ pnpm run demo:headless --output-format json -- "summarize the implementation" pnpm run demo:headless --output-format stream-json -- "run the focused tests" ``` -必须提供且只能提供一个非空位置任务;含空格的任务需要加引号。没有 `-p` 标志。`text` 打印最后一条包含文本的 assistant 消息,`json` 打印一条 DSH 原生结果记录,`stream-json` 则在该记录之前发出顶层会话的规范任务轮次事件。子会话只通过父工具事件和结果对外显示。 +必须提供一个且仅一个非空的任务位置参数;含空格的任务需要加引号。没有 `-p` 标志。`text` 打印最后一条包含文本的 assistant 消息,`json` 打印一条 DSH 原生结果记录,`stream-json` 则在该记录之前发出顶层会话的规范任务轮次事件。子会话只通过父会话的工具事件和结果对外显示。 -每次调用都会创建并持久化新会话,在一个轮次中运行所有模型和工具步骤,然后刷新、释放并退出。这是非交互式自动化:没有提示符、批准、恢复、第二轮次或 stdin 上下文。已配置工具可以修改启动 workspace、运行命令、spawn 子 agent,并消耗提供方 token。 +每次调用都会创建并持久化新会话,在一个轮次中运行所有模型和工具步骤,然后刷写持久化数据、执行 dispose(资源释放),再退出。这是非交互式自动化:没有提示符、批准、恢复、第二轮次或 stdin 上下文。已配置工具可以修改启动时所在的工作区、运行命令、spawn 子 agent,并消耗提供方 token。 ## 高级与快照接线 -[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。[`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) 只将实时 LLM(大语言模型)替换为回放。[`tests/`](tests/) 下的测试拥有无密钥真实 Loader 冒烟测试、密钥门控的外部状态验证冒烟测试,以及带父子会话 fixture(测试前置数据)的 `stream-json` 回放快照。 +[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。[`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) 只将实时 LLM(大语言模型)替换为回放。[`tests/`](tests/) 下涵盖无密钥真实 Loader 冒烟测试、密钥门控的外部状态验证冒烟测试,以及带父子会话 fixture(测试前置数据)的 `stream-json` 回放快照。 -包级 [CLI 契约](../../packages/examples/cli-demo/README.md)记录输出记录、退出状态、取消、持久化以及模型/token 影响。 +这份包(package)级 [CLI(命令行界面)契约](../../packages/examples/cli-demo/README.md) 说明输出记录、退出状态、取消、持久化以及模型/token 影响。 diff --git a/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml b/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml new file mode 100644 index 0000000000..fb614a5372 --- /dev/null +++ b/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml @@ -0,0 +1,69 @@ +# Keyless real-Loader composition for the parent-only override inheritance +# snapshot. The deployment default stays WIDE (workspace-write) while the +# seeded parent session carries a session-scoped read-only override; the +# resumed parent delegates, and only the inheritance capture can confine the +# child — remove it and the child writes successfully under the deployment +# default, so this scenario is the assembled-app red/green anchor for the +# delegation bypass. + +- id: persistence + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: './.sessions' + compression: none + +# file/override/childFiles all default to their DSH_SNAPSHOT_* env vars. +- id: replay + name: '@deepseek-ai/dsh-llm-replay' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +# The confining filesystem stack: the wide deployment default lives on the +# shared policy home; the seeded parent's read-only override must beat it +# INSIDE the child for the scenario to deny. +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: workspace-write + workspaceRoot: !!js process.cwd() + +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: agent + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + agents: [] + workspaceContext: false + skills: + enabled: false + toolTasks: false + goals: false + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + maxDepth: 1 + +# Await the persisted resume before the headless driver inspects root agents. +- id: resumed-agent + name: './tests/fixtures/subagent-inheritance-agent.ts' diff --git a/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts b/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts new file mode 100644 index 0000000000..bd8a7aa2f7 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts @@ -0,0 +1,25 @@ +/** + * Loader fixture that resumes the seeded read-only parent before CLI dispatch. + * @module subagent-inheritance-agent + */ + +import type { Context } from 'cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Fixture plugin name. */ +export const name = 'subagent-inheritance-agent' +/** Services that must exist before the fixture resumes its agent. */ +export const inject = ['agents', 'agentLoop', 'sessionPersistence'] + +/** + * Resume the seeded session and bind its exact handle to this fixture's lifetime. + * @param ctx - settled agent and persistence services from the Loader tree. + * @returns after the resumed agent is published. + */ +export async function apply(ctx: Context): Promise { + const handle = await ctx.agents.resume({ + resumeSessionId: 'subagent-inheritance-parent' as SessionId, + agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + }) + ctx.effect(() => () => handle.dispose(), 'subagent-inheritance-agent.handle') +} diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 53a8342d93..2581d8a042 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -7,6 +7,7 @@ import { refreshFixtureReplacements, scrubRequestHeaders, stabilizeRefreshLog, + tokenizeSessionFixtureCwd, type HarvestedLog, type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' @@ -260,7 +261,9 @@ describe('headless stream-json snapshots', () => { if (existing === undefined || file === undefined) { throw new Error(`headless snapshot has no fixture for persisted log ${index}`) } - const stable = stabilizeRefreshLog(actual.content, existing, replacements, actualContext) + const stable = tokenizeSessionFixtureCwd( + stabilizeRefreshLog(actual.content, existing, replacements, actualContext), + ) await writeFile(file, stable) return stable })) @@ -456,7 +459,9 @@ describe('headless stream-json snapshots', () => { content: actual.content, } const replacements = refreshFixtureReplacements([harvested], [expectedSession]) - expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext) + expectedSession = tokenizeSessionFixtureCwd( + stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext), + ) await writeFile(ptySessionFixture, expectedSession) } const expectedContext = contextFromLogs([expectedSession]) diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index c41a628b57..f412b843ee 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"b07a1eeb-2060-44e5-87d3-05d315a4a74b"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 7a12caea56..370ee495cb 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"1d565b63-5689-4c09-9686-abd3ee379e28"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 2ec15fd69d..c0d6ce1b4b 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-headless","delegationDepth":0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"0dda35fe-e148-4400-b837-2f6e6fe40ae6"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":7,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl index ca36330c36..d684022863 100644 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"42222222-2222-4222-8222-222222222222","createdAt":1783951001000,"cwd":"/tmp/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"42222222-2222-4222-8222-222222222222","createdAt":1783951001000,"cwd":"{{cwd}}","parentSession":"41111111-1111-4111-8111-111111111111"} {"type":"assistant/chunk","seq":0,"time":1783951001001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":1,"time":1783951001002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-one-report","name":"structured_output","argumentsDelta":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}} {"type":"assistant/chunk","seq":2,"time":1783951001003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-one-report","name":"structured_output","arguments":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl index c722158098..15f54b231b 100644 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"43333333-3333-4333-8333-333333333333","createdAt":1783951002000,"cwd":"/tmp/ralph-headless","parentSession":"41111111-1111-4111-8111-111111111111"} +{"type":"session","version":0,"id":"43333333-3333-4333-8333-333333333333","createdAt":1783951002000,"cwd":"{{cwd}}","parentSession":"41111111-1111-4111-8111-111111111111"} {"type":"assistant/chunk","seq":0,"time":1783951002001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":1,"time":1783951002002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-two-report","name":"structured_output","argumentsDelta":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}} {"type":"assistant/chunk","seq":2,"time":1783951002003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-two-report","name":"structured_output","arguments":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl index c452fb5fa8..4b6fe2dcb5 100644 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl +++ b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl @@ -1 +1 @@ -{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"/tmp/ralph-headless"} +{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"{{cwd}}"} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl new file mode 100644 index 0000000000..59a93af0f6 --- /dev/null +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl @@ -0,0 +1,25 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","delegationDepth":1} +{"type":"sandbox/mode","seq":0,"time":0,"data":{"mode":"read-only","source":"delegation"}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":3,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[2],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":11,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"} +{"type":"tool/call","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}} +{"type":"tool/result","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"child-write"},"content":[{"type":"tool-result","toolCallId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":15,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} +{"type":"step/end","seq":22,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":23,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.replay.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.replay.jsonl new file mode 100644 index 0000000000..99947a3797 --- /dev/null +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.replay.jsonl @@ -0,0 +1,18 @@ +{"type": "session", "version": 0, "id": "subagent-inheritance-child", "createdAt": 2, "delegationDepth": 1} +{"type":"turn/start","seq":0,"time":1,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":2,"data":{"content":[{"type":"text","text":"delegated task"}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":3,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":4,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":4,"time":5,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} +{"type":"assistant/chunk","seq":5,"time":6,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}} +{"type":"assistant/chunk","seq":6,"time":7,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":7,"time":8,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"step/end","seq":8,"time":9,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":9,"time":10,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":10,"time":11,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":12,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}} +{"type":"assistant/chunk","seq":12,"time":13,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} +{"type":"assistant/chunk","seq":13,"time":14,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":15,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"step/end","seq":15,"time":16,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":16,"time":17,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl new file mode 100644 index 0000000000..d5bfb405de --- /dev/null +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"sandbox/mode","seq":2,"time":0,"data":{"mode":"read-only"}} +{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":4,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":7,"time":0,"data":{"turn":2,"step":1}} +{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"delegate-write","name":"subagent","argumentsDelta":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":0,"data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} +{"type":"tool/result","seq":16,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":18,"time":0,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The delegated child was denied by the sandbox. PARENT_DONE"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":0,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":26,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/replay.override.json b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/replay.override.json new file mode 100644 index 0000000000..1c0fe9ffad --- /dev/null +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/replay.override.json @@ -0,0 +1 @@ +[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "delegate-write", "name": "subagent", "argumentsDelta": "{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "delegate-write", "name": "subagent", "arguments": "{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The delegated child was denied by the sandbox. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The delegated child was denied by the sandbox. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}] diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts new file mode 100644 index 0000000000..02c96cac15 --- /dev/null +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -0,0 +1,124 @@ +/** + * Assembled-app regression: a parent-only read-only override is seeded into + * its child log and confines a real write under a wider deployment default. + */ + +import { readFile, readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { describe, expect, it } from 'vitest' + +const fixtureDir = fileURLToPath(new URL('./subagent-inheritance-snapshots/parent-override', import.meta.url)) +const replayOverride = join(fixtureDir, 'replay.override.json') +const childReplay = join(fixtureDir, 'child.replay.jsonl') +const parentExpected = join(fixtureDir, 'parent.expected.jsonl') +const childExpected = join(fixtureDir, 'child.expected.jsonl') +const configPath = fileURLToPath(new URL('../subagent-inheritance.cordis.snapshot.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const sessionId = SessionId('subagent-inheritance-parent') +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' +const task = 'Delegate the write probe to a subagent.' + +/** Seed a completed parent turn with the only read-only fact in the app. */ +async function seedReadOnlyParent(root: string, cwd: string): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + const meta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: sessionId, + createdAt: 1, + cwd, + delegationDepth: 0, + } + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, + { type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } }, + { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + try { + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(sessionId, events) + } finally { + await ctx.fiber.dispose() + } +} + +describe('parent-only override inheritance snapshot', () => { + it('confines a delegated child through the assembled headless app', async () => { + let cwd = '' + const result = await runLoaderSmoke({ + label: 'subagent inheritance headless stream-json snapshot', + tempDirPrefix: 'dsh-subagent-inherit-', + binScript, + configPath, + binArgs: ['--config', configPath, '--output-format', 'stream-json', task], + tsconfigPath, + env: { + // The primary fixture path must exist for llm-replay's config guard; + // the override sidecar fully replaces the derived parent script. + DSH_SNAPSHOT_FILE: replayOverride, + DSH_SNAPSHOT_OVERRIDE: replayOverride, + DSH_SNAPSHOT_CHILD_FILES: childReplay, + }, + prepare: async (runCwd) => { + cwd = runCwd + await seedReadOnlyParent(join(runCwd, '.sessions'), runCwd) + }, + inspect: async (runCwd) => { + // THE physical fact: the child's write never reached the disk. Under + // the deployment default (workspace-write) alone it would succeed. + await expect(readFile(join(runCwd, 'inherited.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + + // Collect both persisted logs (parent resumed turn + child run). + const sessionsDir = join(runCwd, '.sessions') + const files = (await readdir(sessionsDir, { recursive: true })).filter(file => file.endsWith('.jsonl')) + const logs = await Promise.all(files.map(async file => readFile(join(sessionsDir, file), 'utf8'))) + const headerOf = (content: string): Record => + JSON.parse(content.split('\n')[0] ?? '{}') as Record + const parent = logs.find(content => content.includes('"subagent-inheritance-parent"')) + const child = logs.find(content => typeof headerOf(content).parentSession === 'string') + if (parent === undefined || child === undefined) throw new Error('missing persisted parent or child log') + + const childRecords = child.trimEnd().split('\n').map( + line => JSON.parse(line) as Record, + ) + expect(childRecords[1]).toMatchObject({ + type: 'sandbox/mode', + seq: 0, + data: { mode: 'read-only', source: 'delegation' }, + }) + + const context: NormalizeContext = { sessionIds: [sessionId, String(headerOf(child).id)], cwd } + const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context)) + const normalizedChild = scrubRequestHeaders(normalizeSessionLog(child, context)) + if (refreshing) { + await writeFile(parentExpected, normalizedParent) + await writeFile(childExpected, normalizedChild) + } + expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8')) + expect(normalizedChild).toBe(await readFile(childExpected, 'utf8')) + // The child's real write was denied by the real fence. + expect(normalizedChild).toContain('file access denied under read-only mode') + }, + }) + + expect(result.stderr).toBe('') + const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) + expect(records.at(-1)).toMatchObject({ + type: 'result', + success: true, + sessionId, + result: 'The delegated child was denied by the sandbox. PARENT_DONE', + reason: { kind: 'completed' }, + }) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index 59c18e0131..a64415bf2a 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md README.md: 6ee4e9d824315bde76b7a534679f018df9a6d3e8 -README.zh.md: dc9b6233e7074e7a9b13bf10bcd2f310b0ad7bf3 +README.zh.md: 43290fc3659750724679a370be5b33ffdca2a5cb diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index dc9b6233e7..43290fc365 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -2,16 +2,16 @@ [English](README.md) | 中文 -面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、console logger、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。 +面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、控制台日志记录器、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。 面向模型的工具为: - `bash`,仅前台 - `read`、`write` 和 `edit` -- `subagent`,使用一个前台进程内 spawn 提供方 +- `subagent`,使用一个在进程内以前台方式运行的 spawn 提供方 - `todo_write` -周边运行时还加载 JSONL 会话持久化和自动上下文压缩(compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。 +周边运行时还加载 JSONL 会话持久化和自动上下文压缩(context compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。 ## 运行时环境 @@ -24,4 +24,4 @@ | `DSH_SESSION_ROOT` | JSONL 轨迹目录 | | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | -通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。 +通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 26254f0cf1..c54812e3e5 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -20,6 +20,7 @@ import { refreshFixtureReplacements, scrubRequestHeaders, stabilizeRefreshLog, + tokenizeSessionFixtureCwd, type HarvestedLog, type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' @@ -238,7 +239,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { await Promise.all(ordered.map(async (log, index) => { const file = fixtureFiles(scenario)[index] if (file === undefined) throw new Error(`no fixture path for persisted log ${index}`) - await writeFile(file, scrubRequestHeaders(log.content)) + await writeFile(file, scrubRequestHeaders(tokenizeSessionFixtureCwd(log.content))) })) } @@ -257,12 +258,19 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const existing = expectedContents[index] const file = files[index] if (existing === undefined || file === undefined) throw new Error(`no fixture for persisted log ${index}`) - const stable = stabilizeRefreshLog(log.content, existing, replacements, actualContext) + const stable = scrubRequestHeaders(tokenizeSessionFixtureCwd( + stabilizeRefreshLog(log.content, existing, replacements, actualContext), + )) await writeFile(file, stable) return stable })) } + for (const [index, expected] of expectedContents.entries()) { + expect(scrubRequestHeaders(expected), `${scenario.name} session fixture ${index} carries request-header bulk`) + .toBe(expected) + } + // Persisted transcripts match the committed fixtures. const expectedContext = contextOfContents(expectedContents) for (const [index, log] of ordered.entries()) { diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl index d3ee0a2f5f..a1e3925531 100644 --- a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"/tmp/sdk-snapshot-bash-tool-ywbuab","delegationDepth":0} +{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785097395904,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097395905,"data":{"content":[{"type":"text","text":"Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391"}],"source":{"kind":"user"},"role":"user","id":"295507c3-4ba7-4695-a535-73e75046abb3"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097395907,"data":{"title":"Run this exact command with","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785097395908,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785097395909,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding agent.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Background execution is not available; long-running commands must finish within the timeout.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1785097395909,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097396437,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785097396438,"data":{"turn":1,"step":1,"index":0,"dt":[219,22,1,0,0,0,1,24,25,0,0,25,1,24,1,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," bash"," command"," and"," reply"," with"," its"," stdout"," only","."]}} {"type":"assistant/chunk","seq":23,"time":1785097396856,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 0e9fb1c561..7b0db305f6 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"0b7fd85c-9f6f-4d46-b954-363984ce66fb","createdAt":1785097410282,"cwd":"/tmp/sdk-snapshot-subagent-spawn-6fzuBd","parentSession":"sdk-snapshot-subagent","delegationDepth":1} +{"type":"session","version":0,"id":"0b7fd85c-9f6f-4d46-b954-363984ce66fb","createdAt":1785097410282,"cwd":"{{cwd}}","parentSession":"sdk-snapshot-subagent","delegationDepth":1} {"type":"turn/start","seq":0,"time":1785097410283,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097410283,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"fb1dfb09-5b8b-4343-8a04-49cc4c7c082e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097410283,"data":{"title":"Reply with exactly: child answer","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785097410284,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785097410284,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding agent.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Background execution is not available; long-running commands must finish within the timeout.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1785097410284,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097410836,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785097410836,"data":{"turn":1,"step":1,"index":0,"dt":[149,26,0,0,24,1,0,0,0,25,0,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} {"type":"assistant/chunk","seq":20,"time":1785097411113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl index 23127d77b8..f43a78f588 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"sdk-snapshot-subagent","createdAt":1785097408901,"cwd":"/tmp/sdk-snapshot-subagent-spawn-6fzuBd","delegationDepth":0} +{"type":"session","version":0,"id":"sdk-snapshot-subagent","createdAt":1785097408901,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785097408905,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097408905,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim."}],"source":{"kind":"user"},"role":"user","id":"e2664740-19d2-4e54-81e5-63ff154af28e"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097408907,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785097408908,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785097408908,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding agent.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Background execution is not available; long-running commands must finish within the timeout.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1785097408908,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097409495,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785097409496,"data":{"turn":1,"step":1,"index":0,"dt":[170,25,1,0,0,0,0,24,0,1,0,0,0,26,0,0,0,0,0,26,0,0,0,0,0,30,0,0,1,0,0,20,1,0,0,28,0,1,0,0,0,23,1,0,0,0,0,25,1,25,26,1,0,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Use"," the"," sub","agent"," tool"," exactly"," once"," with"," description"," '","echo"," probe","'"," and"," prompt"," '","Reply"," with"," exactly",":"," child"," answer"," ","42",".'\n","2","."," Then"," reply"," with"," the"," sub","agent","'s"," final"," answer"," verb","atim",".\n\n","Let"," me"," do"," this"," step"," by"," step","."]}} {"type":"assistant/chunk","seq":61,"time":1785097410031,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl index f3706e24d0..d7192b0a12 100644 --- a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"sdk-snapshot-text","createdAt":1785097381464,"cwd":"/tmp/sdk-snapshot-text-turn-OwFEJv","delegationDepth":0} +{"type":"session","version":0,"id":"sdk-snapshot-text","createdAt":1785097381464,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1785097381468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785097381469,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"4cb523e7-19c9-45d0-8799-911a78c26207"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785097381471,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785097381472,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785097381472,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are a coding agent.\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Background execution is not available; long-running commands must finish within the timeout.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1785097381472,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785097381978,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785097381979,"data":{"turn":1,"step":1,"index":0,"dt":[138,28,27,1,0,0,24,1,0,0,0,26,0,1,25,1,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]}} {"type":"assistant/chunk","seq":25,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} diff --git a/examples/tui-agent/README.i18n.yaml b/examples/tui-agent/README.i18n.yaml index 3d29530411..863092ffb8 100644 --- a/examples/tui-agent/README.i18n.yaml +++ b/examples/tui-agent/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/tui-agent/README.md README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 -README.zh.md: b3f6dc18536b159379eac7433367ccf2cd8fcc53 +README.zh.md: c6acd39d8713816d870c00fa8597754d0d09880a diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md index b3f6dc1853..c6acd39d87 100644 --- a/examples/tui-agent/README.zh.md +++ b/examples/tui-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与新 agent Ralph 迭代、plan mode(`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合单次管道的任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。 +全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与全新 agent Ralph 迭代、plan mode(`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合管道调用的单次任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。 ## 运行 @@ -13,13 +13,13 @@ pnpm run demo:tui ``` -演示脚本和可安装的 `dsh` CLI([`apps/cli`](../../apps/cli/README.md))都会作为已交付的默认配置启动此示例的 `cordis.yml`;`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为 workspace。 +演示脚本和可安装的 `dsh` CLI(命令行界面,见 [`apps/cli`](../../apps/cli/README.md))都会以此示例的 `cordis.yml` 作为已交付的默认配置启动;`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为工作区。 -输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次操作都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。fs 工具和 bash 都会根据会话 workspace 解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。 +输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次 bash 调用都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。文件系统工具和 bash 都会相对于会话工作区解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。 `todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 -TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 +TUI 渲染 Markdown 历史、推理(reasoning)、工具自有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 ### 恢复早先的会话 @@ -29,11 +29,11 @@ TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡 dsh --resume ``` -`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久 goal 阶段和实时/已持久化状态。已安装的 `dsh` 宿主会刷新并释放当前应用,然后以 `dsh --resume ` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume ` 在启动上下文中提供 id,`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。 +`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久化目标阶段和实时/已持久化状态。已安装的 `dsh` 宿主会等待刷写完成,对当前应用执行 dispose(资源释放),然后以 `dsh --resume ` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume ` 在启动上下文中提供 id,`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。 ## Code Mode -[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 +[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 ```sh pnpm run demo:code-mode # this overlay under the TUI (default UI) @@ -54,27 +54,27 @@ pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | | `llm-deepseek` | 默认原生适配器 | -| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 的可替换一半。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | +| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 中可替换的实现侧。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | | `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent | | `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent | | `tool-subagent`, `tool-subagent-fork` | 两次面向模型的 `dsh-tool-subagent` 加载,每次绑定不同提供方,并以不同工具名(`subagent`、`subagent_fork`)公开 | | `workflow-workerthread`, `tool-workflow` | worker 线程工作流引擎及其面向模型的 `workflow` 工具,子调用通过 spawn 后端路由 | | `plan-mode` | 插件拥有的 `/plan [message]` 进入命令和 `/plan off` 退出命令、plan-mode 提示词策略、工具限制,以及经评审的 `exit_plan_mode` 转换 | -| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径根据会话 workspace 解析 | +| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径相对于会话工作区解析 | ## 端到端测试(`pnpm run test:e2e`) 与 UI 无关的带密钥套件通过 `tests/harness.ts` 以程序方式组装完整栈(无 PTY、无 Loader): - `tests/full-loop.e2e.ts`:canary 测试:真实模型通过真实 bash 工具运行 `echo e2e-ok`;断言 `tool/call`/`tool/result` 会话事件和最终答案。 -- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的声称。 -- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后释放整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。 -- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,表层缩减(替换节点遮蔽旧节点),且 agent 在压缩后仍给出正确最终答案。 -- `tests/todo-write.e2e.ts`:加载选用 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。 -- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言线上工具列表精确为 `[run_code]`,`tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。 +- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的说法。 +- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后 dispose 整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。 +- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,模型可见内容缩减(一个替换节点遮蔽了较旧节点),且 agent 在压缩后仍给出正确最终答案。 +- `tests/todo-write.e2e.ts`:加载选用的 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。 +- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言协议层工具列表精确为 `[run_code]`,`tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。 -这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM 对话,Code Mode 覆盖欢迎行,以及恢复失败退出路径。 +这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM(大语言模型)对话,Code Mode 覆盖配置的欢迎行,以及恢复失败退出路径。 ## 快照测试 -`tests/snapshots//session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。使用 `pnpm run test:snapshot:refresh` 刷新仅展示变更;已录制模型旅程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 拥有场景矩阵,以及已录制旅程、瞬时包快照与 PTY 覆盖之间的分工。 +`tests/snapshots//session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。对于仅涉及展示的变更,使用 `pnpm run test:snapshot:refresh`;已录制的模型流程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 规定了场景矩阵,以及已录制旅程、包级瞬态快照与 PTY 覆盖之间的分工。 diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index c6fc223113..86129d3fa4 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_tui_hmr plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] + cfg --> plugin_tui_llm_pi_ai plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] cfg --> plugin_tui_subprocess plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] @@ -71,6 +73,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 7c8b03db05..2b14271c96 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -1,4 +1,4 @@ -# Full-screen TUI coding agent with swappable DeepSeek and local-bash backends. +# Full-screen TUI coding agent with swappable model and local-bash backends. # `dsh-tui-demo` supplies the agent spine, workspace instructions, generic # task controls, JSONL persistence, the pi-tui front door, and `main`. # HMR remains a leaf because it depends on Loader internals. The app bin loads @@ -20,6 +20,17 @@ thinking: enabled reasoningEffort: max +- id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + - provider: openai + apiKey: !!js process.env.OPENAI_API_KEY + baseURL: !!js process.env.OPENAI_BASE_URL + - provider: anthropic + apiKey: !!js process.env.ANTHROPIC_API_KEY + baseURL: !!js process.env.ANTHROPIC_BASE_URL + # Local executor for the app bundle's bash tool. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). - id: subprocess diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index ea088a20f9..a6c33160fa 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -1,6 +1,7 @@ +import { mkdirSync, writeFileSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { execa } from 'execa' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' @@ -36,7 +37,16 @@ while time.monotonic() < deadline: if chunk: output.extend(chunk) while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output: - os.write(fd, actions[action_index]["send"].encode()) + action = actions[action_index] + if "writeFile" in action: + target = os.path.join(cwd, action["writeFile"]["path"]) + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "w", encoding="utf-8") as handle: + handle.write(action["writeFile"]["content"]) + if "send" in action: + os.write(fd, action["send"].encode()) + else: + os.write(fd, action["send"].encode()) action_index += 1 waited, candidate = os.waitpid(pid, os.WNOHANG) if waited == pid: @@ -56,11 +66,14 @@ if actual_exit != int(expected_exit): sys.exit(125) ` -/** One terminal action sent after its marker has rendered. */ -interface TuiPtyAction { - readonly waitFor: string - readonly send: string -} +/** One terminal input or workspace mutation performed after its marker renders. */ +type TuiPtyAction = + | { readonly waitFor: string; readonly send: string } + | { + readonly waitFor: string + readonly writeFile: { readonly path: string; readonly content: string } + readonly send?: string + } /** Inputs for a keyless real-Loader TUI process smoke. */ export interface TuiPtySmokeOptions { @@ -157,7 +170,16 @@ async function runWindowsPtySmoke( terminal.onData((chunk) => { output += chunk while (actionIndex < actions.length && output.includes(actions[actionIndex]!.waitFor)) { - terminal.write(actions[actionIndex]!.send) + const action = actions[actionIndex]! + if ('writeFile' in action) { + const target = join(cwd, action.writeFile.path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, action.writeFile.content) + const input = action.send + if (input !== undefined) terminal.write(input) + } else { + terminal.write(action.send) + } actionIndex += 1 } }) diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl index f32d1160db..4d0354a167 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/tmp/acp-snap-cwd-mrFUuk","delegationDepth":0} +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352050755,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt index 0d8f454204..2efc357338 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt @@ -4,29 +4,30 @@ title "Use the bash tool to — DSH TUI snapshot" cursor hidden column=7 viewportRow=24 bufferRow=24 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Use the bash tool to" - style 1-20 fg=bright-black + style 1-20 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta bold underline 5| "Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop. " 6| 7| "Assistant " style 0-8 fg=bright-magenta bold underline 8| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 9| "The user wants me to run a simple bash command and then reply with \"DONE\". " - style 0-73 fg=bright-black italic + style 0-73 dim italic 10| 11| "● Tool / bash / Echo TERMINAL_OK to verify terminal access" style 0-57 fg=green 12| "$ echo TERMINAL_OK " - style 0-17 fg=cyan + style 0-17 dim 13| "TERMINAL_OK " + style 0-10 dim 14| "[exit 0] " style 0-7 dim 15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " @@ -35,20 +36,20 @@ buffer 17| "Assistant " style 0-8 fg=bright-magenta bold underline 18| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 19| "The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " - style 0-90 fg=bright-black italic + style 0-90 dim italic 20| "DONE " 21| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 22| 23| "/workspace/project deepseek-v4-flash ↑3.0k ↓115 cache 48% 3% contex" - style 0-46 fg=bright-blue bold - style 49-65 fg=bright-black - style 68-88 fg=bright-black - style 91-99 fg=bright-black + style 0-46 fg=bright-magenta bold + style 49-65 dim + style 68-88 dim + style 91-99 dim 24| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim style 7-7 inverse 25-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl index 42a7dcd8ea..3ae5c51857 100644 --- a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"main-session","createdAt":1785052797743,"cwd":"/tmp/dsh-tui-snapshot-code-mode-dispatch-spill-8cOdia"} +{"type":"session","version":0,"id":"main-session","createdAt":1785052797743,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":1785052797817,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785052797818,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool exactly once with the command `seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'`, then return ONLY the number of lines in its output. Reply with just that number and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785052797825,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} @@ -15,7 +15,7 @@ {"type":"assistant/message","seq":156,"time":1785052799798,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."},{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155],"surfaceOp":"append"} {"type":"tool/call","seq":157,"time":1785052799799,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}} {"type":"tool/code-dispatch-start","seq":158,"time":1785052799893,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"}}} -{"type":"tool/code-dispatch","seq":159,"time":1785052799923,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"},"isError":false,"content":[{"type":"text","text":"line 0001: the quick brown fox jumps over the lazy dog\nline 0002: the quick brown fox jumps over the lazy dog\nline 0003: the quick brown fox jumps over the lazy dog\nline 0004: the quick s over the lazy dog\nline 0198: the quick brown fox jumps over the lazy dog\nline 0199: the quick brown fox jumps over the lazy dog\nline 0200: the quick brown fox jumps over the lazy dog\n\n\n(Omitted 10629 bytes. Full formatted result stored at: /tmp/dsh-tui-snapshot-code-mode-dispatch-spill-8cOdia/.spill/session-2d2b9e84a250/825a63550249-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}]}} +{"type":"tool/code-dispatch","seq":159,"time":1785052799923,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"},"isError":false,"content":[{"type":"text","text":"line 0001: the quick brown fox jumps over the lazy dog\nline 0002: the quick brown fox jumps over the lazy dog\nline 0003: the quick brown fox jumps over the lazy dog\nline 0004: the quick s over the lazy dog\nline 0198: the quick brown fox jumps over the lazy dog\nline 0199: the quick brown fox jumps over the lazy dog\nline 0200: the quick brown fox jumps over the lazy dog\n\n\n(Omitted 10629 bytes. Full formatted result stored at: {{cwd}}/.spill/session-2d2b9e84a250/825a63550249-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}]}} {"type":"tool/result","seq":160,"time":1785052799925,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","content":[{"type":"text","text":"200"}],"isError":false},"sourceEventSeqs":[157],"surfaceOp":"append"} {"type":"step/end","seq":161,"time":1785052799926,"data":{"turn":1,"step":1}} {"type":"step/start","seq":162,"time":1785052799928,"data":{"turn":1,"step":2}} diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt index 248ea2f4e7..f4208bf650 100644 --- a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt @@ -4,15 +4,15 @@ title "Using ONE run_code program: call — DSH TUI snapshot" cursor hidden column=7 viewportRow=26 bufferRow=26 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Using ONE run_code program: call" - style 1-32 fg=bright-black + style 1-32 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta bold underline 5| "Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " style 77-99 fg=cyan 6| "'{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " @@ -22,36 +22,38 @@ buffer 9| "Assistant " style 0-8 fg=bright-magenta bold underline 10| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 11| "The user wants me to write a single run_code program that calls bash exactly once with a specific " - style 0-99 fg=bright-black italic + style 0-99 dim italic 12| "command, then returns only the number of lines in its output. " - style 0-60 fg=bright-black italic + style 0-60 dim italic 13| 14| "● Tool / run_code" style 0-16 fg=green 15| "Count lines in seq/awk output " + style 0-99 dim 16| "200 " + style 0-99 dim 17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 18| 19| "Assistant " style 0-8 fg=bright-magenta bold underline 20| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 21| "The result is 200 lines. The user wants me to reply with just that number and stop. " - style 0-82 fg=bright-black italic + style 0-82 dim italic 22| "200 " 23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 24| 25| "/workspace/project deepseek-v4-flash ↑123 ↓208 cache 99% 3% c" - style 0-52 fg=bright-blue bold - style 55-71 fg=bright-black - style 74-93 fg=bright-black - style 96-99 fg=bright-black + style 0-52 fg=bright-magenta bold + style 55-71 dim + style 74-93 dim + style 96-99 dim 26| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim style 7-7 inverse 27-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl index 858af9a595..7af580f60a 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl +++ b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"main-session","createdAt":1785014512062,"cwd":"/tmp/dsh-tui-snapshot-code-mode-7FNsKO"} +{"type":"session","version":0,"id":"main-session","createdAt":1785014512062,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":1785014512139,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1785014512140,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO`. Inside that same program, console.log exactly `captured output`, then return the two outputs joined with a plus sign. Reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785014512146,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index 45879f889f..03d65530b1 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -4,15 +4,15 @@ title "Using ONE run_code program: call — DSH TUI snapshot" cursor hidden column=7 viewportRow=35 bufferRow=61 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Using ONE run_code program: call" - style 1-32 fg=bright-black + style 1-32 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta bold underline 5| "Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " style 63-75 fg=cyan style 90-99 fg=cyan @@ -24,37 +24,37 @@ buffer 9| "Assistant " style 0-8 fg=bright-magenta bold underline 10| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 11| "The user wants me to write a single run_code program that: " - style 0-35 fg=bright-black italic + style 0-35 dim italic style 36-43 fg=cyan - style 44-57 fg=bright-black italic + style 44-57 dim italic 12| "1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " - style 0-2 fg=bright-blue - style 3-8 fg=bright-black italic + style 0-2 fg=bright-magenta + style 3-8 dim italic style 9-12 fg=cyan - style 13-37 fg=bright-black italic + style 13-37 dim italic style 38-50 fg=cyan - style 51-62 fg=bright-black italic + style 51-62 dim italic style 63-75 fg=cyan 13| "2. console.log exactly captured output " - style 0-2 fg=bright-blue + style 0-2 fg=bright-magenta style 3-13 fg=cyan - style 14-22 fg=bright-black italic + style 14-22 dim italic style 23-37 fg=cyan 14| "3. Returns the two outputs joined with a plus sign " - style 0-2 fg=bright-blue - style 3-49 fg=bright-black italic + style 0-2 fg=bright-magenta + style 3-49 dim italic 15| " " 16| "Let me think about the structure. The bash tool returns an object with stdout/stderr. I need to " - style 0-37 fg=bright-black italic + style 0-37 dim italic style 38-41 fg=cyan - style 42-99 fg=bright-black italic + style 42-99 dim italic 17| "extract the stdout text from each call. " - style 0-38 fg=bright-black italic + style 0-38 dim italic 18| " " 19| "Looking at the bash output type: " - style 0-31 fg=bright-black italic + style 0-31 dim italic 20| " " 21| " " 22| " { " @@ -90,51 +90,54 @@ buffer 37| " " 38| " " 39| "So I need to access .stdout.text from each result. " - style 0-19 fg=bright-black italic + style 0-19 dim italic style 20-31 fg=cyan - style 32-49 fg=bright-black italic + style 32-49 dim italic 40| " " 41| "Let me write the program. " - style 0-24 fg=bright-black italic + style 0-24 dim italic 42| 43| "● Tool / run_code" style 0-16 fg=green 44| "Call bash twice, log, join outputs " + style 0-99 dim 45| "captured output " + style 0-99 dim 46| "CODE_ONE+CODE_TWO " + style 0-99 dim 47| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 48| 49| "Assistant " style 0-8 fg=bright-magenta bold underline 50| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 51| "The program ran successfully. The output shows: " - style 0-46 fg=bright-black italic + style 0-46 dim italic 52| "- captured output (from console.log) " - style 0-1 fg=bright-blue + style 0-1 fg=bright-magenta style 2-16 fg=cyan - style 17-35 fg=bright-black italic + style 17-35 dim italic 53| "- CODE_ONE+CODE_TWO (the returned joined string) " - style 0-1 fg=bright-blue + style 0-1 fg=bright-magenta style 2-18 fg=cyan - style 19-47 fg=bright-black italic + style 19-47 dim italic 54| " " 55| "The user asked me to reply with that joined string only and stop. So I'll reply with just " - style 0-99 fg=bright-black italic + style 0-99 dim italic 56| "CODE_ONE+CODE_TWO. " style 0-16 fg=cyan - style 17-17 fg=bright-black italic + style 17-17 dim italic 57| "CODE_ONE+CODE_TWO " 58| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 59| 60| "/workspace/project deepseek-v4-flash ↑182 ↓446 cache 98% 4% context" - style 0-37 fg=bright-blue bold - style 40-56 fg=bright-black - style 59-78 fg=bright-black - style 81-90 fg=bright-black + style 0-37 fg=bright-magenta bold + style 40-56 dim + style 59-78 dim + style 81-90 dim 61| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl index 26519d458e..7027bd50f3 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl index 9daa8958dc..5d383f6421 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl index b9ce1d12da..0e9355b4c5 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl @@ -1,4 +1,4 @@ -{"type": "session", "version": 0, "id": "11111111-1111-4111-8111-111111111111", "createdAt": 1783950000000, "cwd": "/tmp/advanced-acp", "delegationDepth": 0} +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index b4add028d8..7d6a92ea77 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -4,15 +4,15 @@ title "Run this advanced flow exactly — DSH TUI snapshot" cursor hidden column=7 viewportRow=35 bufferRow=58 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Run this advanced flow exactly" - style 1-30 fg=bright-black + style 1-30 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta bold underline 5| "Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use " 6| "run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a " 7| "direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply " @@ -24,8 +24,11 @@ buffer 12| "● Tool / cordis_mount" style 0-20 fg=green 13| "Mount temporary Cordis Plugin " + style 0-99 dim 14| "Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH " + style 0-99 dim 15| "restarts). " + style 0-99 dim 16| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 17| @@ -35,13 +38,16 @@ buffer 20| "● Tool / run_code" style 0-16 fg=green 21| "Verify the temporary marker Plugin " + style 0-99 dim 22| " " 23| "Temporary Plugins " - style 0-16 fg=bright-blue bold + style 0-16 fg=bright-magenta bold dim 24| " " 25| "- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: " - style 0-1 fg=bright-blue + style 0-1 fg=bright-magenta dim + style 2-99 dim 26| " until unmounted or DSH restarts " + style 0-99 dim 27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 28| @@ -51,6 +57,7 @@ buffer 31| "● Tool / subagent" style 0-16 fg=green 32| "DIRECT_CHILD_OK " + style 0-99 dim 33| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 34| @@ -60,11 +67,17 @@ buffer 37| "● Tool / workflow" style 0-16 fg=green 38| "workflow: advanced-acp-snapshot " + style 0-99 dim 39| "workflow \"advanced-acp-snapshot\" completed (1 agent). " + style 0-99 dim 40| "Return value: " + style 0-99 dim 41| "{ " + style 0-99 dim 42| " \"reply\": \"WORKFLOW_CHILD_OK\" " + style 0-99 dim 43| "} " + style 0-99 dim 44| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 45| @@ -74,7 +87,9 @@ buffer 48| "● Tool / cordis_unmount" style 0-22 fg=green 49| "Unmount temporary Cordis Plugin dyn-1 " + style 0-99 dim 50| "Temporary Plugin dyn-1 was unmounted and removed. " + style 0-99 dim 51| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 52| @@ -85,11 +100,11 @@ buffer style 0-46 dim 56| 57| "/workspace/project deepseek-v4-flash ↑18 ↓18 cache 0% 8% cont" - style 0-52 fg=bright-blue bold - style 55-71 fg=bright-black - style 74-90 fg=bright-black - style 93-99 fg=bright-black + style 0-52 fg=bright-magenta bold + style 55-71 dim + style 74-90 dim + style 93-99 dim 58| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl index e1dd4a461a..42b73e2084 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{cwd}}","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","delegationDepth":1} {"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl index 71bad8720d..9083060639 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","delegationDepth":0} +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt index c704cb2399..ff8125c5ad 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -4,15 +4,15 @@ title "Use the workflow tool exactly — DSH TUI snapshot" cursor hidden column=7 viewportRow=35 bufferRow=42 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Use the workflow tool exactly" - style 1-29 fg=bright-black + style 1-29 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta bold underline 5| "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " 6| "\"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " 7| "phase('Run') " @@ -24,57 +24,63 @@ buffer 13| "Assistant " style 0-8 fg=bright-magenta bold underline 14| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 15| "The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " - style 0-99 fg=bright-black italic + style 0-99 dim italic 16| "follow the instructions: " - style 0-23 fg=bright-black italic + style 0-23 dim italic 17| " " 18| "1. args omitted (so I don't include it) " - style 0-2 fg=bright-blue - style 3-38 fg=bright-black italic + style 0-2 fg=bright-magenta + style 3-38 dim italic 19| "2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " - style 0-2 fg=bright-blue - style 3-81 fg=bright-black italic + style 0-2 fg=bright-magenta + style 3-81 dim italic 20| "3. script = as given verbatim " - style 0-2 fg=bright-blue - style 3-28 fg=bright-black italic + style 0-2 fg=bright-magenta + style 3-28 dim italic 21| "4. After it returns, reply with \"WORKFLOW_DONE\" " - style 0-2 fg=bright-blue - style 3-46 fg=bright-black italic + style 0-2 fg=bright-magenta + style 3-46 dim italic 22| " " 23| "Let me do exactly that. " - style 0-22 fg=bright-black italic + style 0-22 dim italic 24| 25| "● Tool / workflow" style 0-16 fg=green 26| "workflow: snapshot-flow " + style 0-99 dim 27| "workflow \"snapshot-flow\" completed (1 agent). " + style 0-99 dim 28| "Return value: " + style 0-99 dim 29| "{ " + style 0-99 dim 30| " \"reply\": \"WF_CHILD_OK\" " + style 0-99 dim 31| "} " + style 0-99 dim 32| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 33| 34| "Assistant " style 0-8 fg=bright-magenta bold underline 35| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 36| "The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " - style 0-99 fg=bright-black italic + style 0-99 dim italic 37| "\"WORKFLOW_DONE\" and stop. " - style 0-24 fg=bright-black italic + style 0-24 dim italic 38| "WORKFLOW_DONE " 39| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 40| 41| "/workspace/project deepseek-v4-flash ↑3.5k ↓227 cache 47% 3% context" - style 0-44 fg=bright-blue bold - style 47-63 fg=bright-black - style 66-86 fg=bright-black - style 89-98 fg=bright-black + style 0-44 fg=bright-magenta bold + style 47-63 dim + style 66-86 dim + style 89-98 dim 42| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl index 76b7ceba58..549dc342a7 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"/tmp/acp-snap-cwd-aN2GRR","delegationDepth":0} +{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352113767,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index e4fa63468a..c0c38592c3 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -4,59 +4,59 @@ title "Reply with exactly the word: — DSH TUI snapshot" cursor hidden column=7 viewportRow=30 bufferRow=30 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Reply with exactly the word:" - style 1-28 fg=bright-black + style 1-28 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta bold underline 5| "Reply with exactly the word: ONE. No tools. " 6| -7| "Entering plan mode (applies from the next step). Use /plan off to leave. " - style 0-71 fg=bright-black +7| "Plan mode on. Use /plan off to leave. " + style 0-36 dim 8| 9| "Assistant " style 0-8 fg=bright-magenta bold underline 10| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 11| "The user wants me to reply with exactly the word \"ONE\" and use no tools. " - style 0-71 fg=bright-black italic + style 0-71 dim italic 12| "ONE " 13| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 14| -15| "Leaving plan mode (applies from the next step). " - style 0-46 fg=bright-black -16| -17| "You " - style 0-2 fg=bright-blue bold underline -18| "Reply with exactly the word: TWO. No tools. " -19| -20| "Context · plan-mode " +15| "Context · plan-mode" style 0-18 dim -21| "The user switched this session back to the default mode. " - style 0-55 fg=bright-black +16| "The user switched this session back to the default mode. " + style 0-55 dim +17| +18| "Plan mode off. " + style 0-13 dim +19| +20| "You " + style 0-2 fg=bright-magenta bold underline +21| "Reply with exactly the word: TWO. No tools. " 22| 23| "Assistant " style 0-8 fg=bright-magenta bold underline 24| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 25| "The user wants me to reply with exactly the word \"TWO\" and no tools. " - style 0-67 fg=bright-black italic + style 0-67 dim italic 26| "TWO " 27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 28| 29| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% co" - style 0-51 fg=bright-blue bold - style 54-70 fg=bright-black - style 73-92 fg=bright-black - style 95-99 fg=bright-black + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-92 dim + style 95-99 dim 30| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim style 7-7 inverse 31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt index 3d99c45758..82b3048bb4 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt @@ -4,15 +4,15 @@ title "Use the read tool twice — DSH TUI snapshot" cursor hidden column=7 viewportRow=27 bufferRow=27 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Use the read tool twice" - style 1-23 fg=bright-black + style 1-23 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta bold underline 5| "Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " 6| 7| "Assistant " @@ -21,16 +21,22 @@ buffer 9| "● Tool / read" style 0-12 fg=green 10| "Read a.txt " + style 0-99 dim 11| "1: alpha " + style 0-99 dim 12| " " 13| "(End of file - total 1 lines) " + style 0-99 dim 14| 15| "● Tool / read" style 0-12 fg=green 16| "Read b.txt " + style 0-99 dim 17| "1: beta " + style 0-99 dim 18| " " 19| "(End of file - total 1 lines) " + style 0-99 dim 20| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 21| @@ -41,12 +47,12 @@ buffer style 0-46 dim 25| 26| "/workspace/project deepseek-v4-flash ↑20 ↓6 cache 0% 3% context" - style 0-47 fg=bright-blue bold - style 50-66 fg=bright-black - style 69-84 fg=bright-black - style 87-96 fg=bright-black + style 0-47 fg=bright-magenta bold + style 50-66 dim + style 69-84 dim + style 87-96 dim 27| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim style 7-7 inverse 28-35| diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/session.jsonl b/examples/tui-agent/tests/snapshots/skill-invocation-policy/session.jsonl new file mode 100644 index 0000000000..e7d6e3aeec --- /dev/null +++ b/examples/tui-agent/tests/snapshots/skill-invocation-policy/session.jsonl @@ -0,0 +1,5 @@ +{"type":"session","version":0,"id":"31f63cc0-0198-4ab2-bfde-79a4eb4f1867","createdAt":1783352180000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"assistant/chunk","seq":0,"time":1783352180001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":1,"time":1783352180002,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"USER-ONLY SKILL LOADED"}}} +{"type":"assistant/chunk","seq":2,"time":1783352180003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"USER-ONLY SKILL LOADED"}}}} +{"type":"assistant/chunk","seq":3,"time":1783352180004,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/terminal.expected.txt b/examples/tui-agent/tests/snapshots/skill-invocation-policy/terminal.expected.txt new file mode 100644 index 0000000000..2d6068d48b --- /dev/null +++ b/examples/tui-agent/tests/snapshots/skill-invocation-policy/terminal.expected.txt @@ -0,0 +1,157 @@ +=== skill autocomplete === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=13 viewportRow=5 bufferRow=5 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Recorded replay: skill-invocation-policy" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "/workspace/project deepseek-v4-flash ↑0 ↓0 0% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +5| " dsh > /skill " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 13-13 inverse +6| " → skill:user-only-skill (project) — User-only assembled snapshot skill. " + style 7-78 fg=bright-magenta +7-35| + + +=== loaded exact invocation === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title " Reference — DSH TUI snapshot" +cursor hidden column=7 viewportRow=30 bufferRow=30 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Reference" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "You " + style 0-2 fg=bright-magenta bold underline +5| " " +6| "References in this skill are relative to " +7| "/workspace/project/.agents/skills/user-only-skill. " +8| " " +9| "USER-ONLY BODY " +10| " " +11| +12| "Context · dsh-tool-skill" + style 0-23 dim +13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " + style 0-99 dim +14| "session: " + style 0-7 dim +15| " " +16| " " + style 0-17 dim +17| "- `model-only-skill`: Model-only assembled snapshot skill. " + style 0-57 dim +18| " " + style 0-18 dim +19| " " +20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " + style 0-99 dim +21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " + style 0-99 dim +22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " + style 0-99 dim +23| "instructions until it has been loaded. " + style 0-37 dim +24| +25| "Assistant " + style 0-8 fg=bright-magenta bold underline +26| "USER-ONLY SKILL LOADED " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +30| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +31-35| + + +=== denied exact invocation === +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title " Reference — DSH TUI snapshot" +cursor hidden column=7 viewportRow=32 bufferRow=32 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Reference" + style 1-40 dim +2| " main-session" + style 1-12 dim +3| +4| "You " + style 0-2 fg=bright-magenta bold underline +5| " " +6| "References in this skill are relative to " +7| "/workspace/project/.agents/skills/user-only-skill. " +8| " " +9| "USER-ONLY BODY " +10| " " +11| +12| "Context · dsh-tool-skill" + style 0-23 dim +13| "A skill is a reusable set of task-specific instructions. The following skills are available in this " + style 0-99 dim +14| "session: " + style 0-7 dim +15| " " +16| " " + style 0-17 dim +17| "- `model-only-skill`: Model-only assembled snapshot skill. " + style 0-57 dim +18| " " + style 0-18 dim +19| " " +20| "If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool " + style 0-99 dim +21| "with the exact skill name before taking task actions. Load all applicable skills, then follow their " + style 0-99 dim +22| "full instructions. This catalog contains summaries only; do not infer or follow a skill's " + style 0-99 dim +23| "instructions until it has been loaded. " + style 0-37 dim +24| +25| "Assistant " + style 0-8 fg=bright-magenta bold underline +26| "USER-ONLY SKILL LOADED " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "Skill \"model-only-skill\" is not available for user invocation. " + style 0-61 fg=yellow +30| +31| "/workspace/project deepseek-v4-flash ↑0 ↓0 3% context" + style 0-51 fg=bright-magenta bold + style 54-70 dim + style 73-77 dim + style 80-89 dim +32| " dsh ◍ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +33-35| diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md b/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md new file mode 100644 index 0000000000..3608a840f5 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/model-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: model-only-skill +description: Model-only assembled snapshot skill. +user-invocable: false +--- + +MODEL-ONLY BODY MUST NOT LOAD diff --git a/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md b/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md new file mode 100644 index 0000000000..fa231cd99d --- /dev/null +++ b/examples/tui-agent/tests/snapshots/skill-invocation-policy/workspace/.agents/skills/user-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: user-only-skill +description: User-only assembled snapshot skill. +disable-model-invocation: true +--- + +USER-ONLY BODY diff --git a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl index 878948fd26..3591582b9b 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl +++ b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7","delegationDepth":0} +{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"{{cwd}}","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783352057657,"data":{"turn":1,"step":1}} diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt index 9dafcf576d..a5d2ca07b3 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -4,15 +4,15 @@ title "Use the todo_write tool to — DSH TUI snapshot" cursor hidden column=7 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold + style 1-8 fg=bright-magenta bold style 10-16 bold 1| " Use the todo_write tool to" - style 1-26 fg=bright-black + style 1-26 dim 2| " main-session" style 1-12 dim 3| 4| "You " - style 0-2 fg=bright-blue bold underline + style 0-2 fg=bright-magenta bold underline 5| "Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " 6| "\"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " 7| "reply with the single word DONE and stop. " @@ -20,31 +20,33 @@ buffer 9| "Assistant " style 0-8 fg=bright-magenta bold underline 10| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 11| "The user wants me to use the todo_write tool to record a plan with exactly three todos in the " - style 0-99 fg=bright-black italic + style 0-99 dim italic 12| "specified statuses, then reply with \"DONE\". " - style 0-42 fg=bright-black italic + style 0-42 dim italic 13| 14| "● Tool / todo_write" style 0-18 fg=green 15| "Update todo list " + style 0-99 dim 16| "Updated todo list: 2 pending, 1 in progress, 0 completed. " + style 0-99 dim 17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 18| 19| "Assistant " style 0-8 fg=bright-magenta bold underline 20| "Reasoning " - style 0-8 fg=bright-black italic + style 0-8 dim italic 21| "The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " - style 0-94 fg=bright-black italic + style 0-94 dim italic 22| "DONE " 23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " style 0-46 dim 24-25| 26| "Plan" - style 0-3 fg=bright-blue bold + style 0-3 fg=bright-magenta bold 27| " ● read the code" style 2-2 fg=yellow 28| " ○ write the fix" @@ -52,12 +54,12 @@ buffer 29| " ○ run the tests" style 2-2 dim 30| "/workspace/project deepseek-v4-flash ↑3.1k ↓145 cache 47% 3% context" - style 0-37 fg=bright-blue bold - style 40-56 fg=bright-black - style 59-79 fg=bright-black - style 82-91 fg=bright-black + style 0-37 fg=bright-magenta bold + style 40-56 dim + style 59-79 dim + style 82-91 dim 31| " dsh ◍ " - style 1-3 fg=bright-blue bold - style 5-6 fg=bright-black + style 1-3 fg=bright-magenta bold + style 5-6 dim style 7-7 inverse 32-35| diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 3908670e27..cd5f83e4e3 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -149,14 +149,14 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { actions: [ { waitFor: 'main-session-', send: '/plan' }, { waitFor: '[off|message] — Enter or leave plan mode', send: '\r' }, - { waitFor: 'Entering plan mode (applies from the next step). Use /plan off to leave.', send: '/exit\r' }, + { waitFor: 'Plan mode on. Use /plan off to leave.', send: '/exit\r' }, ], }) expect(output).toContain('DEEPSEEK') expect(output).toContain('HARNESS') expect(output).toContain('main-session-') expect(output).toContain('[off|message] — Enter or leave plan mode') - expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.') + expect(output).toContain('Plan mode on. Use /plan off to leave.') // Borderless: no box-drawing frame around the banner. expect(output).not.toContain('╭') expect(output).not.toContain('╮') @@ -183,15 +183,15 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // Gating /status on it keeps the assertion race-free; the diagnostics // card is then exercised through the same real Loader/PTY composition. { waitFor: 'scripted session title — DeepSeek Harness', send: '/plan off\r' }, - { waitFor: 'Leaving plan mode (applies from the next step).', send: 'Confirm the scripted run left plan mode.\r' }, + { waitFor: 'Plan mode off.', send: 'Confirm the scripted run left plan mode.\r' }, { waitFor: 'Default mode confirmed.', send: '/status\r' }, { waitFor: 'Session status', send: '/exit\r' }, ], }) expect(output).toContain('I need one decision before I continue.') expect(output).toContain('Reasoning effort: Max.') - expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.') - expect(output).toContain('Leaving plan mode (applies from the next step).') + expect(output).toContain('Plan mode on. Use /plan off to leave.') + expect(output).toContain('Plan mode off.') expect(output).toContain('Default mode confirmed.') expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`) expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`) @@ -217,11 +217,12 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => { - // The whole manual-invocation path in one keyless boot: `ctx.get('skills')` + // The whole user-only invocation path in one keyless boot: `ctx.get('skills')` // resolves in the shipped tree, the client-side `/skill:` command parses, - // the local provider loads `scripted-skill` from the agents home, and the - // rendered `` block reaches the model — proven by the - // scripted adapter echoing the fixture's body marker only when it arrives. + // and the local provider admits a model-disabled skill by the omitted + // `user-invocable` default. The rendered `` block reaches + // the model — proven by the scripted adapter echoing the fixture's body + // marker only when it arrives. const output = await smoke({ label: 'tui-agent skill', tempDirPrefix: 'tui-agent-skill-', @@ -232,6 +233,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { '---', 'name: scripted-skill', 'description: Keyless PTY proof that the skill command loads a local skill into the conversation.', + 'disable-model-invocation: true', '---', '', 'SCRIPTED SKILL BODY MARKER', @@ -250,6 +252,36 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('adds a watched local skill to live /skill: autocomplete without restarting', async () => { + const skill = [ + '---', + 'name: hot-added-skill', + 'description: HOT_ADDED_COMPLETION_MARKER', + '---', + '', + 'Hot-added body.', + '', + ].join('\n') + const output = await smoke({ + label: 'tui-agent hot-added skill autocomplete', + tempDirPrefix: 'tui-agent-hot-skill-', + configPath: scriptedConfigPath, + actions: [ + { + waitFor: 'scripted TUI ready.', + writeFile: { + path: '.agents/skills/hot-added-skill/SKILL.md', + content: skill, + }, + send: '/skill:hot', + }, + { waitFor: 'HOT_ADDED_COMPLETION_MARKER', send: '\x03/exit\r' }, + ], + }) + expect(output).toContain('HOT_ADDED_COMPLETION_MARKER') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('fuzzy-completes an @file path without reading or submitting the file', async () => { const output = await smoke({ label: 'tui-agent file autocomplete', diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 98abee3f0a..95b960e3b6 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -4,7 +4,7 @@ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' +import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -41,6 +41,7 @@ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi type SnapshotMode = 'replay' | 'record' | 'refresh' type Composition = 'native' | 'code' | 'advanced' +type ScenarioInteraction = 'skill-invocation-policy' interface Scenario { name: string @@ -65,6 +66,8 @@ interface Scenario { * preview + locator while the program value stays whole. */ spillMaxInlineBytes?: number + /** Run scenario-specific terminal input instead of replaying recorded user prompts. */ + interaction?: ScenarioInteraction } const SCENARIOS: Scenario[] = [ @@ -98,6 +101,14 @@ const SCENARIOS: Scenario[] = [ recorded: true, seedWorkspace: true, }, + { + name: 'skill-invocation-policy', + composition: 'native', + expectedTools: [], + recorded: false, + seedWorkspace: true, + interaction: 'skill-invocation-policy', + }, { name: 'code-mode', composition: 'code', @@ -269,9 +280,10 @@ async function runScenario(scenario: Scenario): Promise { const dir = scenarioDir(scenario) const fixtureFile = join(dir, 'session.jsonl') const childFiles = childFixturePaths(scenario) - const fixture = await readFile(fixtureFile, 'utf8') - const prompts = userPrompts(fixture) - expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0) + const prompts = userPrompts(await readFile(fixtureFile, 'utf8')) + if (scenario.interaction === undefined) { + expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0) + } const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`)) const displayCwd = `/tmp/${basename(cwd)}` @@ -310,6 +322,63 @@ async function runScenario(scenario: Scenario): Promise { }) await settleTerminal(terminal) + let interactionSnapshot: string | undefined + if (scenario.interaction === 'skill-invocation-policy') { + terminal.send('/skill') + await settleTerminal(terminal) + const discovery = normalizeTerminalSnapshot( + await terminal.snapshot({ includeScrollback: true }), + cwd, + displayCwd, + ) + expect(discovery).toContain('user-only-skill') + expect(discovery).not.toContain('model-only-skill') + + terminal.send('\x03') + await settleTerminal(terminal) + const skillContext = ctx + const skillTurnEnded = new Promise((resolve) => { + const detach = skillContext.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'turn/end') return + detach() + resolve() + }) + }) + terminal.send('/skill:user-only-skill') + terminal.send('\r') + await skillTurnEnded + await agent.whenIdle() + await settleTerminal(terminal) + const loaded = normalizeTerminalSnapshot( + await terminal.snapshot({ includeScrollback: true }), + cwd, + displayCwd, + ) + expect(loaded).toContain('USER-ONLY SKILL LOADED') + + terminal.send('/skill:model-only-skill') + terminal.send('\r') + await settleTerminal(terminal) + const denied = normalizeTerminalSnapshot( + await terminal.snapshot({ includeScrollback: true }), + cwd, + displayCwd, + ) + expect(denied).toContain('model-only-skill') + expect(denied).toContain('not available for user invocation.') + expect(denied).not.toContain('MODEL-ONLY BODY MUST NOT LOAD') + interactionSnapshot = [ + '=== skill autocomplete ===', + discovery, + '', + '=== loaded exact invocation ===', + loaded, + '', + '=== denied exact invocation ===', + denied, + ].join('\n') + } + let remainingPrompts = prompts if (scenario.enterPlanMode === true) { const firstPrompt = prompts[0]! @@ -392,7 +461,7 @@ async function runScenario(scenario: Scenario): Promise { } expect(terminal.themeViolations(), `${scenario.name} must remain theme-agnostic`).toEqual([]) - const snapshot = normalizeTerminalSnapshot( + const snapshot = interactionSnapshot ?? normalizeTerminalSnapshot( await terminal.snapshot({ includeScrollback: true }), cwd, displayCwd, @@ -415,10 +484,16 @@ async function runScenario(scenario: Scenario): Promise { async function writeRecording(scenario: Scenario, result: ScenarioResult): Promise { const dir = scenarioDir(scenario) await mkdir(dir, { recursive: true }) - await writeFile(join(dir, 'session.jsonl'), scrubRequestHeaders(rawSessionLog(result.parent))) + await writeFile( + join(dir, 'session.jsonl'), + scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(result.parent))), + ) expect(result.children).toHaveLength(scenario.childSessions ?? 0) for (const [index, child] of result.children.entries()) { - await writeFile(join(dir, `session.${index + 1}.jsonl`), scrubRequestHeaders(rawSessionLog(child))) + await writeFile( + join(dir, `session.${index + 1}.jsonl`), + scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(child))), + ) } } diff --git a/knip.json b/knip.json index d010bcf21d..e5b7110957 100644 --- a/knip.json +++ b/knip.json @@ -31,6 +31,7 @@ "entry": [ "headless-agent/tests/fixtures/cli-mock-llm.ts", "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", + "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", @@ -100,6 +101,16 @@ "tests/**/*.tsx" ] }, + "packages/client/ui-goal": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, "packages/client/web-react": { "entry": [ "tests/**/*.spec.tsx" @@ -143,6 +154,25 @@ "tests/**/*.ts" ] }, + "packages/core/tools": { + "entry": [ + "tests/**/*.spec.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, + "packages/typert/generator": { + "entry": [ + "tests/**/*.spec.ts", + "tests/fixtures/type-model/**/*.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/bash/bash-sandbox": { "entry": [ "tests/**/*.spec.ts", @@ -163,6 +193,15 @@ "tests/**/*.ts" ] }, + "packages/context/tmux-context": { + "entry": [ + "tests/**/*.spec.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/lsp/lsp-local": { "entry": [ "tests/**/*.spec.ts", diff --git a/native/README.i18n.yaml b/native/README.i18n.yaml index 4013fc656d..b58b0fe1cc 100644 --- a/native/README.i18n.yaml +++ b/native/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write native/README.md README.md: 84808b2ee9dafa4f9f980c35a81ebe12480a4f5d -README.zh.md: f73d4176454d9a577bf674a6bfe3f15cce3402b4 +README.zh.md: 276db0e655f2d632da9729c787b613cd231b2d40 diff --git a/native/README.zh.md b/native/README.zh.md index f73d417645..276db0e655 100644 --- a/native/README.zh.md +++ b/native/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`node-addon-landlock-run` 的记录真源:这是 harness 从 npm 消费的 Landlock「先限制自身、再执行」启动器(`packages/sandbox/sandbox-local`、`packages/bash/bash-sandbox`)。启动器在此处开发,与消费方相邻;独立仓库是打包并发布 npm 包系列的发布镜像。 +`node-addon-landlock-run` 的权威源码位于此处:这是 harness 从 npm 引入并使用的 Landlock「先限制自身、再执行」启动器(`packages/sandbox/sandbox-local`、`packages/bash/bash-sandbox`)。启动器在此处开发,与消费方相邻;独立仓库是打包并发布 npm 包(package)系列的发布镜像。 ## 发布镜像 @@ -17,6 +17,6 @@ 1. 先通过常规 harness PR 将启动器更改落地于此;触发 `Landlock Run` 工作流,并确保其所有任务通过。 2. 在镜像 checkout 中替换 `.github/` 以外的所有内容:`git -C rm -rq -- . ':!.github'`,然后执行 `git -C archive HEAD:native/landlock-run | tar -x -C `,最后执行 `git -C add -A` 并提交。 3. 在镜像中按照其发布清单(`docs/release.md`)操作:`pnpm release:commit ` → 合并 → 标记 `vX.Y.Z` → 两阶段 `Release` 工作流(先以 `publish=false` 预演,再从标签以 `publish=true` 发布)。 -4. 使用已发布的标签/commit 更新上方 manifest(元数据清单)表,并在同一更改中提升 harness 消费方的依赖范围。 +4. 使用已发布的标签/commit 更新上方 manifest(元数据清单)表,并在同一更改中上调 harness 消费方的依赖版本范围。 -镜像不得分叉:如果更改直接提交到镜像中(例如发布期间的热修复),必须在下次导出前将其移植回此处。 +发布镜像不得与此处的权威源码产生分歧:如果更改直接提交到镜像中(例如发布期间的热修复),必须在下次导出前将其移植回此处。 diff --git a/native/landlock-run/README.i18n.yaml b/native/landlock-run/README.i18n.yaml index 7212b6a317..e732ebdf31 100644 --- a/native/landlock-run/README.i18n.yaml +++ b/native/landlock-run/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write native/landlock-run/README.md README.md: 284d5df764cf5a5205973696211aee2366d3b76e -README.zh.md: 7163314abac0362afccee6fcc4506a84701cc27a +README.zh.md: f369799cc8dcfb6de7c4b7b8c18857693d310418 diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md index 7163314aba..f369799cc8 100644 --- a/native/landlock-run/README.zh.md +++ b/native/landlock-run/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以每平台预构建 npm 包加一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)契约。该启动器面向需要在文件系统允许清单下运行不可信命令、但不能限制自身的 agent harness 和其他宿主。 +一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以按平台预构建的 npm 包(package)以及一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)契约。该启动器面向需要让不可信命令在文件系统允许清单约束下运行、同时保持自身不受限制的 agent harness(智能体框架)和其他宿主。 第一个工具是 **`landlock-run`**:一个「先限制自身、再执行」的 [Landlock](https://landlock.io/) 启动器(基于原始内核 UAPI 编写,约 300 行 C11,并与 musl 静态链接)。它在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此命令及其产生的每个进程都在限制下运行,调用进程仍不受限制。它采用失败闭合:如果内核无法强制执行,则不运行命令并直接退出。 @@ -34,12 +34,12 @@ if (probe(launcher) !== 'unusable') { } ``` -公开 API 有意保持简小: +公开 API 有意保持精简: - `launcherPath()`:当前宿主启动器的绝对路径(有意不检查是否存在;探测结果才是可用性信号)。 - `probe(launcher?, { timeoutMs? })`:功能性强制执行探测,返回 `'full' | 'partial' | 'unusable'`。 - `grantArgs({ readOnly?, readWrite? })`:启动器的授权 argv;未授予的一切都被拒绝。 -- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT` (125):契约常量。 +- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`(125):契约常量。 完整的二进制契约(argv 语法、退出码、报告行)锁定在 [docs/cli-contract.md](docs/cli-contract.md) 中。 @@ -57,4 +57,4 @@ pnpm build:native # this Linux architecture's binaries (apt-get install musl- pnpm test ``` -二进制文件被 git 忽略,并且按架构原生构建:本地只构建当前机器的版本,CI 的每架构 runner 则是记录中的构建者。发布流程详见 [docs/release.md](docs/release.md)。 +二进制文件被 git 忽略,并且按架构原生构建:本地只构建当前机器的版本,CI 各架构 runner 产出的构建则作为正式发布依据。发布流程详见 [docs/release.md](docs/release.md)。 diff --git a/native/landlock-run/packages/entry/README.i18n.yaml b/native/landlock-run/packages/entry/README.i18n.yaml index 2985be0419..86fe43ff4d 100644 --- a/native/landlock-run/packages/entry/README.i18n.yaml +++ b/native/landlock-run/packages/entry/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write native/landlock-run/packages/entry/README.md README.md: e402cdfe71c4eb81b977a21955fe3fff6bf55fd3 -README.zh.md: 03dd18969d8e5c94be31605201b74b126ae5c06d +README.zh.md: 6f8136c33560515af891b8873d007eb3e9b013e0 diff --git a/native/landlock-run/packages/entry/README.zh.md b/native/landlock-run/packages/entry/README.zh.md index 03dd18969d..6f8136c335 100644 --- a/native/landlock-run/packages/entry/README.zh.md +++ b/native/landlock-run/packages/entry/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包解析每平台预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 +用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包(package)定位对应平台的预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 ```js import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; @@ -13,6 +13,6 @@ if (probe(launcher) !== 'unusable') { } ``` -启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:始终失败闭合,绝不失败开放。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。 +启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:采用失败闭合策略,绝不在失败时放行。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。 -平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回确定且不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 +平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回一个固定但不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 diff --git a/native/landlock-run/packages/linux-arm64/README.i18n.yaml b/native/landlock-run/packages/linux-arm64/README.i18n.yaml index b6b14f1eef..76d4b9884a 100644 --- a/native/landlock-run/packages/linux-arm64/README.i18n.yaml +++ b/native/landlock-run/packages/linux-arm64/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write native/landlock-run/packages/linux-arm64/README.md README.md: e5117988cf0bae2227edaa041700c2f75753899c -README.zh.md: 93fee68207a9f03a54f214c69904d44729ed71e5 +README.zh.md: abbd0d1040638ad4d64f3ab219bedcd845eb5a9b diff --git a/native/landlock-run/packages/linux-arm64/README.zh.md b/native/landlock-run/packages/linux-arm64/README.zh.md index 93fee68207..abbd0d1040 100644 --- a/native/landlock-run/packages/linux-arm64/README.zh.md +++ b/native/landlock-run/packages/linux-arm64/README.zh.md @@ -2,8 +2,8 @@ [English](README.md) | 中文 -面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个从 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 中随包发布的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其解析为文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包(package)所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 -该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节将打包二进制文件锁定到其来源 CI 构建。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 +该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 同级包:`node-addon-landlock-run-linux-x64`。 diff --git a/native/landlock-run/packages/linux-x64/README.i18n.yaml b/native/landlock-run/packages/linux-x64/README.i18n.yaml index af3f916fb4..e24a9393c7 100644 --- a/native/landlock-run/packages/linux-x64/README.i18n.yaml +++ b/native/landlock-run/packages/linux-x64/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write native/landlock-run/packages/linux-x64/README.md README.md: 68b5dfc9b6f437a387c3792ee047a1f11630aca0 -README.zh.md: b1fa2e3f16c20c4d7e287c17ab0ba946e6cadbea +README.zh.md: e813bcef7143b46a756e5716234f3bc3850de712 diff --git a/native/landlock-run/packages/linux-x64/README.zh.md b/native/landlock-run/packages/linux-x64/README.zh.md index b1fa2e3f16..e813bcef71 100644 --- a/native/landlock-run/packages/linux-x64/README.zh.md +++ b/native/landlock-run/packages/linux-x64/README.zh.md @@ -2,8 +2,8 @@ [English](README.md) | 中文 -面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个从 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 中随包发布的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其解析为文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包(package)所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 -该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节将打包二进制文件锁定到其来源 CI 构建。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 +该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 同级包:`node-addon-landlock-run-linux-arm64`。 diff --git a/package.json b/package.json index a0fc4f780f..a51261a4c2 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,14 @@ "website" ], "scripts": { - "build": "tsc -b && tsdown", + "build": "npm run build:lib && npm run build:web", + "build:lib": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", "typecheck": "tsc -b", - "lint": "eslint .", - "lint:fix": "eslint . --fix", + "lint": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js .", + "lint:fix": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -29,7 +30,7 @@ "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", - "test:web": "npm run build && npm run build:web && vitest run --config vitest.web.config.ts", + "test:web": "npm run build && vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", @@ -95,13 +96,13 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", - "dsh": "node --import tsx apps/cli/src/bin.ts", + "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", - "demo:tui": "node --import tsx apps/cli/src/bin.ts", + "demo:tui": "node --import tsx/esm apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", - "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", + "demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 6aae068fd7..458da3b713 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -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/README.md -README.md: 706d741b87bd22656580419eb43a493c1ab2933a -README.zh.md: c41a15dcb743d024348d5a8a8c105b37e694205b +README.md: 3f467641bbc9eae14a94aa2d3bff0402116a9d3f +README.zh.md: c9d11bbf6239b4239a4e037dac63b05d3a9a58f7 diff --git a/packages/README.md b/packages/README.md index 706d741b87..3f467641bb 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages live at `packages///`; groups are containers, while names r | Group | Role | Release expectation | |---|---|---| | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | +| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface | @@ -44,6 +45,8 @@ Packages live at `packages///`; groups are containers, while names r | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface | | [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface | +| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface | +| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface | | [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | diff --git a/packages/README.zh.md b/packages/README.zh.md index c41a15dcb7..c9d11bbf62 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -11,6 +11,7 @@ | 组 | 职责 | 发布预期 | |---|---|---| | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | +| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | | [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 | @@ -44,6 +45,8 @@ | [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 | | [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 | | [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 | +| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定表面 | +| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 | | [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 | | [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 | | [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | diff --git a/packages/acp/README.i18n.yaml b/packages/acp/README.i18n.yaml index cf2cf1791a..08051fea69 100644 --- a/packages/acp/README.i18n.yaml +++ b/packages/acp/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write packages/acp/README.md README.md: 326615210e5cfc39004fc5ab7462623089ac4126 -README.zh.md: 9999ecdd019501c3f501a6c69fab5e0ccfaf555c +README.zh.md: 8679f2428a9e82a81de69d7d1413132a946fcafa diff --git a/packages/acp/README.zh.md b/packages/acp/README.zh.md index 9999ecdd01..8679f2428a 100644 --- a/packages/acp/README.zh.md +++ b/packages/acp/README.zh.md @@ -6,6 +6,6 @@ ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开 | 包 | 职责 | |---|---| -| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接拥有的清理。 | +| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接负责的清理。 | -与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以驱动同一服务器契约。 +与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器。 diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 5583a2b9a5..d359613cd6 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write packages/acp/acp/README.md README.md: 1b188b994d17ce56e8d5df019ddef755338fcc88 -README.zh.md: f8abe9e45a5efffa436513f7d4a931c69c624b84 +README.zh.md: c1e7d045b55119b62ad44d81071188e1ed6110d5 diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index f8abe9e45a..c1e7d045b5 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 提供的仅面向自动化的 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、通过策略解决一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。 +通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。 -此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、mode、配置选择器、信息征集、推理、计划、标题或工具展示。交互渲染与人类问题属于 Web 和 TUI 模块。 +此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。 ## 插件 @@ -15,7 +15,7 @@ | `provider` | 无 | 每个已创建 agent 的初始提供方路由。 | | `model` | 无 | 每个已创建 agent 的初始模型。 | -两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。 +两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行的 ACP 组合同时要求两者。 ## 协议契约 @@ -23,19 +23,19 @@ |---|---| | `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | | `authenticate` | 空操作,因为服务器不公布身份验证方法。 | -| `session/new` | 使用绝对主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | -| `session/prompt` | 连接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并从该请求拥有的持久 `turn/end` 结算。 | -| `session/cancel` | 仅取消被定址的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 | +| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | +| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并根据该请求所属的持久 `turn/end` 结算。 | +| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 | | `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 | -| `session/request_permission` | 为携带工具调用 id 的桥接层所有批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | +| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | -一个连接可以拥有多个会话。桥接层使用带品牌的 session id 为记录建键,并在路由事件或权限请求前检查精确的 agent 标识。每个会话都有独立的提示词槽位、workspace、取消路径和 disposer。 +一个连接可以拥有多个会话。桥接层以带品牌的会话 id 作为记录键,并在路由事件或权限请求前检查 agent 是否为同一对象。每个会话都有独立的提示词槽位、工作区、取消路径和资源释放器。 -已提交消息输出有意以逐 token 延迟换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。 +已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。 ## 生命周期 -客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行释放所有已拥有的 agent handle,并等待它们的循环/会话清理完成。因此,仅 ACP 的插件重载不会遗留 agent。 +客户端断开连接与 Cordis 的 dispose(资源释放)共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose,并等待它们的循环/会话清理完成。因此,单独重载 ACP 插件不会遗留孤儿 agent。 ## 运行 @@ -45,13 +45,13 @@ ### 提示词文本 -#### 模型所见内容 +#### 模型看到的内容 -`session/prompt` 文本块会原样连接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。 +`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。 #### Token 影响 -提示词 token 取决于数据,并保留在该会话的历史中直到压缩。并发 ACP 会话保留独立上下文。 +提示词 token 取决于数据,并保留在该会话的历史中直到上下文压缩(context compaction)。并发 ACP 会话保留独立上下文。 #### KV Cache 影响 @@ -59,21 +59,21 @@ ### 权限决策 -#### 模型所见内容 +#### 模型看到的内容 -没有直接内容。拥有该决策的工具通过常规工具结果路径记录允许、拒绝、取消或不可用结果。 +不会直接看到任何内容。所属工具通过常规工具结果路径记录其结果:允许、拒绝、取消或不可用。 #### Token 影响 -只有拥有该决策的工具结果会贡献 token。 +只有该工具的结果会贡献 token。 #### KV Cache 影响 -通过所属工具结果仅追加。 +随该工具的结果仅追加。 -## 已知限制与延后工作 +## 已知限制与暂缓事项 - **仅新会话**:不支持加载、列出、恢复、删除和 fork。 -- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接会被展平为文本引用,而不是已获取内容。 -- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不上线。 -- **连接拥有的生命期**:一个连接会释放其所有会话;尚未实现每会话关闭。 +- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。 +- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不会通过协议传输。 +- **由连接管理的生命周期**:一个连接会释放其所有会话;尚未实现单个会话关闭功能。 diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml index 7c222fb6f5..0af14fda76 100644 --- a/packages/bash/README.i18n.yaml +++ b/packages/bash/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write packages/bash/README.md README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b -README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db +README.zh.md: deb23ea820de40c99f0affd3726d9a49857039ea diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index 57c28b45cf..deb23ea820 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品** 包。 +规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| @@ -11,4 +11,4 @@ | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | -接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器配置项;受限实现还需选择一个 `ctx.sandbox` 提供方配置项(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。 +接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。 diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index f94d04f90e..e72432de87 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/bash-local/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706 -README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516 +README.zh.md: c56543f26965effebaf020dd8d9d4ba130cd9b17 diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index aa6de87df4..c56543f269 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))归进程管理器服务所有。 +`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。以 spill 文件兜底的有界输出、凭据清除、kill 升级和 dispose(资源释放)等进程组机制则由 subprocess 服务负责。 包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 @@ -24,11 +24,11 @@ 设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: -- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 +- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。 - **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 -- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 +- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 - **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 +- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 ## 模型体验 @@ -36,12 +36,12 @@ #### KV Cache 影响 -不会直接失效;请求前缀变更由具名消费方负责。 +不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。 ## 已知限制与暂缓事项 -- **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 -- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。 +- **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 +- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。 - **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 - **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 diff --git a/packages/bash/bash-sandbox/README.i18n.yaml b/packages/bash/bash-sandbox/README.i18n.yaml index 86168916fe..e4e294b001 100644 --- a/packages/bash/bash-sandbox/README.i18n.yaml +++ b/packages/bash/bash-sandbox/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md README.md: ca77a9c626784b29145712535d69de4afbd3a697 -README.zh.md: c1a65ead539ef3930d70d27f2b176a5346daded3 +README.zh.md: 4ecc8d533f7af373bdacd133d44a8def6d265868 diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md index c1a65ead53..4ecc8d533f 100644 --- a/packages/bash/bash-sandbox/README.zh.md +++ b/packages/bash/bash-sandbox/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -消费 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 的沙箱实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);后者拥有默认模式 + 工作区根目录,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。 +这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。 包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。 -每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用(必须快速失败并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默无约束运行),属于提供方职责;本包只拥有 bash 侧。 +每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。 | 模式 | 文件影响 | |---|---| @@ -17,12 +17,12 @@ 语义: - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 -- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,bash 产生方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 -- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent 调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。 +- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 +- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。 - **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。 - 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。 -seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它驱动本包遵守的覆盖。 +该 seam 只报告拒绝:拒绝是一项结果事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它设置本包所遵守的模式覆盖值。 ```yaml - id: sandbox @@ -36,7 +36,7 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协 name: '@deepseek-ai/dsh-bash-sandbox' ``` -无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,在真实世界验证,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。 +无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,从外部验证实际文件效果,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。 ## 模型体验 @@ -44,11 +44,11 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协 #### 模型看到的内容 -基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布一个执行限制的 `sandboxMode`,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。 +基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布表明启用隔离的 `sandboxMode` 能力,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。 #### Token 影响 -在 `bash` 可见的请求上增加少量固定 schema;模式切换不增加上下文 token。 +在 `bash` 可见的请求上,schema 固定增加少量内容;模式切换不增加上下文 token。 #### KV Cache 影响 @@ -62,29 +62,29 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协 #### Token 影响 -除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到压缩。 +除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到上下文压缩(context compaction)。 #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 ### 间接的 Bash 工具错误 #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误;它由 `dsh-sandbox` 持有](../../sandbox/sandbox/README.md#confinement-error-indirectly)。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。 #### Token 影响 -该次调用可见的是有条件错误文本,并保留在历史记录中直到压缩。 +该次调用会在相应条件下显示错误文本,该文本会保留在历史记录中直到上下文压缩。 #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 ## 已知限制与暂缓事项 - **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。 -- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但匹配的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。 +- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。 - **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。 - **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。 diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index 520828acde..4a2c37ad91 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write packages/bash/bash/README.md README.md: d7bf746969f52000fe298b65b995b7c631d8001c -README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339 +README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index 14476b7703..a7c0cac0bc 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -4,7 +4,7 @@ **bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 -本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): +本包(package)是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): | 包 | 职责 | |---|---| @@ -13,19 +13,19 @@ | `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 | | `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema | -该拆分与 LLM seam(`LlmService`/`LlmAdapter`)及 agent 工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。 +该拆分与 LLM(大语言模型) seam(`LlmService`/`LlmAdapter`)及 agent(智能体)工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。 ## 服务 API(`ctx.bash`) | 成员 | 语义 | |---|---| -| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止终止都会 resolve 为描述性 `BashRunResult`。 | +| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止导致的终止都会 resolve 为描述性 `BashRunResult`。 | | `start(spec)` | 后台执行。立即返回不含任务语义的 `BashProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.tasks`。 | | `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 | | `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 | | `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 | -实现会继承 `BashExecutor` 并实现抽象方法。dispose 必须终止每个运行中的进程并等待其退出,详见 HMR 安全测试。 +实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出,详见 HMR(热模块替换)安全测试。 ## 词汇 @@ -33,7 +33,7 @@ 每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。 -`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 ## 模型体验 @@ -41,9 +41,9 @@ #### KV Cache 影响 -不会直接失效;请求前缀变更由具名消费方负责。 +不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。 ## 已知限制与暂缓事项 - **没有交互式输入词汇**:`stdin` 只会在 spawn 时写入一次并关闭;seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。 -- **前台超时始终由执行器拥有**:seam 上的调用方拥有 deadline 模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。 +- **前台超时始终由执行器负责**:seam 上由调用方负责 deadline 的模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。 diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index a529b56b56..676c935cbd 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.i18n.yaml @@ -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 -README.md: 965ae25a5e29a4f767adfcb73e4a77f1060e4b46 -README.zh.md: 60be5c5ca5624719f5ca651a78b6ba56f3f3df06 +# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md +README.md: deb6b899c81cb8c335b4c1cffdde4797e0a8be92 +README.zh.md: c2514308fb9f234e6d191a6b1a821ac3d195378b diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 965ae25a5e..deb6b899c8 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -57,7 +57,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef ## UI presentation -The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, raw output, and parsed exit status. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe. +The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, output, and parsed exit status. Because the card shows the exit as its own pill, the `[exit code: N]` / `[killed by signal: …]` marker the parse consumes leaves the output; every other marker (truncation, timeout, sandbox) stays in it. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe. ## The tool builds its request from named args only @@ -153,6 +153,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual. +- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay and loses that line from the card body, because the parse treats it as the marker it consumes; a display-only known residual. - **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md). - **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters. diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index 60be5c5ca5..c2514308fb 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -57,7 +57,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques ## UI 展示 -工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。 +工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、输出和解析后的退出状态。由于卡片以独立的 pill 展示退出状态,解析所消耗的 `[exit code: N]` / `[killed by signal: …]` 标记会从输出中移除;其他所有标记(截断、超时、沙箱)都保留在输出中。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。 ## 工具仅使用具名参数构建请求 @@ -153,6 +153,6 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr ## 已知限制与延期工作 -- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill;这是仅影响展示的已知残留问题。 +- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill,并且该行会从卡片正文中丢失,因为解析会把它当作自己消耗的标记;这是仅影响展示的已知残留问题。 - **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。 - **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `task_kill`,或依赖持有者/服务的 dispose。 diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index b403c4414e..f8cece2862 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -296,7 +296,9 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | if (isBackground || result.isError) { return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] } } - return { card: 'terminal', output: raw, ...parseExitStatus(raw) } + // The exit marker becomes the card's exit pill, so it leaves the output body. + const { body, ...exit } = parseExitStatus(raw) + return { card: 'terminal', output: body, ...exit } } /** diff --git a/packages/bash/tool-bash/src/render.ts b/packages/bash/tool-bash/src/render.ts index 77a88e28f3..eabe681c25 100644 --- a/packages/bash/tool-bash/src/render.ts +++ b/packages/bash/tool-bash/src/render.ts @@ -95,10 +95,23 @@ export function renderProcessRead( } /** - * Recover the structured exit status from a rendered {@link renderResult} - * string — the inverse of the status markers it appends. A killed marker - * yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both - * means a clean exit 0. + * The exit status recovered from a rendered result, with the output body that + * status was split off from. + */ +export type ParsedExitStatus = + & { body: string } + & ({ exitCode: number } | { signal: string }) + +/** + * Split a rendered {@link renderResult} string into its output body and the + * structured exit status — the inverse of the status markers it appends. A + * killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`; + * absent both means a clean exit 0. + * + * The consumed marker is removed from `body` because a terminal presentation + * shows the exit status as its own pill: leaving the marker in the output would + * render the exit twice. Other markers (timeout, sandbox denial) carry facts no + * pill shows, so they stay in the body. * * Replay only retains the rendered content text, not the original * `BashRunResult`, so terminal presentation must recover the exit pill here. @@ -106,12 +119,12 @@ export function renderProcessRead( * that merely ends with marker-like text from matching unless the final line * is indistinguishable from a real marker. * @param text - rendered model-facing bash result. - * @returns the recovered terminal exit code or signal. + * @returns the marker-free body plus the recovered terminal exit code or signal. */ -export function parseExitStatus(text: string): { exitCode: number } | { signal: string } { +export function parseExitStatus(text: string): ParsedExitStatus { const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) - if (signal?.[1] !== undefined) return { signal: signal[1] } + if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] } const exit = /\n\[exit code: (\d+)\]$/.exec(text) - if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) } - return { exitCode: 0 } + if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) } + return { body: text, exitCode: 0 } } diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 15222ee647..a52b3f16f1 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -911,22 +911,32 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => { const ctx = await setup() const present = ctx.tools.get('bash')!.presentResult!( - { command: 'echo hi', description: 'echo' }, - { content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false }, + { command: 'printf "hi\\n\\n"', description: 'echo' }, + // A clean run renders no exit marker at all, so the body is the raw bytes. + { content: [{ type: 'text', text: 'hi\n\n' }], isError: false }, ) // A terminal result keeps the RAW bytes (newlines intact) a terminal renderer - // needs; the bridge derives the fenced fallback. exitCode is parsed back from - // the [exit code: N] marker. - expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 }) + // needs; the bridge derives the fenced fallback. + expect(present).toEqual({ card: 'terminal', output: 'hi\n\n', exitCode: 0 }) }) it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => { const ctx = await setup() const args = { command: 'x', description: 'x' } const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false }) - expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 }) + expect(nonzero).toEqual({ card: 'terminal', output: 'oops', exitCode: 3 }) const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false }) - expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' }) + expect(killed).toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' }) + }) + + it('bash presentResult: markers a pill CANNOT show (timeout, sandbox denial) stay in the terminal output', async () => { + const ctx = await setup() + const args = { command: 'x', description: 'x' } + const timedOut = ctx.tools.get('bash')!.presentResult!( + args, + { content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false }, + ) + expect(timedOut).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 }) }) it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => { @@ -952,8 +962,11 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { const rendered = renderResult(c.result) const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false }) // Drop card + output; the remaining fields are the parsed exit. - const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string } + const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string } expect(exit).toEqual(c.expect) + // Whatever the parse consumed is gone from the body, so a card with an exit + // pill never shows the same status twice. + expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /) } }) @@ -964,6 +977,7 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { // newline; parsing requires the leading newline emitted for real markers, so this stays exit 0. const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false }) expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 }) + // Unparsed marker-like text is real output, so it is NOT stripped from the body. // Same for a fake signal marker with no leading newline. const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false }) expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 }) diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml new file mode 100644 index 0000000000..0bac8a9924 --- /dev/null +++ b/packages/client/README.i18n.yaml @@ -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 packages/client/README.md +README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b +README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8 diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000000..b111d67fa4 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,34 @@ +# client/ — web-GUI browser half + +English | [中文](README.zh.md) + +The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-`. + +| Package | Role | ctx key / slot | +|---|---|---| +| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) | +| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) | +| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) | +| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` | +| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` | +| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) | +| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` | +| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) | +| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` | +| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) | +| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` | +| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) | +| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) | +| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) | +| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) | +| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` | +| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` | +| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) | +| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) | +| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` | +| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) | +| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) | +| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) | +| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) | + +Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer. diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md new file mode 100644 index 0000000000..b498008eb8 --- /dev/null +++ b/packages/client/README.zh.md @@ -0,0 +1,34 @@ +# client/ — web GUI 浏览器半侧 + +[English](README.md) | 中文 + +dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-`。 + +| 包 | 角色 | ctx 键/slot | +|---|---|---| +| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) | +| `modules/` | 客户端模块系统:Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) | +| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) | +| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` | +| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` | +| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) | +| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` | +| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) | +| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` | +| `ui-primitives/` | 纯 React 原子:图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) | +| `ui-layout/` | shell 三栏 AppFrame;声明 `sidebar`/`conversation`/`details`/`conversation.empty` | `ctx.layout` | +| `ui-sidebar/` | 侧栏 shell:Workspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | (slot 宿主) | +| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces`、`conversation.hero.workspace`) | +| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | (slot 宿主) | +| `ui-trajectory/` | Trajectory/Waterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view`) | +| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` | +| `ui-slash/` | 输入触发流水线:光标下的 `/` 与 `@` 检测、分组候选菜单、源名册 | `ctx.slash` | +| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash`) | +| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash`) | +| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` | +| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer`) | +| `ui-settings/` | 设置 shell:触发 chrome + 模态面板;声明 `settings.*` slot | (slot 宿主) | +| `ui-settings-general/` | 设置的无主文案:chrome 内容 + General 分区骨架 | (填充 `settings.*`) | +| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section`) | + +特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 0dd8860d65..6b8558f9be 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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 -README.md: 80228a180faba0c556ff720e999b29b5bb1635b6 -README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819 +# pnpm run verify-translation-pairing --write packages/client/connection/README.md +README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6 +README.zh.md: ca5da643db443956c25399f07c8b460900942ad4 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 80228a180f..173a9b9998 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -4,6 +4,10 @@ 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 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 + +The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). + ## Keyless fixture Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index f4b857886b..ca5da643db 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -4,6 +4,10 @@ 协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +## /api 浏览器信任栅栏 + +node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 + ## 无密钥 fixture 任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 3fba84aab2..d86b2bdf2a 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -33,7 +33,8 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "schemastery": "^3.18.0" }, "files": [ "lib/index.js", diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts new file mode 100644 index 0000000000..8c1bddd631 --- /dev/null +++ b/packages/client/connection/src/api-request-trust.ts @@ -0,0 +1,129 @@ +/** + * Browser-trust fence for every /api request. Defends the two confused-deputy + * paths a browser opens against a local HTTP API — DNS rebinding (Host names + * the attacker's domain while the socket reaches this server) and cross-site + * requests fired from a malicious page. The Host fence binds every request, + * browser-looking or not: over plain HTTP a browser attaches neither Origin + * nor Fetch-Metadata to reads (EventSource, images, navigations — those + * headers go only to trustworthy destinations), so an unmarked request may + * still be a rebound browser read and Host is the one header rebinding cannot + * forge. Non-browser and remote clients pass the same fence via loopback, the + * CLI-derived LAN IP literals, or a declared `trustedHosts` authority. + * Network reachability and authentication stay out of scope: binding policy + * belongs to the webserver config, and this fence is not an auth layer. + */ + +import type { IncomingHttpHeaders } from 'node:http' + +/** The request facts the fence reads (structural subset of IncomingMessage). */ +interface ApiTrustRequest { + headers: IncomingHttpHeaders +} + +function header(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name] + 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 { + // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws. + return new URL(`http://${authority}`) + } catch { + return undefined + } +} + +/** + * Assert one configured `trustedHosts` entry is a bare authority (`host` or + * `host:port`) in canonical form: it must survive WHATWG parsing unchanged + * (case aside). Anything parsing would silently rewrite is refused as a typo + * that must fail the load loudly instead of being ignored until requests 403 + * or quietly changing the grant: URL parts beyond the authority + * (`harness.internal/path`, `user@harness.internal` — which would authorize + * the embedded hostname), stripped whitespace, a dangling colon or + * zero-padded port (which would broaden an intended exact-port grant to every + * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding, + * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire + * carries). + * @param entry - the configured value, verbatim. + */ +export function assertTrustedAuthority(entry: string): void { + const entryUrl = parseAuthority(entry) + if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return + throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`) +} + +/** + * Canonical form of a parsed authority: `hostname` when no port was written, + * else `hostname:port`. The port is judged from URL parses under both special + * schemes (their default ports differ, so `:80` and `:443` still count as + * explicit), never from the raw string, where WHATWG trimming would misread + * shapes like `host:port ` as port-less. + */ +function canonicalAuthority(entry: string, entryUrl: URL): string { + // An authority that parsed under http cannot fail under https. + const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port + return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}` +} + +/** + * Whether the request authority matches a `trustedHosts` entry. An entry with + * an explicit port matches that exact authority; a port-less entry matches the + * hostname on any port (the shape the CLI derives for IP-literal LAN serving, + * where the bound port may be OS-assigned). Both sides compare through WHATWG + * normalization, so case and a redundant `:80` never decide trust. + */ +function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean { + return trustedHosts.some((entry) => { + const entryUrl = parseAuthority(entry) + if (entryUrl === undefined) return false + return canonicalAuthority(entry, entryUrl) === entryUrl.hostname + ? entryUrl.hostname === hostUrl.hostname + : entryUrl.host === hostUrl.host + }) +} + +/** + * Decide whether one /api request may reach the RPC bridge. + * @param request - node HTTP request facts (headers). + * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. + * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. + */ +export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean { + // Host fence (DNS-rebinding defense), applied to every request: the browser + // fills Host from the URL it believes it is talking to, so a rebound page + // carries the attacker's domain here even though the socket lands on this + // server. There is no marker shortcut — a browser read over plain HTTP + // (EventSource, images, navigations) arrives with neither Origin nor + // Fetch-Metadata, indistinguishable from curl, and its response is readable + // by the rebound page. + const host = header(request.headers, 'host') + if (host === undefined) return false + const hostUrl = parseAuthority(host) + if (hostUrl === undefined) return false + if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false + // Cross-site fence: modern browsers label the initiator relationship on + // every fetch; an explicit cross-site marker is refused regardless of Origin. + if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false + // Origin fence: when a browser attaches an Origin it must be exactly this + // authority (compared through the same normalization as the Host). Absent + // Origin is fine — the Host fence above already bound the request. The + // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused. + const origin = header(request.headers, 'origin') + if (origin === undefined) return true + try { + return new URL(origin).host === hostUrl.host + } catch { + return false + } +} diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 77f5445f6d..a8e561ba33 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -8,10 +8,12 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + DirectoryEntry, DirectoryListing, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, SessionModels, + GoalsApi, GoalRef, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 4bb5d2baa3..3253efbb06 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -80,6 +80,62 @@ const MARKDOWN_FIXTURE = [ const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)' +/** + * SGR wrapper for the terminal output sample below: authoring the escapes as + * `\u001b` keeps literal control bytes out of this source file. + * @param code - the SGR parameter (an ANSI color or attribute number). + * @param body - the text the attribute applies to. + * @returns the body wrapped in the attribute and a reset. + */ +function sgr(code: number, body: string): string { + return `\u001b[${code}m${body}\u001b[0m` +} + +/** + * Terminal output sample for fixture turn 65, authored to carry every feature + * the terminal card draws that turn 60's two prompt rows cannot reach: + * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to + * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll + * rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the + * height cap collapses the middle. The exit status is authored separately in + * TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash + * presenter CONSUMES its `[exit code: N]` marker out of the body, because a + * terminal card shows the exit as its own pill and leaving the marker in would + * render it twice (packages/bash/tool-bash/src/render.ts). + */ +const TERMINAL_OUTPUT_FIXTURE = [ + sgr(1, 'Running 4 checks'), + `${sgr(32, '\u2713')} typecheck 1.82s`, + `${sgr(32, '\u2713')} lint 0.94s`, + `${sgr(32, '\u2713')} duplication 2.10s`, + `${sgr(31, '\u2717')} unit 8.41s`, + '', + sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'), + ` ${sgr(31, 'FAIL')} caps output at the configured line budget`, + ' expected 16 lines, received 24', + '', + 'NAME LINES BRANCHES FUNCTIONS UNCOVERED', + 'TerminalBlock.tsx 100% 100% 100% -', + 'ansi.ts 100% 100% 100% -', + 'clipboard.ts 100% 100% 100% -', + 'CodeBlock.tsx 98.4% 96.2% 100% 41-43', + 'highlight.ts 100% 100% 100% -', + 'Pill.tsx 100% 100% 100% -', + 'StateDot.tsx 100% 100% 100% -', + 'markdown/Markdown.tsx 100% 100% 100% -', + '', + sgr(31, '1 of 4 checks failed'), +].join('\n') + +/** + * Exit status for each terminal sample, keyed by its output text. Authored + * alongside the sample rather than parsed back out of its trailing marker, + * which is the bash tool's own job and not something to reimplement here. + */ +const TERMINAL_EXIT_STATUS: Record = { + [TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 }, +} + const DEEPSEEK_REASONING = { efforts: [ { id: 'off', name: 'Off' }, @@ -170,7 +226,9 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') + // A two-line command, so the fixture covers the terminal card's one-row-per- + // command-line prompt (and that the card still marks the call exactly once). + toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑') toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入') @@ -224,8 +282,22 @@ function buildAlphaLog(): SessionEvent[] { { content: '实现 fixture 样本', status: 'in_progress' }, { content: '浏览器验收', status: 'pending' }, ] + // Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover — + // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd + // whose prompt label is its last segment, and a non-zero exit authored beside + // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no + // `[exit code: N]` marker, since the real presenter consumes that one out of + // the body. Named `bash`, so it also covers + // the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback + // row) — the two chat-row shapes the terminal card renders in. + // + // Ordered BEFORE the todo turn deliberately: the standing plan retires at the + // next `turn/start`, so a turn appended after it would leave the dock's plan + // strip empty and take the todo surfaces' own coverage with it. + toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) + const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). @@ -250,7 +322,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { return undefined } switch (name) { + // Both names present the same terminal card: `fx-bash` lands on the + // render-site fallback row, `bash` on the keyed BashRow registration. case 'fx-bash': + case 'bash': return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' } case 'fx-write': return { @@ -271,7 +346,10 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR if (call === undefined) return undefined switch (call.card) { case 'terminal': - return { card: 'terminal', output: resultText, exitCode: 0 } + // The sample's own exit status, authored beside it: re-parsing the + // trailing marker here would duplicate the bash tool's `parseExitStatus`, + // which this client-side fixture cannot import. + return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) } case 'diff': return { card: 'diff', diffs: call.diffs } case 'generic': @@ -302,7 +380,73 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi return undefined } +/** + * Fixture parallel of the plan unit's double-event fold: `command/run` + * records named `plan` set the wanted target (`off` → false, else true); + * `plan/mode` commits and clears it. `wanted` is exposed for the prompt + * boundary (the fixture's agent/step parallel). + */ +function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } { + let active = false + let wanted: boolean | null = null + for (const event of log) { + const item = event as unknown as { type: string; data?: Record } + if (item.type === 'command/run' && item.data?.['name'] === 'plan') { + const args = item.data['args'] + wanted = (typeof args === 'string' ? args : '').trim() !== 'off' + } else if (item.type === 'plan/mode') { + active = item.data?.['active'] === true + wanted = null + } + } + return { active, pending: wanted !== null && wanted !== active, wanted } +} + +/** The plan projection's wire view over the full log. */ +function planViewOf(log: readonly SessionEvent[]): { active: boolean; pending: boolean } { + const plan = foldPlan(log) + return { active: plan.active, pending: plan.pending } +} + /** Fixture parallel of the host's projection units: whole current values per key over the full log. */ +/** Fixture preset table (the host PermissionService defaults). */ +const PERMISSION_PRESETS: Record = { + 'workspace-write': { sandbox: 'workspace-write', approval: 'ask', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', description: 'Full file access without approval prompts.' }, +} + +/** Host permissions-unit parallel: fold the three knob events, derive the select over the fixture defaults. */ +function permissionSelectOf( + log: readonly SessionEvent[], +): { options: { value: string; name: string; description?: string }[]; currentValue: string } { + let preset: string | null = null + let sandbox = 'workspace-write' + let approval = 'ask' + for (const event of log) { + const item = event as { type: string; data: Record } + if (item.type === 'permission/preset') preset = item.data['preset'] as string + else if (item.type === 'sandbox/mode') sandbox = item.data['mode'] as string + else if (item.type === 'approval/policy') approval = item.data['policy'] as string + } + const matches = (spec: { sandbox: string; approval: string }): boolean => spec.sandbox === sandbox && spec.approval === approval + let currentValue = 'custom' + const folded = preset === null ? undefined : PERMISSION_PRESETS[preset] + if (preset !== null && folded !== undefined && matches(folded)) { + currentValue = preset + } else { + for (const [name, spec] of Object.entries(PERMISSION_PRESETS)) { + if (matches(spec)) { currentValue = name; break } + } + } + return { + options: [ + ...Object.entries(PERMISSION_PRESETS).map(([value, spec]) => ({ value, name: value, description: spec.description })), + ...currentValue === 'custom' ? [{ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }] : [], + ], + currentValue, + } +} + function projectionValuesOf(log: readonly SessionEvent[]): Record { const values: Record = {} const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title') @@ -311,6 +455,12 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record= 0; i--) { + const event = log[i] as unknown as { + type: string + data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } } + } | undefined + if (event === undefined || event.type !== 'user/message') continue + const source = event.data?.source + if (source?.kind !== 'goal' || source.round !== 0) continue + const change = source.change + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (change === undefined || change.kind !== 'goal/change') continue + if (change.operation === 'clear') return null + return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } + } + return null +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -475,9 +703,43 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { updatedAt: fixtureEpoch, }] let nextWorkspace = 1 + + // In-memory browse tree behind the fixture's `browse` picker capability — + // deterministic content mirroring the design mock so assembled Web tests + // and snapshots can walk it. Leaves are materialized lazily: a child listed + // by its parent lists as empty until something is created inside it. + const FIXTURE_HOME = '/home/fixture' + const directoryTree = new Map([ + ['/', ['home']], + ['/home', ['fixture']], + [FIXTURE_HOME, ['Documents', 'Downloads', '.config']], + [`${FIXTURE_HOME}/Documents`, [ + 'project', 'deepseek-iOS', 'deepseek-android', 'deepseek-platform', + 'deepseek-web', 'deepseek-harness', 'deepseek-app', 'deepseek-landing-blog', + ]], + ]) + const childrenOf = (path: string): string[] | undefined => { + const known = directoryTree.get(path) + if (known !== undefined) return known + const parent = path.slice(0, path.lastIndexOf('/')) || '/' + const name = path.slice(path.lastIndexOf('/') + 1) + return directoryTree.get(parent)?.includes(name) === true ? [] : undefined + } + const crumbsOf = (path: string): { name: string; path: string; hidden: boolean }[] => { + const crumbs = [{ name: '/', path: '/', hidden: false }] + let acc = '' + for (const segment of path.split('/').filter(Boolean)) { + acc += `/${segment}` + crumbs.push({ name: segment, path: acc, hidden: false }) + } + return crumbs + } const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) - /** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */ + /** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */ const pendingApprovalRpcId = mint() + const pendingApprovalId = 'fx-approval-1' as Extract['approvalId'] + /** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */ + let approvalPending = true const pendingQuestionRpcId = mint() let questionPending = true const fixtureQuestions: Extract['questions'] = [ @@ -572,6 +834,47 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { for (const frame of projectionFramesOf(id, log, event)) emitMux(frame) } + /** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */ + const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => { + const ref = change.operation === 'clear' ? change.cleared : change.goal + const payload = change.operation === 'clear' + ? { cleared: change.cleared, clearedAt: change.clearedAt } + : { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } + append(id, { + type: 'user/message', surfaceOp: 'append', + data: userMessage( + text(`${JSON.stringify(payload)}`), + { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource, + ), + }) + return backscanGoal(logOf(id)) as FxGoalProjection + } + + /** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */ + const fxMutateGoal = ( + request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>, + ref: { id: string; revision: number }, + next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, + ): Promise> => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const id = request.payload.sessionId + const current = backscanGoal(logOf(id)) + if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { + return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } }) + } + const goal = next(current) + if (goal === undefined) { + return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } }) + } + const projection = appendGoalChange(id, { + kind: 'goal/change', version: 1, + operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete', + goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(), + }) + return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } }) + } + /** At most one in-flight replay per session; cancel clears it. */ const replays = new Map; finish(aborted: boolean): void }>() @@ -718,6 +1021,27 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') return ok(request, { sessionId: created.sessionId }) }, + rename: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const { sessionId, title } = request.payload + const normalized = title.trim().replace(/\s+/g, ' ') + if (normalized.length === 0) { + return err(request, { + code: 'title-invalid', + message: 'session title must contain visible characters', + details: { sessionId }, + }) + } + // The append emits the session/event and its session/projection frame + // (host parallel); the unary response settles the caller first. + append(sessionId, { + type: 'session/title', + data: { title: normalized, messageSeqs: [], source: { kind: 'user' } }, + }) + const appended = logOf(sessionId).at(-1) as SessionEvent + return ok(request, { title: normalized, seq: appended.seq }) + }, history: async (request) => { const log = logs.get(request.payload.sessionId) ?? [] // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency). @@ -804,6 +1128,12 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { nextTurn.set(id, turn + 1) setRunning(id, true) append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + // Boundary flush parallel (the host's agent/step seam): an outstanding + // /plan selection commits as plan/mode inside the opened turn. + const plan = foldPlan(logOf(id)) + if (plan.wanted !== null && plan.wanted !== plan.active) { + append(id, { type: 'plan/mode', data: { active: plan.wanted } }) + } append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) }) startReply( id, @@ -833,7 +1163,42 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), - pickDirectory: request => ok(request, { path: null }), + // Deterministic native pick: the keyless lanes drive the full + // pick-then-adopt path without an OS chooser (design-mock content, + // same tree the browse primitives serve). + pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), + listDirectory: (request) => { + const target = request.payload.path ?? FIXTURE_HOME + const children = childrenOf(target) + if (children === undefined) { + return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }) + } + return ok(request, { + path: target, + home: FIXTURE_HOME, + crumbs: crumbsOf(target), + entries: [...children].sort((a, b) => a.localeCompare(b)) + .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), + // The fixture tree is tiny; no level ever reaches a backend bound. + truncated: false, + }) + }, + createDirectory: (request) => { + const parent = request.payload.path + const children = childrenOf(parent) + if (children === undefined) { + return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } }) + } + // Same root special case as listDirectory's entry paths: a plain join + // under '/' would mint '//name' and fork the tree's identity. + const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}` + if (children.includes(request.payload.name)) { + return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } }) + } + directoryTree.set(parent, [...children, request.payload.name]) + directoryTree.set(target, []) + return ok(request, { path: target }) + }, openPath: request => ok(request, { opened: true as const }), }, workspace: { @@ -934,7 +1299,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { commands: [ { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '' } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '' } }, + { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, ], }) }, @@ -950,15 +1317,72 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim()) const name = match?.[1] const args = match?.[2] ?? '' + // /permission mirrors the host handler: switch through the knob + // events (each append pushes a permissions projection frame). + if (name === 'permission') { + const preset = args.trim() + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const spec = PERMISSION_PRESETS[preset] + if (preset === '') { + const current = permissionSelectOf(logOf(id)).currentValue + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } }) + } else if (spec === undefined) { + append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) + } else { + if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) + append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) + append(id, { type: 'approval/policy', data: { policy: spec.approval } }) + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } }) + } + return ok(request, { matched: true as const, commandId }) + } + if (name === 'goal') { + // Host parallel: /goal with an objective creates (or reports) the + // current goal; the command lifecycle pair brackets the mutation. + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const objective = args.trim() + const current = backscanGoal(logOf(id)) + let text: string + if (objective === '') { + text = current === null ? 'No goal is set. Usage: /goal ' : `Current goal: ${current.goal.objective}` + } else if (current !== null && current.goal.phase !== 'complete') { + text = `A goal already exists (${current.goal.objective}). Clear it first.` + } else { + const created = appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'create', + goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 }, + roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), + }) + text = `Goal created: ${created.goal.objective}` + } + append(id, { type: 'command/done', data: { commandId, kind: 'success', text } }) + return ok(request, { matched: true as const, commandId }) + } + // Host parallel: /plan on an idle fixture session commits plan/mode + // immediately (the boundary flush covers only a running turn), so the + // outcome copy matches the immediate branch of the host handler. + const running = summaryOf(id)?.running === true const outcomes: Record = { compact: 'fixture:已压缩(假动作)', echo: args.trim(), - 'goal-fixture': `fixture:goal 已设置(${id})`, + plan: args.trim() === 'off' + ? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.') + : (running + ? 'Entering plan mode (applies from the next step). Use /plan off to leave.' + : 'Plan mode on. Use /plan off to leave.'), } const text = name === undefined ? undefined : outcomes[name] if (name === undefined || text === undefined) return ok(request, { matched: false as const }) const commandId = `fx-cmd-${logOf(id).length}` as CommandId append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + if (name === 'plan' && !running) { + const plan = foldPlan(logOf(id)) + if (plan.wanted !== null && plan.wanted !== plan.active) { + append(id, { type: 'plan/mode', data: { active: plan.wanted } }) + } + } append(id, { type: 'command/done', data: { commandId, kind: 'success', ...text === '' ? {} : { text } } }) return ok(request, { matched: true as const, commandId }) }, @@ -974,6 +1398,62 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) }, }, + goals: { + // Mutation-only mirror of the host handlers: each verb CAS-checks the + // projected current goal, appends the whole-value change (the mux + // stream and projection frame ride the shared append path), and + // acknowledges with the new ref only. + create: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const id = request.payload.sessionId + const current = backscanGoal(logOf(id)) + if (current !== null && current.goal.phase !== 'complete') { + return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } }) + } + const projection = appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'create', + goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 }, + roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), + }) + return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } }) + }, + edit: request => fxMutateGoal(request, request.payload.ref, current => ({ + ...current.goal, + revision: current.goal.revision + 1, + ...request.payload.objective === undefined ? {} : { objective: request.payload.objective }, + ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, + })), + pause: request => fxMutateGoal(request, request.payload.ref, current => ( + current.goal.phase === 'active' + ? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' } + : undefined + )), + resume: request => fxMutateGoal(request, request.payload.ref, current => ( + current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active' + ? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' } + : undefined + )), + complete: request => fxMutateGoal(request, request.payload.ref, current => ( + current.goal.phase === 'complete' + ? undefined + : { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' } + )), + clear: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const id = request.payload.sessionId + const current = backscanGoal(logOf(id)) + if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) { + return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } }) + } + appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'clear', + cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(), + }) + return ok(request, { cleared: true as const }) + }, + }, events: { async *mux(_request, signal) { const conn = new FxInbox() @@ -991,14 +1471,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } }) } } - conn.push({ - rpcId: pendingApprovalRpcId, - payload: { - type: 'approval/requested', sessionId: sid('fx-alpha'), - approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract['approvalId'], - toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)', - }, - }) + if (approvalPending) { + conn.push({ + rpcId: pendingApprovalRpcId, + payload: { + type: 'approval/requested', sessionId: sid('fx-alpha'), + approvalId: pendingApprovalId, + toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', + }, + }) + } if (questionPending) { conn.push({ rpcId: pendingQuestionRpcId, @@ -1036,6 +1518,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, }, respond(message: ClientResponse): Promise { + // Same routing discipline as the host: rpcId first, then the payload's + // audit correlation; a settled or unknown id is not-pending. + if (message.rpcId === pendingApprovalRpcId) { + if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' }) + if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) + const value = message.result.value as { approvalId?: unknown; outcome?: unknown } + if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) { + return Promise.resolve({ accepted: false, reason: 'bad-response' }) + } + approvalPending = false + emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome }) + return Promise.resolve({ accepted: true }) + } if (!questionPending || message.rpcId !== pendingQuestionRpcId) { return Promise.resolve({ accepted: false, reason: 'not-pending' }) } @@ -1090,10 +1585,13 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.history': return this.api.sessions.history(request) case 'session.models': return this.api.sessions.models(request) case 'session.selectModel': return this.api.sessions.selectModel(request) + case 'session.rename': return this.api.sessions.rename(request) case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) + case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) + case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) @@ -1104,6 +1602,12 @@ export class FixtureApiClient extends AbstractApiClient { // The in-memory execute never blocks, so a never-aborting signal is faithful here. case 'command.execute': return this.api.commands.execute(request, new AbortController().signal) case 'skill.list': return this.api.skills.list(request) + case 'goal.create': return this.api.goals.create(request) + case 'goal.edit': return this.api.goals.edit(request) + case 'goal.pause': return this.api.goals.pause(request) + case 'goal.resume': return this.api.goals.resume(request) + case 'goal.complete': return this.api.goals.complete(request) + case 'goal.clear': return this.api.goals.clear(request) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 1ce30dbdc4..0aa2cc3f82 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -13,6 +13,7 @@ import { WebApiClient } from './web-api-client.ts' export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, @@ -20,6 +21,7 @@ export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, + GoalsApi, GoalRef, } from './api.ts' export { RpcId, AbstractApiClient, transportError } from './api.ts' diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 33f6d0cc41..ce55089bdb 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,11 +1,12 @@ /** Host HTTP bridge for browser-client RPC. */ import type { Context } from 'cordis' +import z from 'schemastery' // Activates the httpServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' -import { isTrustedNativeDialogRequest } from './native-dialog-request.ts' +import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -15,20 +16,42 @@ export const name = 'client-connection' /** Services required before mounting the route. */ export const inject = ['httpServer', 'apiProxy'] +/** Plugin config: the deployment's non-loopback serving authorities. */ +export interface ConnectionConfig { + /** + * Authorities this deployment serves beyond loopback: exact `host:port`, or + * port-less `host` matching any port. The /api trust fence refuses any + * request whose Host is neither loopback nor listed here, so a + * non-loopback (`0.0.0.0`) deployment must declare the names it is reached + * by (the dsh CLI derives the machine's LAN IP literals itself). An entry + * that is not a bare, canonical authority fails the plugin load. + */ + trustedHosts?: string[] +} + +export const Config: z = z.object({ + trustedHosts: z.array(String).default([]), +}) + /** - * Mounts the API gateway under the browser transport prefix. + * Mounts the API gateway under the browser transport prefix. Every request on + * the prefix passes the browser-trust fence first (DNS-rebinding and + * cross-site defense — [api-request-trust](./api-request-trust.ts)). * @param ctx - Host plugin context. + * @param config - resolved plugin config (schema defaults applied). */ -export function apply(ctx: Context): void { +export function apply(ctx: Context, config?: ConnectionConfig): void { + // The Loader resolves schema defaults; hand-built test contexts may pass none. + const trustedHosts = config?.trustedHosts ?? [] + // Config boundary: a malformed entry fails the load loudly here rather than + // silently authorizing its hostname prefix at request time. + for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) const route: WebRoute = { kind: 'prefix', path: API_PATH, handler: async (req, res) => { - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - if ((pathname === `${API_PATH}/host.pickDirectory` - || pathname === `${API_PATH}/host.openPath`) - && !isTrustedNativeDialogRequest(req)) { + if (!isTrustedApiRequest(req, trustedHosts)) { res.writeHead(403) res.end('forbidden') return diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts deleted file mode 100644 index 0eaf09f149..0000000000 --- a/packages/client/connection/src/native-dialog-request.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** Trust check for browser requests that can invoke privileged native host actions. */ - -import type { IncomingHttpHeaders } from 'node:http' - -interface NativeDialogRequest { - headers: IncomingHttpHeaders - socket: { remoteAddress?: string | undefined } -} - -function header(headers: IncomingHttpHeaders, name: string): string | undefined { - const value = headers[name] - return typeof value === 'string' ? value : undefined -} - -function isLoopback(address: string | undefined): boolean { - if (address === undefined) return false - if (address === '::1') return true - const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address - const first = ipv4.split('.')[0] - return first === '127' -} - -function isLoopbackHostname(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '[::1]' || 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) -} - -/** - * Require a local socket plus browser-controlled same-origin metadata. - * @param request - the node HTTP request facts used by the carrier guard. - * @returns true only for a same-origin browser request whose peer and URL are loopback. - */ -export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean { - if (!isLoopback(request.socket.remoteAddress)) return false - if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false - const origin = header(request.headers, 'origin') - const host = header(request.headers, 'host') - if (origin === undefined || host === undefined) return false - try { - const parsed = new URL(origin) - const hostUrl = new URL(`http://${host}`) - return (parsed.protocol === 'http:' || parsed.protocol === 'https:') - && parsed.host === host - && isLoopbackHostname(parsed.hostname) - && isLoopbackHostname(hostUrl.hostname) - } catch { - return false - } -} diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts new file mode 100644 index 0000000000..f145230a8b --- /dev/null +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -0,0 +1,108 @@ +/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */ + +import { describe, expect, it } from 'vitest' +import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts' + +function request(headers: Record): { headers: Record } { + return { headers } +} + +describe('isTrustedApiRequest', () => { + it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => { + // Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to + // reads (EventSource, images, navigations), so a rebound-origin GET is + // markerless and its response readable: no marker shortcut may exist. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true) + expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true) + expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false) + expect(isTrustedApiRequest(request({}), [])).toBe(false) + }) + + it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => { + for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) { + expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true) + } + }) + + it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => { + expect(isTrustedApiRequest(request({ + host: 'evil.example:3080', + origin: 'http://evil.example:3080', + 'sec-fetch-site': 'same-origin', + }), [])).toBe(false) + }) + + it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => { + const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' } + expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true) + expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false) + expect(isTrustedApiRequest(request(headers), [])).toBe(false) + }) + + it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => { + expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true) + // An unparsable entry never matches; it must not poison the rest of the list. + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false) + }) + + it('refuses cross-origin browser markers even on a loopback Host', () => { + // Origin present and different → cross-site request that survived preflight rules. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false) + // Explicit cross-site label → refused regardless of Origin. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false) + // Opaque origin (sandboxed iframe, file: page) parses to no authority. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false) + }) + + it('accepts a same-origin browser request, with or without an Origin header', () => { + expect(isTrustedApiRequest(request({ + host: 'localhost:3080', + origin: 'http://localhost:3080', + 'sec-fetch-site': 'same-origin', + }), [])).toBe(true) + // Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site. + expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true) + }) + + it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => { + for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) { + expect(() => { assertTrustedAuthority(entry) }).not.toThrow() + } + // WHATWG parsing would quietly read a hostname out of each of these; the + // config boundary must refuse them instead of authorizing the prefix. + for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } + // WHATWG trimming would silently strip these; the entry must fail instead. + for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } + // WHATWG parsing would silently rewrite these — a dangling colon or + // zero-padded port would broaden an intended exact-port grant to every + // port, and non-canonical host spellings would not read back as written. + for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } + }) + + it('never lets stray whitespace broaden an exact-port entry to every port', () => { + // Defense in depth below the load-time assert: the explicit-port judgment + // reads the parsed URL, so a trimmed `host:port ` entry stays exact. + const trusted = ['harness.internal:3080 '] + expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false) + expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true) + }) + + it('refuses malformed or untrusted authorities on browser requests', () => { + const markers = { 'sec-fetch-site': 'same-origin' } + expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false) + }) +}) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 1dac997ae2..650f982b11 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -45,6 +45,7 @@ export class FakeApiClient implements IApiClient { // Programmable slots (defaults answer OK-empty); reassign per case. onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) + onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => Promise> = () => Promise.resolve(ok({ @@ -70,6 +71,18 @@ export class FakeApiClient implements IApiClient { onOpenPath: (payload: unknown) => Promise> = () => Promise.resolve(ok({ opened: true as const })) + onListDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) + + onCreateDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake/new' })) + private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -84,6 +97,7 @@ export class FakeApiClient implements IApiClient { models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)), selectModel: (payload: ModelTarget & { sessionId: SessionId }) => this.record('session.selectModel', payload, this.onSelectModel(payload)), + rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } @@ -91,6 +105,8 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), + listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)), + createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), } @@ -127,6 +143,15 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } + readonly goals: IApiClient['goals'] = { + create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index bd66d124a4..70ee127b3d 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => { expect(response.rpcId).toBe(request.rpcId) if (!response.result.ok) throw new Error('list failed') const commands = response.result.value.commands - expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture']) + expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') expect(echo?.input?.hint).toBeTruthy() @@ -64,11 +64,11 @@ describe('createFixtureApi commands/skills', () => { it('addresses execute to the session; an unknown session errs', async () => { const api = createFixtureApi() - const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal) + const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal) if (!hit.result.ok) throw new Error('execute failed') expect(hit.result.value.matched).toBe(true) - const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal) + const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal) expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 2d8015506f..ac27323eb9 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -69,9 +69,22 @@ describe('createFixtureApi', () => { // tail block still rides it — empty-log cut at -1, the host convention. const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 })) if (!empty.result.ok) throw new Error('empty failed') - // Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write. + // Fixture composes the todos + plan units (host parallel when tool-todo + // and plan-mode are mounted): the empty-log values. expect(empty.result.value).toEqual({ - events: [], hasMore: false, projections: { asOfSeq: -1, values: { todos: null } }, + events: [], hasMore: false, projections: { asOfSeq: -1, values: { + todos: null, + // Permission unit composed: the composition-default select. + permissions: { + options: [ + { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, + ], + currentValue: 'workspace-write', + }, + plan: { active: false, pending: false }, + goal: null, + } }, }) }) @@ -206,7 +219,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 4) abort.abort() + if (envelopes.length >= 8) abort.abort() } return envelopes } @@ -214,13 +227,16 @@ describe('createFixtureApi', () => { const second = await openOnce() expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow the subscribed frame (title + todos units). + // Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units). expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) - expect(first[3]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[3]?.rpcId).toBe(first[3]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[4]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[4]?.rpcId).toBe(first[4]?.rpcId) + expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' }) + expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) + expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) + expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[7]?.rpcId).toBe(first[7]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { @@ -307,6 +323,44 @@ describe('createFixtureApi', () => { })).toEqual({ accepted: true }) }) + it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => { + const api = createFixtureApi() + // Discover the resident approval's stable rpcId from the mux baseline. + const abort = new AbortController() + const seen: { rpcId: string; frame: MuxFrame }[] = [] + const consuming = (async () => { + for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload }) + })() + await vi.waitFor(() => { + expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true) + }) + const requested = seen.find(s => s.frame.type === 'approval/requested') + if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable') + const approvalId = requested.frame.approvalId + + // Routed but malformed answers. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + // The real answer settles the question and broadcasts resolved. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } })) + .toEqual({ accepted: true }) + await vi.waitFor(() => { + expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true) + }) + // Settled: a duplicate answer is late, and a fresh mux open replays nothing. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } })) + .toEqual({ accepted: false, reason: 'not-pending' }) + abort.abort() + await consuming + const abort2 = new AbortController() + const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2) + expect(replayed.some(f => f.type === 'approval/requested')).toBe(false) + }) + it('describe answers the fixture identity', async () => { const api = createFixtureApi() const response = await api.host.describe(req({})) @@ -315,6 +369,22 @@ describe('createFixtureApi', () => { expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) }) + it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { + const api = createFixtureApi() + const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) + if (!created.result.ok) throw new Error('create failed') + expect(created.result.value.path).toBe('/srv') + const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal) + if (!listed.result.ok) throw new Error('list failed') + expect(listed.result.value.crumbs).toEqual([ + { name: '/', path: '/', hidden: false }, + { name: 'srv', path: '/srv', hidden: false }, + ]) + const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal) + if (!root.result.ok) throw new Error('root list failed') + expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) + }) + it('workspace.list serves the resident account and create reuses on path collision', async () => { const api = createFixtureApi() const listed = await api.workspace.list(req({})) @@ -394,6 +464,47 @@ describe('createFixtureApi', () => { expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed']) }) + it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const framesPromise = (async () => { + const frames: MuxFrame[] = [] + for await (const envelope of api.events.mux(req({}), abort.signal)) { + frames.push(envelope.payload) + if (frames.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === '重命名')) abort.abort() + } + return frames + })() + await new Promise(resolve => setTimeout(resolve, 10)) + + const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'fx-void' } } }) + + const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' ' })) + expect(blank.result).toMatchObject({ ok: false, error: { code: 'title-invalid', details: { sessionId: 'fx-alpha' } } }) + + const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: ' 重命名 ' })) + if (!renamed.result.ok) throw new Error('rename failed') + expect(renamed.result.value.title).toBe('重命名') + const acceptedSeq = renamed.result.value.seq + // The response seq addresses the appended title event (the client plane + // has no session/title in its event union — titles ride the projection — + // so the event is located by seq and its payload checked structurally). + const history = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 100 })) + if (!history.result.ok) throw new Error('history failed') + const appended = history.result.value.events.find(entry => entry.event.seq === acceptedSeq) + expect(appended?.event).toMatchObject({ + type: 'session/title', + data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } }, + }) + // Beyond the subscribe-time baseline replay, the append emitted exactly + // one title projection frame carrying the new value at the response seq. + const frames = await framesPromise + const titleFrames = frames.filter(f => f.type === 'session/projection' && f.key === 'title' && f.sessionId === sid('fx-alpha') && f.value === '重命名') + expect(titleFrames).toHaveLength(1) + expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq }) + }) + it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => { const api = createFixtureApi() const wsid = 'fx-ws-fixture' as WorkspaceId @@ -698,6 +809,29 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) if (!moved.result.ok) throw new Error('workspace move failed') expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) + // Goal lifecycle over the fixture fold: create → edit → pause → resume → complete → clear; + // every mutation acknowledges with the NEW CAS ref (state rides the projection frames). + const goalCreated = await client.goals.create({ sessionId: id, objective: 'ship it' }) + if (!goalCreated.result.ok) throw new Error('goal create failed') + let ref = goalCreated.result.value.ref + expect(ref.revision).toBe(1) + const edited = await client.goals.edit({ sessionId: id, ref, objective: 'ship it v2' }) + if (!edited.result.ok) throw new Error('goal edit failed') + ref = edited.result.value.ref + const paused = await client.goals.pause({ sessionId: id, ref }) + if (!paused.result.ok) throw new Error('goal pause failed') + ref = paused.result.value.ref + const resumed = await client.goals.resume({ sessionId: id, ref }) + if (!resumed.result.ok) throw new Error('goal resume failed') + ref = resumed.result.value.ref + // A stale ref loses the CAS check. + expect((await client.goals.pause({ sessionId: id, ref: { ...ref, revision: 1 } })).result.ok).toBe(false) + const completed = await client.goals.complete({ sessionId: id, ref }) + if (!completed.result.ok) throw new Error('goal complete failed') + ref = completed.result.value.ref + // complete → complete is an invalid transition. + expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false) + expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } }) }) it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts deleted file mode 100644 index 1a3d70dd15..0000000000 --- a/packages/client/connection/tests/native-dialog-request.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { IncomingHttpHeaders } from 'node:http' -import { describe, expect, it } from 'vitest' -import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' - -function request( - remoteAddress: string | undefined, - headers: IncomingHttpHeaders = { - host: '127.0.0.1:3080', - origin: 'http://127.0.0.1:3080', - 'sec-fetch-site': 'same-origin', - }, -) { - return { socket: { remoteAddress }, headers } -} - -describe('native dialog request trust', () => { - it('accepts loopback same-origin browser requests', () => { - expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true) - expect(isTrustedNativeDialogRequest(request('::1', { - host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(true) - expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(true) - expect(isTrustedNativeDialogRequest(request('127.0.0.2', { - host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(true) - }) - - it('rejects remote sockets and requests without matching browser metadata', () => { - expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false) - expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - }) -}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 2c90cd8b7a..2c7fd0b281 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,4 +1,6 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ +import { EventEmitter } from 'node:events' +import { Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { IncomingMessage, ServerResponse } from 'node:http' @@ -6,48 +8,113 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { API_PATH, apply, inject } from '../src/index.ts' -describe('connection node half', () => { - it('registers the /api prefix route and removes it with the fiber', async () => { - const ctx = new Context() - const routes: WebRoute[] = [] - // Structural fake: the plugin only touches register(); the service class - // carries private state a literal cannot (and need not) reproduce. - const httpServer: Pick = { - register(route) { - routes.push(route) - return () => { routes.splice(routes.indexOf(route), 1) } - }, - tapIndex: () => () => {}, - port: 0, - } - ctx.provide('httpServer', httpServer as HttpServerService) - ctx.provide('apiProxy', {} as unknown as ApiProxy) +/** Structural httpServer fake: the plugin only touches register(). */ +function fakeHttpServer(routes: WebRoute[]): Pick { + return { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } +} - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() +/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */ +function fakeRequest(headers: Record): IncomingMessage { + const request = Readable.from([]) as unknown as IncomingMessage + Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers }) + return request +} + +/** Response recorder compatible with both the fence's short-circuit and the bridge. */ +function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { + const state: { status?: number; body?: unknown } = {} + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(value: number) { state.status = value; return this }, + write() { return true }, + end(this: { writableEnded: boolean }, value?: unknown) { + if (value !== undefined) state.body = value + this.writableEnded = true + return this + }, + }) as unknown as ServerResponse + return { response, state } +} + +async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + const fiber = ctx.plugin({ inject: [...inject], apply }, config) + await fiber.await() + return { routes, dispose: () => fiber.dispose() } +} + +describe('connection node half', () => { + it('fails the load on a trustedHosts entry that is not a bare authority', async () => { + const routes: WebRoute[] = [] + const ctx = new Context() + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + // The apply throw also escapes cordis as a late rejection — the shape the + // boot's installFailLoud is contracted to catch. Capture it so the run + // stays clean, same pattern as the webserver bind-failure test. + const rejections: unknown[] = [] + const onUnhandled = (err: unknown): void => { rejections.push(err) } + process.on('unhandledRejection', onUnhandled) + try { + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) + await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/) + expect(routes).toHaveLength(0) + for (let i = 0; i < 100 && rejections.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority') + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('registers the /api prefix route and removes it with the fiber', async () => { + const { routes, dispose } = await mounted() expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) - - for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) { - let status: number | undefined - let body: unknown - const deniedRequest = { - url, - headers: { - host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', - }, - socket: { remoteAddress: '192.168.1.8' }, - } as unknown as IncomingMessage - const deniedResponse = { - writeHead(value: number) { status = value; return this }, - end(value?: unknown) { body = value; return this }, - } as unknown as ServerResponse - await routes[0]!.handler(deniedRequest, deniedResponse) - expect(status).toBe(403) - expect(body).toBe('forbidden') - } - - await fiber.dispose() + await dispose() expect(routes).toHaveLength(0) }) + + it('refuses an untrusted Host on any /api path before the bridge runs', async () => { + const { routes, dispose } = await mounted() + const { response, state } = fakeResponse() + await routes[0]!.handler(fakeRequest({ + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }), response) + expect(state.status).toBe(403) + expect(state.body).toBe('forbidden') + await dispose() + }) + + it('passes loopback and declared-authority requests through to the bridge', async () => { + const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] }) + // Loopback, no browser markers (curl shape): the fence passes; the carrier + // answers 404 for a GET unary path — proof the bridge ran. + const loopback = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response) + expect(loopback.state.status).toBe(404) + // LAN authority declared as a port-less IP literal — the shape the CLI + // derives for `--host 0.0.0.0` — passes markerless curl on any port. + const lan = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response) + expect(lan.state.status).toBe(404) + // Declared public authority, same-origin browser shape. + const declared = fakeResponse() + await routes[0]!.handler(fakeRequest({ + host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin', + }), declared.response) + expect(declared.state.status).toBe(404) + await dispose() + }) }) diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index ce05fdae22..4eef7fa3c2 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/README.i18n.yaml @@ -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 +# pnpm run verify-translation-pairing --write packages/client/hmr/README.md README.md: 2b2f63c25cbf3a46babef78a4dfb52f859156887 -README.zh.md: 6d94ca4a5e91f390e58575aa4ddf64fc18a509de +README.zh.md: 58fbad900d9ab86a9d28979f691f24de29e9b6f4 diff --git a/packages/client/hmr/README.zh.md b/packages/client/hmr/README.zh.md index 6d94ca4a5e..58fbad900d 100644 --- a/packages/client/hmr/README.zh.md +++ b/packages/client/hmr/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。 +为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。 -浏览器侧订阅系统 SSE 通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber 之前执行:只释放 fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `