Merge remote-tracking branch 'origin/master' into perf/tui-resume-scan

This commit is contained in:
Turtle
2026-07-31 18:32:43 +08:00
591 changed files with 83648 additions and 5872 deletions

View File

@@ -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-web-client-architecture.md: cfc2a7e62358e6282148b2d024ef3b162a903642
2026-07-19-gui-web-client-architecture.zh.md: b5b082c25f664cfcb0ddd3fcc6c4cd3d58472218
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
2026-07-19-gui-web-client-architecture.md: 63b6f5795c3d49f25cd964cf04a0c9d41a667bfb
2026-07-19-gui-web-client-architecture.zh.md: 2d57c12ebae38aafa4e606da95af954990761b3c

View File

@@ -50,7 +50,7 @@ There is no registration model besides slots — the former view and tool rings
## The data object layer (`packages/client/runtime/src/client/sessions/`)
Frames enter, snapshots exit, the fold sits between — React-free (zero React imports, grep-assertable):
Frames enter, snapshots exit, the projection sits between — React-free (zero React imports, grep-assertable):
```
mux/host 帧ConnectionController 泵入sinks 注入)
@@ -62,17 +62,17 @@ SessionManager.handleMuxEnvelope / handleHostEnvelope
Session.handleMuxEnvelope ──► events 窗口seq 连续升序)
│ │ 定稿事件 │ chunk
│ ▼ ▼
FoldAdapter PartialAccumulator
TranscriptAdapter PartialAccumulator
│ (→ nodes (→ partial
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
```
- **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot<ConversationSnapshot>`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental fold; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail.
- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (folded, surface-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; the nodes array is rebuilt but element references come from the cache; unchanged substructures reuse the previous snapshot's references.
- **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot<ConversationSnapshot>`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail.
- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (the human transcript, log-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; an unchanged nodes projection keeps the same array reference, while a changed flow returns a new array that reuses unchanged element references; unchanged substructures reuse the previous snapshot's references.
- **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation.
- **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned.
- **FoldAdapter / PartialAccumulator**: the fold reuses the core SurfaceManager (`@deepseek-ai/dsh-session/surface`), padding sentinel events so a paged window starting at seq > 0 satisfies the core's `seq === index` assertion; a cross-window replace degrades to a tolerant linear scan and sets `foldDegraded`. Chunks stay out of the fold entirely (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark.
- **TranscriptAdapter / PartialAccumulator**: the transcript is the append-origin surface projected in log order (`isAppendSurfaceEvent` from `@deepseek-ai/dsh-session/surface`) plus one marker per landed compaction checkpoint — never the model surface, which shadows replaced ranges and would erase conversation the reader already saw. Node order is seq-monotonic by construction, so there is no core `seq === index` assertion to satisfy and no degradation branch. Chunks contribute no node (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark.
- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; the Web carriage (HTTP POST for the two client→server quadrants, SSE for the two server→client) and the client class family are the layering RFC's territory.
## The React face (`packages/client/web-react`)

View File

@@ -62,17 +62,17 @@ SessionManager.handleMuxEnvelope / handleHostEnvelope
Session.handleMuxEnvelope ──► events 窗口seq 连续升序)
│ │ 定稿事件 │ chunk
│ ▼ ▼
FoldAdapter PartialAccumulator
TranscriptAdapter PartialAccumulator
│ (→ nodes (→ partial
Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
```
- **Session**session.ts懒建、常驻——建成后在后台持续吃帧切走切回秒显。操作面`prompt`/`cancel`RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history幂等`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open。订阅面`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot<ConversationSnapshot>`,构造时挂 `useSelector = bindSnapshotSelector(this)`Session 本身就是 uSES 源。帧分发是一个 switch`session/event` 帧按 seq 去重唯一去重键open 在途时缓冲,否则追加 + 增量 foldopen/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。
- **ConversationSnapshot**conversation.ts不可变快照契约——`nodes`fold 产物surface 序)、`partial``runningCalls``pending``running``removed``openState``hasMore``promptError` 等。**引用纪律**memo 与 uSES 的前提顶层对象每变必新nodes 数组重建但元素引用来自缓存;未变的子结构复用上一快照的引用。
- **Session**session.ts懒建、常驻——建成后在后台持续吃帧切走切回秒显。操作面`prompt`/`cancel`RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history幂等`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open。订阅面`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot<ConversationSnapshot>`,构造时挂 `useSelector = bindSnapshotSelector(this)`Session 本身就是 uSES 源。帧分发是一个 switch`session/event` 帧按 seq 去重唯一去重键open 在途时缓冲,否则追加 + 增量投影open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。
- **ConversationSnapshot**conversation.ts不可变快照契约——`nodes`人类对话记录,日志序)、`partial``runningCalls``pending``running``removed``openState``hasMore``promptError` 等。**引用纪律**memo 与 uSES 的前提):顶层对象每变必新;未变化的 nodes 投影保持同一数组引用,消息流变化时返回新数组并复用未变化的元素引用;未变的子结构复用上一快照的引用。
- **SessionManager**manager.ts实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。
- **Notifier**notifier.ts两条通知通道按变更来源取用。`markDirty()`默认帧驱动一律用它按微任务合批——N 次变更、一次通知、一次重渲染flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。
- **FoldAdapter / PartialAccumulator**fold 复用核心 SurfaceManager`@deepseek-ai/dsh-session/surface`),垫哨兵事件使 seq > 0 起头的分页窗口满足核心 `seq === index` 断言;跨窗口 replace 时降级为容错线性扫描并置 `foldDegraded`。分片完全不进 foldO(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。
- **TranscriptAdapter / PartialAccumulator**对话记录是按日志顺序投影的 append 来源 surface`@deepseek-ai/dsh-session/surface``isAppendSurfaceEvent`),外加每次落地的压缩检查点一个标记——绝不用模型 surface后者遮蔽被替换的范围会抹掉读者已经看过的对话。节点顺序天然按 seq 单调,因此既无核心 `seq === index` 断言需要满足,也没有降级分支。分片不贡献任何节点O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。
- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入代际围栏之内指数退避重连500ms 翻倍至 10s 封顶、抖动、无限重试sinks 单向注入Controller 不认识 Session。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`Web 承载HTTP POST 载两个 client→server 象限、SSE 载两个 server→client 象限)与客户端类族归分层 RFC 属地。
## React 面(`packages/client/web-react`

View File

@@ -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-24-single-harness-home-resolver.md: 10ed0e9f1fd6ac4630d92a66953fdf1d52b3b5f1
2026-07-24-single-harness-home-resolver.zh.md: 1ce56281357595de134ddea285c8c2e0c1801ce9
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md
2026-07-24-single-harness-home-resolver.md: 159ba88b7b4a8d50f1be2cbe5d9162a654014e16
2026-07-24-single-harness-home-resolver.zh.md: 62046abca48a3c2b07fde4180031dc2186dc101f

View File

@@ -22,7 +22,7 @@ One resolver owns the harness home, in `@deepseek-ai/dsh-paths`, single-root:
explicit configured path > $DSH_HOME > ~/.dsh
```
An empty or whitespace-only `$DSH_HOME` is treated as unset, matching the guard telemetry's old resolver carried: without it `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check.
An empty or whitespace-only `$DSH_HOME` is treated as unset, matching the guard telemetry's old resolver carried: without it `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check.
`@deepseek-ai/dsh-home` is deleted. Its three importers (`dsh-tool-bash`, `dsh-skill-local`, `dsh-agent-spine-demo`) now import `resolveDshHome` from `dsh-paths`. `dsh-telemetry`'s `globalConfigDir` delegates to `resolveDshHome`, dropping its second resolver, the `DSH_CONFIG_HOME` override, the XDG/`%APPDATA%` branches, and the `deepseek-harness` namespace; the anonymous id now lives directly under the harness home.

View File

@@ -22,7 +22,7 @@ Status: implemented
explicit configured path > $DSH_HOME > ~/.dsh
```
空或仅含空白的 `$DSH_HOME` 被当作未设置处理,这与 telemetry 旧解析器所带的保护一致:若无此保护,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。
空或仅含空白的 `$DSH_HOME` 被当作未设置处理,这与 telemetry 旧解析器所带的保护一致:若无此保护,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions``storages``dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。
`@deepseek-ai/dsh-home` 被删除。它的三个引用方(`dsh-tool-bash``dsh-skill-local``dsh-agent-spine-demo`)现在从 `dsh-paths` 导入 `resolveDshHome``dsh-telemetry``globalConfigDir` 转而委托给 `resolveDshHome`,去掉了它的第二个解析器、`DSH_CONFIG_HOME` 覆盖项、XDG/`%APPDATA%` 分支以及 `deepseek-harness` 命名空间;匿名 id 现在直接存放在 harness home 之下。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
2026-07-29-projected-token-usage-and-request-context.md: 1e2c5ff067928620dee3d0937c247bec245e34f2
2026-07-29-projected-token-usage-and-request-context.zh.md: 811d92e134b1df0fc6725e6c8d38b37efb57b3aa

View File

@@ -0,0 +1,59 @@
# Agent Note: Projected token usage and context occupancy
Status: implemented
English | [中文](2026-07-29-projected-token-usage-and-request-context.zh.md)
## Problem
The Web stats line derived token totals from the currently loaded conversation nodes. That window is paged, so scrolling changed the totals, and compaction replaces visible content without preserving the billing behind it. Durable provider billing needs a source that survives both.
Context occupancy needs a numerator and a denominator that no existing surface carried to the browser: the prompt size of the latest request, and the capacity of the route it used.
## Decision
Both values are ordinary durable session-projection state. `@deepseek-ai/dsh-token-meter` registers two units when `ctx.sessionProjections` is present.
`tokenUsage` folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value for the same `(turn, step)` replaces the earlier sample instead of double-counting it. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.
`contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes, excluding output — and optional `contextWindow` from the newest `request/context` record. Neither field is synthesized before its source exists.
`request/context` is a new log-only session event recording registration-bound metadata for the route a request resolved to. AgentLoop appends it inside the step beside `request/header`, from the context metadata `prepareCall()` now returns alongside the resolved config — the same registration-bound lookup that already validated reasoning, so no second resolve happens. It is skipped when provider, model, and capacity all match the previous record. A route whose adapter advertises no capacity is recorded with `contextWindow` absent, clearing an older route's denominator.
Capacity deliberately stays out of `EpochHeader`. That type is the reconstruction contract — what a request was built from — and `headerEquals` compares it field-wise to decide whether a snapshot is a real `change`. Capacity is adapter metadata describing a route, so placing it there would let a capacity change masquerade as a request-envelope change and would drag it into the loop's reconstruction invariant.
Both units ride the standard projection lifecycle: history tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence.
The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known.
## Context occupancy is approximate, and that is the decision
`pressureTokens` and `contextWindow` are independent last-wins fields, not one atomic observation. Switching models pairs a fresh capacity with the previous route's pressure until the next request reports usage, and the numerator describes the last request rather than the surface as it currently stands.
This was accepted deliberately. An occupancy percentage is a user-facing reference figure: nothing in the harness makes decisions from it, and compaction reads `measure()` directly instead. The TUI status line has always computed occupancy this way, dividing a `measure()` total by a capacity resolved separately for the selected model — so an atomic variant here would have been the outlier, not the norm.
Reviewers should not treat the non-atomicity as a defect awaiting a fix. A consumer that genuinely needs an exact same-boundary figure should call `ctx.tokenMeter.measure()` at its own request boundary, where both values are available together, rather than read this projection.
## Alternatives considered
**An atomic request-boundary snapshot delivered as a transient mux frame (implemented, then rejected).** An earlier revision of this branch emitted `session/model-request`: one non-replayable frame carrying `contextTokens` and `contextWindow` measured at the same `agent/model-request` boundary. Being the only non-replayable class on the mux stream is what broke it. Host and mux are independent SSE streams with no cross-stream ordering, so a request emitted before a removal could arrive after `host/session-removed` and revive a dead session's telemetry, while a legitimate request for a new lifecycle reusing the same id could be fenced by a late removal. `session/subscribed` is not lifecycle proof — it says a queue began subscribing to an id, not that a new in-memory session replaced an older one — and `lastSeq` is a durable watermark two lifecycles can share. A correct fix required a monotonic lifecycle generation on the frame, on subscription, and on removal, plus a client watermark comparison.
That cost bought a worse display: occupancy went blank after every reconnect and never moved while a conversation grew. It also made ApiProxy a measurement site calling the O(surface) `measure()` on every request, and expressed reconnect state through a synthetic `cancelled` open error the UI had to special-case.
**Fold the loaded node window in React.** Cannot survive pagination or compaction, and makes a presentation package reconstruct log semantics.
**Publish usage only with final assistant messages.** A request that reports a usage chunk and then fails would lose its billing.
**Resolve capacity inside token-meter.** The package documents itself as independent of model routing and is otherwise a pure reader that never appends to the log. AgentLoop already holds the resolved metadata where the header is written.
**Extend the `session.models` RPC with capacity.** The handler already resolves and discards it, so the field is nearly free — but `StatsLine` lives in `ui-conversation` while the model directory lives in `ui-model`, and `ui-conversation` cannot depend on `ui-model`. Delivering it would have required either a second dock entry splitting one text row across two plugins, or a cross-plugin store write.
**Add a context circle beside the model selector.** That placement suggests selected-model state. The stats line carries the figure without a duplicate UI or data path.
## Consequences
Token totals stay stable across pagination, compaction, replay, restart, and reconnect, because they are ordinary durable projection state recovered through the generic paths. The cross-stream reordering race is gone by construction rather than fenced.
Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary.
Each session log gains one small `request/context` record per route or advertised-capacity change. The token-meter projection is the canonical owner of durable session-projection usage semantics; the TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute.

View File

@@ -0,0 +1,59 @@
# Agent Note: token 用量投影与上下文占用率
Status: implemented
[English](2026-07-29-projected-token-usage-and-request-context.md) | 中文
## 问题
Web 统计行原先从当前已加载的会话节点推导 token 总量。该窗口是分页的因此滚动会改变总量压缩compaction又会替换可见内容而不保留其背后的计费用量。持久的提供方计费用量需要一个能同时经受这两者的数据源。
上下文占用率需要一个分子和一个分母,而这两者都不曾由任何既有接口送达浏览器:最新一个请求的提示词规模,以及该请求所用路由的容量。
## 决策
这两个值都是普通的持久会话投影状态。当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册两个单元。
`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;同一 `(turn, step)``assistant/message` 用量值会替换先前样本不会重复计数。推理reasoning仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。
`contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和,不含输出),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。在各自来源出现前,两个字段都不会被合成。
`request/context` 是新增的仅入日志会话事件记录请求所解析到的路由的、绑定注册项的元数据。AgentLoop 在步骤内紧随 `request/header` 追加它,数据取自 `prepareCall()` 现在与已解析配置一并返回的上下文元数据:正是那次已经校验过推理的、绑定注册项的查询,因此不会发生第二次解析。当提供方、模型和容量都与上一条记录相同时会跳过。适配器不公布容量的路由会以缺失 `contextWindow` 的形式记录,从而清除较早路由的分母。
容量刻意不进入 `EpochHeader`。该类型是重建契约,即请求由什么构建而成,而 `headerEquals` 会逐字段比较它,以判定某个快照是否真的是一次 `change`。容量是描述路由的适配器元数据,把它放进去会让容量变化伪装成请求封装的变化,还会把它拖进 AgentLoop 的重建不变式。
两个单元都沿用标准投影生命周期:历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端栅栏。
Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM大语言模型与工具的墙钟时间它们回答的是「屏幕上有什么」按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。
## 上下文占用率是近似值,而这正是决策本身
`pressureTokens``contextWindow` 是两个各自后者胜的独立字段,不是一次原子观测。切换模型时,新容量会与上一路由的压力配对,直到下一个请求报告用量为止;分子描述的是最后一个请求,而不是此刻的表层。
这是刻意接受的结果。占用率百分比是面向用户的参考数字harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。TUI 状态行一直以这种方式计算占用率,即用 `measure()` 总量除以为所选模型单独解析出的容量;因此在这里做成原子版本才是异类,而不是常态。
评审人不应把这种非原子性当作待修的缺陷。确实需要同一边界精确数字的消费方,应在自己的请求边界调用 `ctx.tokenMeter.measure()`,那里两个值同时可得,而不是读取该投影。
## 备选方案
**以临时 mux 帧交付请求边界上的原子快照(已实现,随后否决)。** 本分支较早的一个修订版会发出 `session/model-request`:一个不可回放的帧,携带在同一个 `agent/model-request` 边界测得的 `contextTokens``contextWindow`。真正让它失效的,是它成了 mux 流上唯一的不可回放类别。Host 流与 mux 流是两条独立的 SSEServer-Sent Events彼此之间没有顺序保证在移除之前发出的请求可能在 `host/session-removed` 之后才到达,让一个已死会话的遥测数据复活;而复用同一 id 的新生命周期的合法请求,又可能被一条迟到的移除拦下。`session/subscribed` 不能证明生命周期:它只说明某个队列开始订阅某个 id而不说明新的内存会话替换了较早的会话`lastSeq` 则是两个生命周期可以共用的持久水位线。正确的修法需要在帧上、订阅上和移除上都带一个单调递增的生命周期代次,再加上一次客户端水位线比较。
这份代价换来的是更差的显示:占用率在每次重连后变为空白,而且会话增长期间从不移动。它还把 ApiProxy 变成一个测量点,每个请求都要调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处理的、连接打开时的合成 `cancelled` 错误来表达重连状态。
**在 React 中归并已加载的节点窗口。** 无法跨分页或压缩保留数据还会迫使展示包package重建日志语义。
**仅随最终 assistant 消息发布用量。** 如果请求报告一个用量分片后失败,就会丢失自己的计费用量。
**在 token-meter 内部解析容量。** 该包自述与模型路由无关且在其他方面是一个从不向日志追加内容的纯读取方。AgentLoop 在写入请求头的位置已经持有已解析的元数据。
**为 `session.models` RPC 增加容量字段。** 其处理器已经解析出容量又将其丢弃,因此这个字段几乎是免费的;但 `StatsLine` 位于 `ui-conversation`,模型目录位于 `ui-model`,而 `ui-conversation` 不能依赖 `ui-model`。要送达它,就得增加第二个 dock 条目、把一行文本拆到两个插件里,或者做一次跨插件的 store 写入。
**在模型选择器旁增加上下文圆环。** 该位置会让人以为这是所选模型的状态。统计行可以承载该数字,无需引入重复的 UI 或数据路径。
## 后果
token 总量在分页、压缩、回放、重启和重连期间保持稳定,因为它们是通过通用路径恢复的普通持久投影状态。跨流重排序竞态从构造上就不存在,而不是被栅栏挡住。
占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。
每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 投影是持久会话投影用量语义的正典所有方TUI 未挂载通用投影 seam因此保留自己的实时逐步骤 map而独立浏览器 fixture 会镜像该单元。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md
2026-07-30-client-locale-full-rollout.md: c080d9f240d4533ecd9694ceecfada8662c46425
2026-07-30-client-locale-full-rollout.zh.md: 062d982e3d7ea62f3ca4c8fedb842e8336f0852c
2026-07-30-client-locale-full-rollout.md: 09baf5876029295f7a80b6a0fe6a6395d98f406c
2026-07-30-client-locale-full-rollout.zh.md: 806916aea15a21fd24fdfc4654976b3c4577a675

View File

@@ -14,7 +14,7 @@ After the typed locale standard seat landed (`locale:` on register → framework
**Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record<string, string>` is the key source and `en satisfies Record<XxxKey, string>` locks bilingual balance.
**Zero-cordis atoms (ui-primitives) take copy as props**: `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity).
**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity).
**The non-translation boundary (deliberate decisions, not debt):**
@@ -25,7 +25,7 @@ After the typed locale standard seat landed (`locale:` on register → framework
**Derivation layers stay pure; localization happens at render.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank sessions and the Ungrouped bucket keep their stored titles, with the renderer substituting localized copy off the `blank` flag / absent `workspaceId`; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter, staying pure.
**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario deliberately bypasses the helper to cover the zh default.
**Test and e2e doctrine**: `makeTranslate(...dicts)` (dsh-client-test-runtime) mirrors the service lookup chain (first-dict-wins, key fallback, `{name}` interpolation); component specs stub the `t` seat with it, typed against real props seats. Web e2e uniformly opens through `newEnglishPage` (pins `dsh.locale=en` before boot) and the built-boot snapshot pins the same — goldens are immune to localization migrations; the settings language-switch scenario bypasses the helper and opens a `zh-CN` browser, since the initial locale follows `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)).
The "apply layer subscribes to `locale/change` and re-registers for fresh labels" mechanism in the [settings/locale/theme layering note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) is superseded by this decision (thunk + revision lifecycle).

View File

@@ -14,7 +14,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
**组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record<string, string>` 为 key 源、`en satisfies Record<XxxKey, string>` 锁双语平衡。
**zero-cordis 原子组件ui-primitives文案 props 化**`TerminalBlock`/`JsonTree``labels``CodeBlock``copyLabel`/`copiedLabel``MarkdownText``codeLabels``JsonBlock``truncatedLabel``ConnectionBanner``label``Modal``closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label传对象 props 的调用点按 `t` 身份 memo`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。
**zero-cordis 原子组件ui-primitives文案 props 化**`HoverCard``copyLabel`/`copiedLabel``TerminalBlock`/`JsonTree``labels``CodeBlock``copyLabel`/`copiedLabel``MarkdownText``codeLabels``JsonBlock``truncatedLabel``ConnectionBanner``label``Modal``closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label传对象 props 的调用点按 `t` 身份 memo`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。
**不翻译边界(刻意决定,不是欠账):**
@@ -25,7 +25,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
**派生层保持纯函数,本地化只在渲染层**ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}` 由渲染组合字典模板blank 会话/未分组桶的存储标题不变,渲染按 `blank` 标志/`workspaceId` 缺席替换本地化文案;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl格式模板进字典消息时钟 `clock.md`/`clock.ymd`workspace hover `date.ymd`),格式化函数吃 `t` 参数保持纯。
**测试与 e2e 口径**`makeTranslate(...dicts)`dsh-client-test-runtime镜像服务查找链首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一 `newEnglishPage`boot 前钉 `dsh.locale=en`built-boot snapshot 同样钉 en——golden 对语言迁移免疫settings 语言切换用例刻意绕开该 helper 覆盖 zh 默认态
**测试与 e2e 口径**`makeTranslate(...dicts)`dsh-client-test-runtime镜像服务查找链首个命中字典胜出、key 兜底、`{name}` 插值),组件测试的 `t` 桩统一用它并以真实 props 席位定型。web e2e 统一 `newEnglishPage`boot 前钉 `dsh.locale=en`built-boot snapshot 同样钉 en——golden 对语言迁移免疫settings 语言切换用例绕开该 helper 并开启 `zh-CN` 浏览器,因为初始 locale 跟随 `navigator`[由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md)
[settings/locale/theme 分层 Note](../../proposed/architecture/2026-07-25-client-settings-locale-theme.md) 中"apply 层订阅 `locale/change` 重注册刷新 label"的机制已被本决定取代thunk + revision 生命周期)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md
2026-07-29-human-transcript-append-origin.md: a296b93d538d9c28bd61ee8fd0530863b4bfd878
2026-07-29-human-transcript-append-origin.zh.md: 96e0cd1038fe8904dfd4c1eceaae9b25339c5dca
2026-07-29-human-transcript-append-origin.md: dcc4a786c6f1926f06dce03124ec1d8ca805d7ae
2026-07-29-human-transcript-append-origin.zh.md: 0fefc52afa52e99cdec2bcea1a86b9c28711dd67

View File

@@ -24,9 +24,9 @@ No persisted event, RPC envelope, compaction transaction, or model-visible surfa
## Deferred
The browser client still builds its conversation from the model surface through `FoldAdapter`, so compaction still collapses web history to a single context row. The same predicate is the fix there, together with an append-order transcript projection and a marker component; that work is a separate change against `packages/client/runtime` and `packages/client/ui-conversation`.
The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint's provenance group, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page.
That work must handle a page whose checkpoint cites a `surfaceOp.start` outside the window: pagination no longer spends quota on the checkpoint, so it never cuts on the checkpoint's provenance group, and `FoldAdapter` pads absent events with a non-surface sentinel — so `SurfaceManager` rejects the range and `nodes()` falls back to `degradedSeqs()` with a logged error. The hole predates this change (counting could already run past a checkpoint into the range it shadows), but the old rule accidentally covered the case where the checkpoint was the oldest counted message and pulled the whole shadowed range onto its page. `degradedSeqs()` — every surface-eligible event in append order — is already close to the transcript projection A2 needs, which is the shape to build deliberately rather than reach as a degradation. Rendering compaction *progress* — a terminal indicator while a compaction runs — needs the bracket-first ordering that the queued manual `/compact` work introduces, and is likewise out of scope here. The marker also carries no scale: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. That belongs with progress, where the reader meets the other half of the same information. Whoever takes it should fold the terminal's two replacement branches — replay and the live listener, textually identical and 600 lines apart — into one `renderReplacement(event)` first, so the marker's content has a single home.
Rendering compaction *progress* — a terminal indicator while a compaction runs — needs the bracket-first ordering that the queued manual `/compact` work introduces, and is out of scope here. The marker also carries no scale: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. That belongs with progress, where the reader meets the other half of the same information. Whoever takes it should fold the terminal's two replacement branches — replay and the live listener, textually identical and 600 lines apart — into one `renderReplacement(event)` first, so the marker's content has a single home.
## Alternatives considered

View File

@@ -24,9 +24,9 @@ Status: implemented
## Deferred
浏览器客户端仍通过 `FoldAdapter` 从模型 surface 构建会话,因此压缩在 Web 端仍会把历史折叠成一行上下文。那里的修复用的是同一个谓词,另需按追加顺序的记录投影与一个标记组件;该工作是针对 `packages/client/runtime``packages/client/ui-conversation` 的独立变更
浏览器客户端在[Web 记录投影笔记](2026-07-30-web-transcript-log-ordered-projection.md)中单独修复:它按日志顺序投影同一份 append 来源记录并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会按检查点的溯源分组切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页
该工作必须处理这样一页:其检查点引用的 `surfaceOp.start` 落在窗口之外。分页不再为检查点消耗额度,因此永远不会按检查点的溯源分组切分;而 `FoldAdapter` 会用一个非 surface 的哨兵事件填补缺失事件——于是 `SurfaceManager` 拒绝该范围,`nodes()` 退化为 `degradedSeqs()` 并记录一条错误。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但旧规则恰好覆盖了这样一种情形:检查点是最旧的被计数消息,其溯源分组把整段被遮蔽的范围一起拉到该页。`degradedSeqs()`——按追加顺序的每个 surface 可入事件——已经很接近 A2 所需的记录投影,因此那正是应当刻意构建的形态,而不是作为退化路径被动落到的结果。渲染压缩*进度*——压缩运行期间的终端指示——需要排队式手动 `/compact` 工作引入的“先开括号”顺序,同样不在本次范围内。标记同样不携带规模信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。这件事属于进度那一侧,读者正是在那里遇到同一份信息的另一半。接手者应当先把终端里两处替换分支——回放与实时监听器,文本完全相同却相隔 600 行——合并为一个 `renderReplacement(event)`,让标记的内容只有一个归处。
渲染压缩*进度*——压缩运行期间的终端指示——需要排队式手动 `/compact` 工作引入的“先开括号”顺序,不在本次范围内。标记同样不携带规模信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。这件事属于进度那一侧,读者正是在那里遇到同一份信息的另一半。接手者应当先把终端里两处替换分支——回放与实时监听器,文本完全相同却相隔 600 行——合并为一个 `renderReplacement(event)`,让标记的内容只有一个归处。
## Alternatives considered

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md
2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1
2026-07-30-hover-popup-pointer-grace.zh.md: db10e156103284383f911684b2c92977a0315275

View File

@@ -0,0 +1,35 @@
# Agent Note: Hover popup pointer grace
Status: implemented
English | [中文](2026-07-30-hover-popup-pointer-grace.zh.md)
## Problem
Both popups the workspace browser rows raise floated out of reach of the pointer. `HoverCard` closed on the first `pointerleave` from its anchor and rendered its card `pointer-events: none`, but the card sits 8px off the anchor's right edge, so every path to it crossed ground belonging to neither and killed the card before it arrived — the full workspace path and session title it exists to show could be read only in passing. The row action menus passed `closeOnPointerLeave`, whose handler sat on the portaled list: aiming back at the `...` trigger that opened the list closed it, and so did any overshoot past a list edge, with no window to come back.
## Decision
`usePointerGrace` ([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts)) owns one cancelable delayed close, shared by both atoms, with `POINTER_GRACE_MS` at 200. Leaving arms the close; coming back cancels it. Transit through an anchor-to-popup gap is therefore survivable, while a pointer that has genuinely moved on still dismisses the popup.
`HoverCard` arms the grace on leave instead of closing, and its card no longer sets `pointer-events: none`, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press on the card starts a selection instead of dismissing it; only anchor-region presses and an owner flipping `disabled` dismiss immediately, ahead of the grace.
`Menu` moves pointer-leave dismissal from the portaled list to the wrapper span. React's enter/leave traversal runs over the React tree, so the trigger and the portaled list are one region there: crossing the 4px gap between them, or aiming back at the trigger, no longer counts as leaving. Leaving is only armed while the list is open, and an owner-driven close (selection, Escape, outside click) disarms a pending grace close in an effect keyed on `open` alone — folding that into the outside-click effect would cancel the grace on every re-render, since owners pass a fresh `onClose` closure each time.
## Alternatives considered
**Close the popups only on outside click and Escape.** Rejected because both popups are hover-raised and unlabeled as dismissible; leaving them up after the pointer has moved to another row would strand a card over unrelated content.
**Widen the anchor's hit area to abut the popup.** Rejected because the 8px and 4px offsets are the design's, and an invisible bridge element would have to track every reposition the fixed-positioned popups already do on scroll and resize.
**Keep the hover card `pointer-events: none` and only add the grace.** Rejected because the pointer resting on the card would then hit whatever is behind it, so the grace would expire and close the card the user had just reached.
**Give each atom its own timer.** Rejected because the two closes are the same behavior with the same tuning; a shared hook keeps them from drifting apart.
## Consequences
The hover card is now hit-testable and covers 244px of whatever it overlays while shown, which is the price of being reachable; it still lives only as long as the pointer is on the row or the card. Row menus survive the round trip between trigger and list, and a menu that closes for its own reason cannot be reopened into a stale pending close. Menus without `closeOnPointerLeave` are untouched — the wrapper handlers are only attached when it is set.
## Testing
`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model.

View File

@@ -0,0 +1,35 @@
# Agent Note: 悬浮弹层的指针宽限期
Status: implemented
[English](2026-07-30-hover-popup-pointer-grace.md) | 中文
## 问题
工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭,其卡片还设置了 `pointer-events: none`;但卡片位于锚点右边缘外 8px 处,因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域,卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 `closeOnPointerLeave`,而其处理器挂在传送后的列表上:把指针移回打开该列表的 `...` 触发按钮会关闭列表,越过列表边缘的任何一次抖动同样如此,且没有任何折返窗口。
## 决策
`usePointerGrace`[packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))持有唯一一个可取消的延迟关闭,由两个原子组件共享,`POINTER_GRACE_MS` 为 200。离开会启动关闭折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙而真正移开的指针仍会关闭弹层。
`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择,不会关闭卡片;只有锚点区域内的按下和所有者将 `disabled` 置真,才会抢在宽限期之前立即关闭卡片。
`Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮都不再算作离开。只有在列表打开时才会启动离开关闭由所有者驱动的关闭选择、Escape、外部点击会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect则每次重新渲染都会取消宽限期因为所有者每次都传入新的 `onClose` 闭包。
## 考虑过的替代方案
**仅通过外部点击和 Escape 关闭这两种弹层。** 之所以否决:两者都由悬停唤起,且没有可见的关闭标识;在指针已移到其他行之后仍让它们停留,会把卡片遗留在无关内容之上。
**扩大锚点的命中区域,使其与弹层相接。** 之所以否决8px 与 4px 的偏移来自设计稿,而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和缩放时执行的每一次重新定位。
**保留悬浮卡片的 `pointer-events: none`,只加入宽限期。** 之所以否决:那样指针停在卡片上时命中的是卡片背后的元素,宽限期仍会到期,并关闭用户刚刚够到的卡片。
**让两个原子组件各自持有计时器。** 之所以否决:这两处关闭是同一种行为、同一套调参;共享 hook 可以防止它们各自漂移。
## 后果
悬浮卡片现在可被命中,显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返而因自身原因关闭的菜单也不会被残留的待执行关闭重新关掉。未设置 `closeOnPointerLeave` 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。
## 测试
`packages/client/ui-primitives/tests/hover-card.spec.tsx``tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
2026-07-30-web-transcript-log-ordered-projection.md: 9fb338643774efaeb9deab6f66920a9f4276ce67
2026-07-30-web-transcript-log-ordered-projection.zh.md: 7962dd432b8bbf115acde9dd480eba9c91f35bfc

View File

@@ -0,0 +1,72 @@
# Agent Note: The browser conversation is a log-ordered human transcript
Status: implemented
English | [中文](2026-07-30-web-transcript-log-ordered-projection.zh.md)
## Problem
The browser client built its conversation from the model-visible surface: `FoldAdapter` ran the core `SurfaceManager` over the history window and read `surface.nodes`. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the web flow collapsed every message it shadowed into a single dim context row — conversation the user had already read. Nothing was lost from the log; the defect was entirely in the projection, and [the terminal and the host gateway were fixed the same way](2026-07-29-human-transcript-append-origin.md) while the browser was left for this change.
Surface order made two further problems structural. It is not seq-ascending after a replacement — `SurfaceManager` splices the high-seq checkpoint into the position of the range it shadows — so log-only nodes merged into that array by numeric seq (slash-command rows, interrupted frozen nodes) could be flushed ahead of the checkpoint and never interleave into the retained tail again. And because pagination no longer spends `maxMessages` quota on replacement copies, a page can now carry a checkpoint whose `surfaceOp.start` lies outside the window; the core fold rejects that range, so `nodes()` fell back to a lenient linear scan behind a `console.error` and published a `foldDegraded` flag describing the failure.
## Decision
`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It projects the raw window in log order: every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint. A landed compaction therefore keeps the conversation it shadowed on the model side, and the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two are now separate on both frontends.
Node order is seq-monotonic by construction, and three things follow. The log-only `command/run` / `command/done` pair folds into `CommandNode`s that splice into an already-monotonic array by seq — no anchors, no reordering. `Session` keeps ownership of interrupted frozen nodes and merges them by their fractional seqs with a plain sort, which is now exactly flow order. And a window whose checkpoint cites a shadowed range outside it has no range to resolve, so the marker renders and nothing is logged.
`foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended.
The marker's summary text comes from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes the row non-expandable rather than empty, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves the text.
No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required.
## Recognizing a checkpoint: one declaration, pinned at compile time
Recognition needs all three conditions, as in the terminal: `event.type === 'user/message'`, the compaction seam's checkpoint plugin source, **and** `isReplacementSurfaceEvent(event)`. A plugin-sourced `user/message` that *appends* is injected context — a session-reference card — not a compaction.
What is unreachable from a `packages/client/*` program is `dsh-compact`'s **root**, not the package. The root reaches `dsh-session`'s root, whose cordis `Context` merge declares the host `sessions: SessionStore` against the client's `sessions: ISessions``TS2717`, the one-program-per-side rule in [development.md](../../../../docs/development.md#typescript-project-layout) — and that holds for a type-only import too, because the collision is a compiler fact rather than a bundler one.
The repo's answer to exactly this is a cordis-free leaf subpath, and this change adds one: `COMPACT_CHECKPOINT_SOURCE` and `isCompactCheckpointSource` now live in `packages/compact/compact/src/checkpoint.ts`, which imports no cordis and augments no module (the `dsh-commands/brand` / `dsh-llm/message` shape), and the root re-exports both so every host-side consumer — the terminal's chat helpers, `dsh-session-reference`'s projection — is unchanged. The adapter pins its literal to that declaration with a type-only import:
```ts
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
```
Renaming the seam's plugin id is now a compile error in the client: `TS2322: Type '"compact"' is not assignable to type '"compaction"'`. The import must stay **type-only** — a value import of any `@deepseek-ai` package that is neither a platform module nor an inline-safe wire layer is rejected by the client purity gate (`packages/client/tsdown.client.ts`), whose own message records that type-only imports are erased and never reach it. A type-only leaf import needs both a `tsconfig.base.json` `paths` entry and `{"path": "../../compact/compact"}` in `packages/client/runtime/tsconfig.json` `references`: composite `rootDir` rules apply to erased imports as well, and without the reference the diagnostic is `TS6059`/`TS6307`.
`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` stays as the behavioral half, driving the adapter with a checkpoint built from the canonical **value**. The test value-imports the cordis-free `@deepseek-ai/dsh-compact/checkpoint` leaf and deliberately never loads the compact package root or the host-side `Context` merges reachable through it.
The divergence from the terminal is therefore narrow: both frontends recognize a checkpoint from the same declaration — the terminal value-imports `isCompactCheckpointSource` host-side, where no gate applies, and the client pins the type.
## What #835's positional anchors were for, and why they are dissolved rather than lost
The unmerged manual-compaction-queueing branch fixes the same interleaving bug by recording a per-event anchor — the surface tail at append time — and retargeting shadowed anchors onto the checkpoint. That mechanism exists to make positional anchors survive surface **reordering**. The human transcript is never re-ordered, so anchors have nothing to retarget: the precondition is removed, not the fix discarded. The mechanism is absent from this base and is not authored here.
## Alternatives considered
**Value-import the predicate** from the new leaf and add `dsh-compact` to the client `INLINE_SAFE` allowlist. Rejected: the client needs the plugin id, not the predicate — a type is enough, and an erased import never reaches the purity gate, so nothing has to be admitted to it. The allowlist would only matter for a value import, and there it is a poor trade: `INLINE_SAFE` matches on specifier *prefix*, so admitting the package admits its cordis-importing root along with the leaf.
**A bare shape rule** — any replacement `user/message` is a compaction. Rejected: correct today only because compaction is the sole producer of replacement `user/message`s, with nothing to catch it if that changes. The pinning spec costs one file and removes exactly that risk.
**Tag the checkpoint host-side** through the projection or wire contract. Rejected: most aligned with the "collaborate through cordis services" rule, but the client folds raw `SessionEvent`s today, so it means a wire contract change out of proportion to one pure predicate.
**Move frozen-node ownership into the adapter** (`nodes(extraNodes)`), as the unmerged branch does. Rejected: the interrupted nodes come from the `turn/end` sweep `Session` already runs over the window, and with a seq-monotonic transcript the simple shape is correct — the adapter returns nodes, the session merges frozen ones by seq. Widening the adapter's signature would buy nothing and split the sweep from its product.
**Keep `foldDegraded` as a defensive flag.** Rejected: it described a specific failure of a fold that no longer runs. A flag no consumer can act on, reachable only through a `console.error`, is a false contract.
## Consequences
Compaction no longer erases web history; a session compacted several times shows one marker per landed compaction, in log order, and the same window renders identically live and after a cold resume. The pagination hole is closed by construction rather than defended against, and `ConversationSnapshot` loses a published field, which touched thirteen files.
`ConversationNode` gains an eighth arm, so every exhaustive consumer grew one case: `MessageItem` renders the marker through the new `CompactionItem`, and the trajectory layout widens its no-cell arm so a marker contributes no cell but still advances the duration cursor.
The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves.
The web e2e scenario now seeds a real compaction transaction over its recorded turn, so the aria golden pins both halves of the fix through the real host and a real browser: the recorded prompt and full tool output are still on screen, and one marker sits after them. The seed recording itself is untouched and stays model-authentic — replay derives the compacted turn from the recording's own surface.
## Deferred
Compaction **progress** — an indicator while a compaction runs — needs the bracket-first ordering the queued manual-compaction work introduces, and stays out of scope here as it did in the terminal. The marker also carries no **scale**: the checkpoint's `sourceEventSeqs` already hold the shadowed count, so a count or range would tell a reader how much each row folded. Both belong together, where the reader meets the two halves of the same information.

View File

@@ -0,0 +1,72 @@
# Agent Note: 浏览器会话是按日志顺序投影的人类对话记录
Status: implemented
[English](2026-07-30-web-transcript-log-ordered-projection.md) | 中文
## Problem
浏览器客户端从模型可见的 surface 构建会话:`FoldAdapter` 在历史窗口上运行核心 `SurfaceManager` 并读取 `surface.nodes`。一次成功的压缩会用一个检查点节点替换一段 surface 范围因此该替换一落地Web 流就把它所遮蔽的每条消息折叠成一行灰暗的上下文——那是用户已经读过的对话。日志中什么都没丢失;缺陷完全在投影层,而[终端与宿主历史网关已按同一方式修复](2026-07-29-human-transcript-append-origin.md),浏览器留给了本次变更。
surface 顺序还让另外两个问题成为结构性的。一次替换之后它并非按 seq 升序——`SurfaceManager` 把高 seq 的检查点拼接到它所遮蔽范围的位置上——因此按数值 seq 归并进该数组的仅日志节点(斜杠命令行、被打断的冻结节点)可能被冲刷到检查点之前,再也无法交错回保留下来的尾部。而且由于分页不再为 replacement 副本消耗 `maxMessages` 额度,一页现在可以携带一个 `surfaceOp.start` 落在窗口之外的检查点;核心 fold 拒绝该范围,于是 `nodes()` 退回到一次宽容的线性扫描、打印一条 `console.error`,并发布一个描述该失败的 `foldDegraded` 标志。
## Decision
`TranscriptAdapter` 取代 `FoldAdapter`,并且从不查询 surface 顺序。它按日志顺序投影原始窗口:每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点一个 `CompactionSummaryNode` 标记。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话,标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不在对话中标记任何边界。凡必须发送模型所见内容的一切仍读 surface这是人类投影两者现在在两个前端上都已分离。
节点顺序天然按 seq 单调,由此有三个结果。仅日志的 `command/run` / `command/done` 对折叠成 `CommandNode`,按 seq 插入一个本已单调的数组——无锚点,无重排。`Session` 保留被打断的冻结节点的归属,用一次普通排序按其分数 seq 归并,而这现在恰好就是流顺序。检查点所引被遮蔽范围落在窗口之外的窗口没有范围需要解析,因此标记正常渲染且不打印任何日志。
`foldDegraded``ConversationSnapshot` 消失,随之消失的是哨兵填充、它们所需的 `baseSeq` 算术,以及 `degradedSeqs()`。它们的存在只为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所描述的 fold 已不再运行。删除该标志是修复的一部分,而非修复之后的清理——`degradedSeqs()` 本身已几乎就是按日志顺序的投影,只是作为抛错后的落点而非本意到达。
标记的摘要文本来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时该行不可展开而非空白,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出文本。
没有任何持久化事件、RPC 信封、压缩事务或模型可见 surface 发生变化,也不需要迁移。
## 识别检查点:同一份声明,在编译期钉住
识别需要三个条件同时成立,与终端一致:`event.type === 'user/message'`、压缩缝隙的检查点插件来源,**以及** `isReplacementSurfaceEvent(event)`。一条 append 的插件来源 `user/message` 是注入上下文——跨会话引用卡片——不是压缩。
`packages/client/*` 程序无法到达的是 `dsh-compact` 的**根部**,而不是这个包。根部会到达 `dsh-session` 的根部,后者的 cordis `Context` 合并声明了宿主侧 `sessions: SessionStore`,与客户端的 `sessions: ISessions` 冲突——`TS2717`,即 [development.md](../../../../docs/development.md#typescript-project-layout) 中每侧一个 program 的规则;这一点对仅类型导入同样成立,因为该冲突是编译器事实而非打包器事实。
本仓库对这一情形的既有答案是不含 cordis 的叶子子路径,本次变更就新增了一个:`COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource` 现在住在 `packages/compact/compact/src/checkpoint.ts`,它不导入 cordis、也不增强任何模块`dsh-commands/brand` / `dsh-llm/message` 的形状),而包根重新导出两者,因此每个宿主侧消费方——终端的 chat helper、`dsh-session-reference` 的投影——都不需改动。适配器用仅类型导入把它的字面量钉在该声明上:
```ts
import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint'
const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
```
重命名缝隙的插件 id 现在会在客户端产生编译错误:`TS2322: Type '"compact"' is not assignable to type '"compaction"'`。该导入必须保持**仅类型**——任何既非平台模块又非 inline-safe wire 层的 `@deepseek-ai` 包值导入都会被客户端纯度门禁(`packages/client/tsdown.client.ts`)拒绝,而它自己的报错信息就记录着仅类型导入会被擦除、永不抵达该门禁。仅类型的叶子导入同时需要 `tsconfig.base.json` 的一条 `paths` 条目和 `packages/client/runtime/tsconfig.json` `references` 中的 `{"path": "../../compact/compact"}`composite 的 `rootDir` 规则同样适用于被擦除的导入,缺少该引用时的诊断是 `TS6059`/`TS6307`
`packages/client/runtime/tests/compact-checkpoint-pin.spec.ts` 作为行为侧的另一半保留,用由权威**值**构造的检查点驱动适配器。该测试以值导入方式从不含 cordis 的 `@deepseek-ai/dsh-compact/checkpoint` 叶子路径取得该值,并刻意不加载 compact 包根或经由它可达的宿主侧 `Context` 合并。
因此与终端的分歧很窄:两个前端都从同一份声明识别检查点——终端在宿主侧值导入 `isCompactCheckpointSource`(那里不适用任何门禁),客户端钉住类型。
## #835 的位置锚点是为什么而存在,以及为什么它是被溶解而非丢失
尚未合并的排队式手动压缩分支用另一种方式修同一个交错缺陷:为每个事件记录一个锚点——追加时的 surface 尾部——并把被遮蔽的锚点重定向到检查点上。该机制的存在是为了让位置锚点在 surface **重排**中存活。人类对话记录永不被重排,因此锚点没有任何东西需要重定向:前提被移除,修复并未被丢弃。该机制在本基线上并不存在,本次也不撰写它。
## Alternatives considered
**从新叶子值导入该谓词**,并把 `dsh-compact` 加入客户端 `INLINE_SAFE` 白名单。已拒绝:客户端需要的是插件 id不是谓词——一个类型就够了而被擦除的导入根本不会抵达纯度门禁因此无需向它放行任何东西。白名单只在值导入时才有意义而在那里它是笔糟糕的交换`INLINE_SAFE` 按标识符*前缀*匹配,因此放行该包会连它那个会导入 cordis 的根部一起放行。
**一条纯形状规则**——任何 replacement `user/message` 都是压缩。已拒绝:它今天正确只因为压缩是 replacement `user/message` 的唯一生产者,一旦这点改变便无任何机制能捕获。那个 pin 测试只花一个文件,就精确消除了这一风险。
**在宿主侧给检查点打标**,经投影或线协议。已拒绝:这最贴合“经 cordis 服务协作”的规则,但客户端今天折叠的是原始 `SessionEvent`,因此这意味着一次线协议契约变更——为一个纯谓词付出的代价不成比例。
**把冻结节点的归属移进适配器**`nodes(extraNodes)`),像那个未合并分支所做的那样。已拒绝:被打断的节点来自 `Session` 已经在窗口上运行的 `turn/end` 清扫,而在按 seq 单调的记录之上,简单形态就是正确的——适配器返回节点,会话按 seq 归并冻结节点。加宽适配器签名什么也换不到,还会把清扫与它的产物拆开。
**把 `foldDegraded` 留作一个防御性标志。** 已拒绝:它描述的是一个已不再运行的 fold 的特定失败。一个消费方无法据以行动、只能通过 `console.error` 到达的标志,是一份虚假契约。
## Consequences
压缩不再抹掉 Web 历史;一个被压缩多次的会话按日志顺序显示每次落地压缩一个标记,而同一窗口在实时与冷恢复之后渲染完全相同。分页缺口是被构造性闭合而非被防御,`ConversationSnapshot` 少了一个已发布字段,这触及十三个文件。
`ConversationNode` 增加第八个分支,因此每个穷尽消费方都多一个分支:`MessageItem` 通过新的 `CompactionItem` 渲染标记trajectory 布局加宽它的“无单元格”分支,使标记不贡献单元格但仍推进耗时游标。
性能契约未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。
Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事务,因此 aria 基准经真实宿主与真实浏览器钉住修复的两半:录制的提问与完整工具输出仍在屏幕上,其后坐着一个标记。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出被压缩的那一轮。
## Deferred
压缩**进度**——压缩运行期间的指示——需要排队式手动压缩工作引入的“先开括号”顺序,与终端一样不在本次范围内。标记同样不携带**规模**信息:检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量,因此一个计数或区间可以告诉读者每一行折叠了多少内容。两者应当放在一起,读者正是在那里遇到同一份信息的两半。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-english-compaction-checkpoints.md
2026-07-31-english-compaction-checkpoints.md: dc95ed187a6ba5b86800f06ebefb2776995f9421
2026-07-31-english-compaction-checkpoints.zh.md: 7cd4179430ed775f3807af5a02e81838e495c81e

View File

@@ -0,0 +1,28 @@
# Agent Note: Compaction checkpoints use an English engineering register
Status: implemented
English | [中文](2026-07-31-english-compaction-checkpoints.zh.md)
## Problem
A compaction checkpoint becomes part of the next model request's durable prefix. When a multilingual conversation leads the compactor to preserve its narrative material in the conversation language, the checkpoint can introduce a large amount of a language that is absent from the code, tool output, and existing reasoning prefix. That language then persists across later compaction cycles and can influence the conversation model's reasoning register.
## Decision
`COMPACTION_INSTRUCTION` requires an English-language internal engineering checkpoint. It asks the model to translate narrative source material as needed while preserving exact literals, including paths, commands, errors, identifiers, signatures, and quoted wording when exactness matters. The checkpoint's headings and terse engineering bullets remain the existing structured format.
The requirement is integrated into the first sentence of the trailing compaction instruction. The replayed system prompt, tools, and conversation history remain byte-identical to the routed request, so the change retains the prefix-cache reuse owned by the [compaction summary prefix-cache note](2026-07-21-compaction-summary-prefix-cache-reuse.md).
## Alternatives considered
- **Leave checkpoint language to the replayed conversation** — rejected: the checkpoint is a durable prompt prefix, so preserving a transient conversational register can amplify it across later compactions.
- **Constrain the conversation model's language** — rejected: the policy is for an internal checkpoint, not the user's visible conversation, and a conversation-wide rule would unnecessarily change normal interaction.
- **Require ASCII-only output** — rejected: ASCII is a character-set constraint rather than an engineering-register constraint and would unnecessarily distort legitimate literals and technical material.
- **Append a separate final English-only sentence** — rejected: stating the requirement in the instruction's opening output contract is shorter and ties it directly to the checkpoint being requested.
## Consequences
- New checkpoints normalize narrative context into English while retaining the exact strings that future tool use and code work depend on.
- Existing checkpoint structure, compaction routing, and cache alignment are unchanged; only the final user instruction is different.
- The direct summarization call remains outside transcript snapshots because it emits no `assistant/chunk` events. The real-loop regression instead asserts the exact final instruction received by the summarization request.

View File

@@ -0,0 +1,28 @@
# Agent Note: 压缩检查点使用英语工程文体
Status: implemented
[English](2026-07-31-english-compaction-checkpoints.md) | 中文
## 问题
压缩compaction检查点会成为下一次模型请求中持久存在的前缀。当多语言对话使压缩器以对话语言保留叙述性材料时检查点可能引入大量代码、工具输出和既有推理reasoning前缀中均未出现的语言内容。该语言随后会在后续压缩周期中持续存在并影响对话模型的推理文体。
## 决策
`COMPACTION_INSTRUCTION` 要求生成英语的内部工程检查点。它要求模型在必要时翻译叙述性源材料,同时保留精确的字面量;这包括路径、命令、错误、标识符、签名,以及精确性重要时的引用措辞。检查点的标题及简洁的工程项目符号仍沿用既有的结构化格式。
这项要求被整合到尾部压缩指令的第一句话中。回放的系统提示词、工具和对话历史与已路由请求保持字节级一致,因此该变更保留 [compaction summary prefix-cache note](2026-07-21-compaction-summary-prefix-cache-reuse.md) 所确立的前缀缓存复用。
## 考虑过的替代方案
- **让回放的对话决定检查点语言**——不采纳:检查点是持久的提示词前缀,保留短暂的对话文体可能在后续压缩中放大这种影响。
- **约束对话模型的语言**——不采纳:该策略针对内部检查点,而不是用户可见的对话;对整个对话施加规则会不必要地改变正常交互。
- **要求仅输出 ASCII**——不采纳ASCII 是字符集约束,而非工程文体约束,并会不必要地扭曲合法的字面量和技术材料。
- **在末尾追加一句独立的仅限英语要求**——不采纳:在指令开头的输出契约中说明该要求更简洁,也直接将其与所请求的检查点绑定。
## 后果
- 新检查点会将叙述性上下文规范化为英语,同时保留未来工具使用和代码工作所依赖的精确字符串。
- 既有检查点结构、压缩路由和缓存对齐保持不变;只有最后一条 user 指令不同。
- 直接摘要调用仍不纳入 transcript文本记录快照因为它不会发出 `assistant/chunk` 事件。真实循环回归改为断言摘要请求收到的精确最终指令。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-approval-seam.md
2026-07-06-approval-seam.md: efb4159d736779af28edc1ae6091de4669c92f31
2026-07-06-approval-seam.zh.md: 9a4656f30a43473fe90cde9c56d45f48943e5d10
2026-07-06-approval-seam.md: ae143a41302b7bcd6029345fa91ca4eda837c141
2026-07-06-approval-seam.zh.md: a66ce804167fd4de5c8dbe51d70c9b038d1ebb17

View File

@@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou
# policy: never # deployment default for sessions without an override; 'ask' when omitted
```
The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its [automation-only bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) registers an answerer that sends `session/request_permission` to the owning client with the exact tool-call id and one-shot allow/reject options. `policy: never` is the unattended stance — every ask auto-rejects deterministically and is stated in the system prompt. `policy` is validated against the closed list at plugin load; anything else throws.
The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its [automation-only bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) registers an answerer that sends `session/request_permission` to the owning client with the exact tool-call id and one-shot allow/reject options. `policy: never` is the unattended stance — every ask auto-rejects deterministically, and the current value joins the runtime-context snapshot. `policy` is validated against the closed list at plugin load; anything else throws.
What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision.
@@ -63,7 +63,7 @@ Answerers are `approval/request` waterfall listeners. Zero listeners fall throug
#### The per-session policy tier
The seam also owns the session-scoped `'ask' | 'never'` policy described by [the sandbox Agent Note](2026-07-06-sandbox.md). Effective policy is folded from logged switches over the deployment default. `'never'` resolves to `rejected` inside `request()` before any answerer can run; `'ask'` dispatches and otherwise falls through to `unavailable`. The prompt states only deterministic `'never'`, switch narration is coalesced, and every request still records the audit pair.
The seam also owns the session-scoped `'ask' | 'never'` policy described by [the sandbox Agent Note](2026-07-06-sandbox.md). Effective policy is folded from logged switches over the deployment default. `'never'` resolves to `rejected` inside `request()` before any answerer can run; `'ask'` dispatches and otherwise falls through to `unavailable`. Both current values join the atomic runtime-context snapshot before each model request, so a policy switch needs no separate narration; every approval request still records the audit pair.
#### The ACP answerer
@@ -83,7 +83,7 @@ The answerer routes through the bridge's exact-agent ownership check described b
Unit tests pin outcomes, first-wins delegation, containment, cancellation, scoped routing, audit pairing, the unbypassable `'never'` policy, tool deny reasons, and ACP ownership/outcome mapping through a real scripted bridge.
Snapshots record allowed and rejected sandbox escalation through `session/request_permission`, plus the `'never'` prompt and policy-switch notice. Unscripted permission prompts cancel and fail closed.
Snapshots record allowed and rejected sandbox escalation through `session/request_permission`, plus the complete `'ask'` and `'never'` runtime-context contributions. Unscripted permission prompts cancel and fail closed.
## Deferred
@@ -124,7 +124,7 @@ Costs and accepted limits:
- **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. 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 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 next atomic runtime-context snapshot states the policy; 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.

View File

@@ -25,7 +25,7 @@ Status: implemented
# policy: never # deployment default for sessions without an override; 'ask' when omitted
```
仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用被拒绝——失败关闭无需配置。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项。`policy: never` 是无人值守姿态:每次 ask 确定性地自动拒绝,并在系统提示词中声明`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。
仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用被拒绝——失败关闭无需配置。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项。`policy: never` 是无人值守姿态:每次 ask 都会被确定性地自动拒绝,当前值也会加入运行时上下文快照`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。
组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;轮次内成功的请求会在发起请求的 agent 的会话日志上落一对持久的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。空闲时的请求或审计追加失败会拒绝,而不会返回未经审计的决策。
@@ -63,7 +63,7 @@ tool/result "escalated" — this one call ran under the wider mode; the gra
#### 每会话策略层
seam 还拥有[沙箱 Agent Note](2026-07-06-sandbox.md) 所描述的会话级 `'ask' | 'never'` 策略。生效策略由日志中记录的切换在部署默认值之上折叠而成。`'never'` 会在任何应答者运行之前,于 `request()` 内部解析为 `rejected``'ask'` 则派发请求,否则一路委派至 `unavailable`提示词仅声明确定性的 `'never'`,切换叙述会被合并,每个请求仍记录审计对。
seam 还拥有[沙箱 Agent Note](2026-07-06-sandbox.md) 所描述的会话级 `'ask' | 'never'` 策略。生效策略由日志中记录的切换在部署默认值之上折叠而成。`'never'` 会在任何应答者运行之前,于 `request()` 内部解析为 `rejected``'ask'` 则派发请求,否则一路委派至 `unavailable`两个当前值都会在每次模型请求前加入原子化的运行时上下文快照,因此策略切换无需单独叙述;每次批准请求仍记录审计对。
#### ACP 应答者
@@ -83,7 +83,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有
单元测试固定结果、先到先得的委派、错误容纳、取消、作用域路由、审计配对、不可绕过的 `'never'` 策略、工具拒绝原因,以及通过真实脚本化桥实现的 ACP 归属/结果映射。
快照记录通过 `session/request_permission` 批准和拒绝沙箱升级,以及 `'never'` 提示词与策略切换通知。没有脚本化应答的权限提示会取消并失败关闭。
快照记录通过 `session/request_permission` 批准和拒绝沙箱升级,以及完整的 `'ask'` `'never'` 运行时上下文贡献。没有脚本化应答的权限提示会取消并失败关闭。
## 延后
@@ -124,7 +124,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有
- **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。
- **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。
- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`'never'` 父级会把该覆盖项预置到每个进程内子 agent 的日志中([决策](2026-07-25-subagent-policy-inheritance.md)),因此子 agent 一开始就会得知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。
- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次成功的自动拒绝都会记录审计对。
- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);下一份原子化的运行时上下文快照会声明该策略;每次成功的自动拒绝都会记录审计对。
- **热重载或应答者在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。
- **客户端从哪里获得审批上下文?** 请求携带精确的 `callId` 和发起方的人类可读 `reason`;通道适配器可自行关联更丰富的工具调用状态,而无需在审批 seam 中重复携带参数。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md
2026-07-06-sandbox.md: 42b78ad8341dd52c4dd146a2207a5ae909d28f1e
2026-07-06-sandbox.zh.md: dfa3349e4d74d6f2c4944414c25fe3726d4a9b5a
2026-07-06-sandbox.md: 4355ab57374f77f1733fff39e2fb4ccadcedaf6b
2026-07-06-sandbox.zh.md: 02c5337555b7466c2bac7fc4774cdd2c945178ae

View File

@@ -40,7 +40,7 @@ The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook comm
Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests.
Denied file effects return a `[sandbox: file access denied under <mode> mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to "<mode>"`, and permits no re-ask. The prompt does not announce sandbox mode, avoiding preemptive refusal. When `dsh-permission` is composed with a UI adapter, one preset selects both knob values; unmatched values fold to `custom`. The [ACP automation composition](../../../../examples/acp-agent/README.md) does not mount that UI service and selects its deployment mode explicitly.
Denied file effects return a `[sandbox: file access denied under <mode> mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to "<mode>"`, and permits no re-ask. The owner-derived runtime context states the current file policy without replacing those enforcement boundaries. When `dsh-permission` is composed with a UI adapter, one preset selects both knob values; unmatched values fold to `custom`. The [ACP automation composition](../../../../examples/acp-agent/README.md) does not mount that UI service and selects its deployment mode explicitly.
### Design detail
@@ -72,7 +72,7 @@ Backend profiles share the mode contract but differ in necessary host grants. La
`dsh-bash-sandbox` extends `LocalBashExecutor` and hands `ctx.sandbox` the exact `['bash', '-c', command]` argv it is about to spawn. A denial is an orthogonal result fact, conservatively classified from the active runner's stderr dialect. A runner failure outranks denial: foreground execution throws `SANDBOX_UNAVAILABLE`; a settled `BashProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `task_output`.
The model's view is result facts only: the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). No prompt section states the sandbox mode (§ Per-session modes).
The model sees the current effective file policy in the owner-derived `sandbox:policy` runtime context, while the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries.
#### Escalation: one approved wider retry after a denial
@@ -105,7 +105,7 @@ interface SessionEventMap {
Each owner exports the same three-piece kit: the event declaration, a pure fold (`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)` — a `findLast`, typed to the domain's closed union), and THE write path (`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)` — a switch IS its event; nothing mutates state out of band). No shared owner service, no generic facts map, no registry: a third knob copies the ~40-line pattern into its own package. Execution follows the fold on both sides — the bash tool's per-call stamp reads it as the middle rung of the § Escalation precedence chain, and the approval seam's `'never'` gate is [the approval Agent Note](2026-07-06-approval-seam.md)'s side of the same pattern.
Sandbox mode is not narrated in the prompt; denial results report the mode when it matters, avoiding preemptive refusal based on a standing label. Approval policy is different: only `'never'` is stated because automatic rejection otherwise looks like a user decision. Policy-change notices are coalesced and delivered by the next pre-step, with log-derived fallback after restart. The notice source is inferred from event position: a knob event after the last request header is user-driven; unlogged drift is operator or config driven.
Sandbox and approval policy are rendered as ordered contributions to one runtime-context snapshot before every request. The loop records the complete snapshot as a sourced `user/message`; both `'ask'` and `'never'` are explicit, so neither owner needs switch narration or last-told state.
**The optional UI surface** is `PermissionService`: a deployment-defined preset table whose entries bundle one sandbox mode with one approval policy. The shipped `workspace-write` and `danger-full-access` presets write through to both domain setters; a knob combination outside the table is reported as `custom`. UI adapters may expose that table as a selector. The automation-only ACP transport advertises no configuration selector and mounts no permission-preset service.
@@ -117,10 +117,10 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s
### Testing
- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and narrator coalescing.
- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and runtime-context ordering and materialization.
- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip.
- **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip.
- **Snapshot:** pin prompt deltas and notices plus both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins a successful deployment-selected workspace-write mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent.
- **Snapshot:** pin the atomic current-policy context and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins both the workspace-write runtime-context message and a successful deployment-selected mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent.
## Deferred phases
@@ -149,9 +149,9 @@ Each phase gets its full design when picked up, validated against the code at th
- **Per-session dynamic tool schemas** — rejected: schemas are registry-global by design (one assembly vocabulary, the pinned-header snapshot contract), and re-registering per session would buy only what the execution-time strict-wider check already guarantees, at the cost of a per-session schema surface and header churn on every switch.
- **Hard-match the retry to a prior denial** — rejected: command-string identity is fragile (quoting, `workdir`, env prefixes, a pipeline retried as its failing stage) — false-rejects honest retries or is trivially satisfied; the real boundary is the human seeing command + justification. Revisit only if `allow_always` grant storage ever needs machine-checkable scopes.
- **A generic `env/state` facts map with an owner service** — rejected: approval and sandbox compose independently, so neither's state may drag in a third package; single-key folds are one `findLast` each, dissolving the owner service; no invariant spans the knobs, so atomic multi-key patches bought nothing.
- **Narrate via `agent/user-message` + a bus event** — rejected: it presupposes a turn-entry seam that does not exist (the real seam is `agent/prompt-submit`), and pre-step's position serves both the coalesced turn-entry notice and the mid-turn immediacy bound with one listener.
- **A standing prompt statement of the sandbox mode (+ a switch narrator)** — shipped first, then removed on live evidence: with `Bash commands run under the "read-only" file sandbox.` in every request, the model refused to ATTEMPT denied-then-escalatable work (five of twelve turns in the first manual session ended with zero tool calls), turning the sandbox into a soft lockout. The denial marker names the mode at the moment it matters and the escalation fields carry the recovery; the approval knob keeps its statement because an auto-rejection is behaviorally indistinguishable from a human "no".
- **Track "last told" with its own bookkeeping events** — rejected: the `request/header` fold already records the exact prompt the model saw; parsing the closed candidate sentences back replaces a second bookkeeping stream — events are needed only where they ARE the store.
- **Narrate each switch through `agent.inject()` plus a bus event** — rejected: independent notices expose owner ordering and intermediate combinations, while one assembly pass can materialize the complete current state atomically at the request boundary.
- **State sandbox mode in the stable system prompt** — shipped first, then removed on live evidence: with `Bash commands run under the "read-only" file sandbox.` in every request, the model refused to ATTEMPT denied-then-escalatable work (five of twelve turns in the first manual session ended with zero tool calls), turning the sandbox into a soft lockout. The denial marker names the mode at the moment it matters and the escalation fields carry the recovery. The absence decision is superseded by [the current-policy decision](2026-07-30-current-sandbox-policy-context.md); this measurement and causal observation remain the evidence that any replacement must counter-test.
- **Track "last told" with its own bookkeeping events** — rejected: the latest sourced runtime-context `user/message` records the exact full snapshot the model saw. Materializing that snapshot from current owner contributions replaces a second bookkeeping stream — events are needed only where they ARE the store.
- **Independent sandbox and approval selectors** — rejected: one deployment-defined permission preset keeps the two policy knobs coherent for UI clients that expose runtime switching.
## Consequences
@@ -160,12 +160,12 @@ What shipped pins — the tiers in Testing hold each:
- A denied command retried with `sandbox_permissions` + `justification` prompts the user through the composed answerer chain; a grant runs THAT call under the wider mode (result facts say so) while every other call keeps its own effective mode; every non-grant outcome produces its distinct error text and executes nothing.
- The escalation fields exist exactly when the mounted executor confines; a request that is not strictly wider than the call's effective mode fails closed with its own text and prompts no one; a deployment with no ApprovalService fails escalating calls closed and leaves plain calls untouched.
- The system prompt never states the sandbox mode (an approval `'never'` policy is the one stated knob), and the whole exchange — headers, knob events, notices, approvals, results — reconstructs from the session log alone, with no event types beyond the two knob events.
- One preset selection records only changed knob values, while a no-op selection records nothing; an approval-policy switch is narrated in at most one coalesced notice, and a committed sandbox switch is honored by the next call's stamp.
- A resumed session's overrides apply with no catch-up state; a default changed while the process was down is narrated before the session's first new request, attributed to the operator.
- One sourced runtime-context message states the complete current sandbox and approval policies atomically; the whole exchange — context snapshots, headers, knob events, approval notices, approvals, and results — reconstructs from the session log alone, with no policy bookkeeping events beyond the two knob events.
- One preset selection records only changed knob values, while a no-op selection records nothing; the next request snapshots both current values atomically, and a committed sandbox switch is honored by the next call's stamp.
- A resumed session's overrides enter its first new runtime-context snapshot with no catch-up state; a composition default changed while the process was down likewise appears in that snapshot.
- Two concurrent sessions never see each other's state or notices.
- Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd.
- `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/step`, `agent/prompt-submit`, and capability-owned policy resolution.
- Policy ownership stays in plugins through `systemPrompt.context`, `SessionEventMap` merging, and capability-owned resolution; the generic loop change materializes every owner's ordered context as one sourced message.
Costs and accepted limits:
@@ -178,9 +178,8 @@ Costs and accepted limits:
- **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants.
- **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone.
- **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, at execution when an unprobed sole runner refuses (classified as a sandbox failure, not a command failure) — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority.
- **The approval narrator's restart baseline parses prompt prose.** The closed candidate sentence is owned by the writing module itself, so a wording change is a coordinated writer+parser edit in one file; a session whose headers predate the section silently adopts the current policy without a notice.
- **The approval section is still a dynamic prompt surface** (a `'never'` switch breaks provider prompt-prefix caching for that session). Accepted: policy switches are rare, and a model acting on a stale `'never'` is worse. The sandbox knob no longer touches the prompt at all.
- **The model may hold a stale belief about the sandbox mode** (nothing announces a switch). Accepted deliberately: the next attempt's marker or success corrects it, and the observed failure mode of announcing — preemptive refusal — is worse than one wasted retry.
- **Runtime-context history is append-only.** A policy switch appends a complete superseding snapshot after retained history, preserving the stable system-and-conversation prefix; unchanged state adds no message.
- **Older policy snapshots remain in history.** Each full snapshot explicitly supersedes earlier runtime-context snapshots, so replay and compaction need only retain the latest materialized message.
## FAQ
@@ -191,8 +190,8 @@ Costs and accepted limits:
- **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam.
- **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary).
- **Does a granted escalation persist?** No. The grant is consumed by the exact foreground or background call that asked; every neighboring call keeps its own effective mode. A later background denial surfaces through `task_output` and may ground a new exact-command retry.
- **When does a runtime mode switch take effect?** Once its session event commits, the very next capability resolution folds and stamps the new mode. The model is not told a standing mode; its next command simply behaves under the new policy, and any denial names that policy at the point of use.
- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline changes behavior the same way a switch does (the approval policy, being stated, is additionally narrated with operator/config attribution).
- **When does a runtime mode switch take effect?** Once its session event commits, the next runtime-context snapshot and the next capability resolution fold the new mode. The sourced context message records what the model was told, and any later denial names the same policy at the point of use.
- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline enters the next full runtime-context snapshot.
- **What does `enforcement: 'partial'` on a result mean?** The selected backend enforces the subset its kernel ABI governs — e.g. Landlock before ABI v3 does not govern path truncate — and says so structurally instead of refusing the host; the probe's report line distinguishes the cases. The bwrap and Seatbelt profiles govern every promised file effect by construction, so they always report `full`.
## Prior art

View File

@@ -40,7 +40,7 @@ harness 是一个 SDK因此约束必须是开发者可组合的能力
配置错误大声失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段(命令 spawn 之前)抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner seam。
被拒绝的文件操作返回 `[sandbox: file access denied under <mode> mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions``justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to "<mode>"`,且不允许再次请求。提示词不声明沙箱模式,以避免基于常驻标签的预防性拒绝。当 `dsh-permission` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP 自动化组合](../../../../examples/acp-agent/README.md)不挂载该 UI 服务,而是显式选定其部署模式。
被拒绝的文件操作返回 `[sandbox: file access denied under <mode> mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions``justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to "<mode>"`,且不允许再次请求。由归属方派生的运行时上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP 自动化组合](../../../../examples/acp-agent/README.md)不挂载该 UI 服务,而是显式选定其部署模式。
### 设计细节
@@ -72,7 +72,7 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes
`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,并把即将 spawn 的确切 `['bash', '-c', command]` argv 交给 `ctx.sandbox`。拒绝是与其他结果正交的事实,依据当前 runner 的 stderr 方言保守分类。Runner 失败优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`bash 生产者再通过通用 `task_output` 渲染它。
模型看到的仅是结果事实:静态工具描述解释拒绝标记(`[sandbox: file access denied under <mode> mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试当升级字段被公布时,被拒绝的结果还额外携带升级提示本身,使被认可的同轮次重试在决策点提示,而非依赖模型回忆描述(§ 升级机制)。没有提示词段落声明沙箱模式(§ 按会话模式)
模型会在归属方派生的 `sandbox:policy` 运行时上下文中看到当前有效的文件策略;静态工具描述解释拒绝标记(`[sandbox: file access denied under <mode> mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试当升级字段被公布时,被拒绝的结果还携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界
#### 升级机制:拒绝后一次经批准的更宽重试
@@ -105,7 +105,7 @@ interface SessionEventMap {
每个拥有者导出相同的三件套:事件声明、纯 fold`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)`——一个 `findLast`,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。无共享拥有者服务、无通用 facts map、无注册表第三个旋钮只需将约 40 行模式复制到自己的包中。执行在两侧都遵循 fold——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.md) 同一模式的另一侧。
沙箱模式不在提示词中叙述;拒绝结果在需要时报告模式,避免基于常驻标签的预防性拒绝。批准策略不同:只有 `'never'` 被声明,因为自动拒绝在行为上与用户的「不」无法区分。策略变更通知被合并,由下一个步骤前检查点递送,重启后有基于日志的回退。通知来源从事件位置推断:最后一个 request header 之后的旋钮事件是用户驱动的;未记录的漂移是运维人员或配置驱动的
沙箱策略与批准策略会在每次请求前渲染为同一份运行时上下文快照中的有序贡献。循环会将完整快照记录为一条带来源的 `user/message``'ask'``'never'` 都会明确写入,因此两个归属方都无需切换叙述或「上次告知」状态
**可选的 UI 界面**是 `PermissionService`:一张部署定义的 preset 表,每个条目捆绑一个沙箱模式与一个批准策略。随附的 `workspace-write``danger-full-access` preset 写入两个领域 setterpreset 表之外的旋钮组合报告为 `custom`。UI 适配器可以把该表暴露为选择器。仅面向自动化的 ACP 传输层不公布任何配置选择器,也不挂载权限 preset 服务。
@@ -117,10 +117,10 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
### 测试
- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传、以及叙述器合并
- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传、以及运行时上下文排序与具体化
- **Keyless 真实 runner** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。
- **With-key** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。
- **快照:** 固定提示词 delta 和通知,以及两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定一次成功的、由部署选定的 workspace-write 变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture测试前置数据保持平台无关。
- **快照:** 固定原子化的当前策略上下文和两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定 workspace-write 运行时上下文消息与一次成功的、由部署选定的变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture测试前置数据保持平台无关。
## 延迟阶段
@@ -149,9 +149,9 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
- **按会话动态工具 schema**否决。schema 设计上是注册表全局的(一套 assembly 词汇、固定 header 快照契约),按会话重新注册只能买到执行时严格放宽检查已保证的东西,代价是按会话的 schema 表面和每次切换的 header 变动。
- **将重试硬匹配到先前的拒绝**:否决。命令字符串同一性脆弱(引号、`workdir`、env 前缀、作为失败阶段重试的管道)——要么误拒诚实的重试,要么被轻易满足;真正的边界是人看到命令 + 理由。仅在 `allow_always` 授权存储需要机器可检查的范围时才重新考虑。
- **通用 `env/state` facts map 加拥有者服务**否决。approval 和沙箱独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。
- **通过 `agent/user-message` + 总线事件叙述**:否决。它预设了一个不存在的轮次入口 seam真正的 seam 是 `agent/prompt-submit`),而步骤前检查点的位置使一个监听器能够同时服务合并的轮次入口通知和轮中即时性约束
- **提示词中常驻声明沙箱模式+ 切换叙述器)**:先交付后移除,基于实际证据:当每个请求中都`Bash commands run under the "read-only" file sandbox.` 时,模型拒绝尝试被拒绝后可升级的工作(首次手动会话十二个轮次有五个以零工具调用结束),沙箱变成软锁。拒绝标记在需要时命名模式,升级字段承载恢复路径;批准旋钮保留其声明,因为自动拒绝在行为上与人的「不」无法区分
- **用专门的簿记事件追踪「上次告知」**:否决。`request/header` fold 已记录模型看到的确切提示词;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们本身即为存储时才需要。
- **通过 `agent.inject()` 总线事件逐次叙述切换**:否决。独立通知会暴露归属方顺序和中间组合,而一次组装过程可以在请求边界以原子方式具体化完整的当前状态
- **在稳定系统提示词中声明沙箱模式**:先交付,随后根据线上证据移除:每次请求都带`Bash commands run under the "read-only" file sandbox.` 时,模型拒绝尝试本可在被拒后升级的工作(首次人工会话十二个轮次有五个以零工具调用结束),使沙箱变成软锁。拒绝标记会在相关时刻指出模式,升级字段承载恢复路径。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)取代了省略策略的决策;这项测量和因果观察仍是任何替代方案必须进行反证测试的依据
- **用专门的簿记事件追踪「上次告知」**:否决。最新一条带来源的运行时上下文 `user/message` 会记录模型看到的确切完整快照。根据当前归属方贡献具体化该快照,取代了第二条簿记流——事件仅在它们本身即为存储时才需要。
- **相互独立的沙箱与批准选择器**:否决。一个部署定义的权限 preset 让两个策略旋钮对暴露运行时切换的 UI 客户端保持一致。
## 后果
@@ -160,12 +160,12 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
- 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时,通过组合的应答器链提示用户;授权使该次调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。
- 升级字段恰好在已挂载的执行器约束时存在;不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。
- 系统提示词从不声明沙箱模式(批准 `'never'` 策略是唯一被声明的旋钮),且整个交互——header、旋钮事件、通知、批准结果——仅从会话日志即可重建,除两个旋钮事件外无额外事件类型
- 一次 preset 选择只记录发生变化的旋钮值,而无操作的选择不记录任何内容;批准策略切换最多以一条合并通知叙述,已提交的沙箱切换由下一次调用的盖章兑现。
- 恢复会话的覆盖直接生效,无需追赶状态;进程停止期间变更的默认值在会话的首个新请求前被叙述,归因于运维人员
- 一条带来源的运行时上下文消息会以原子方式声明完整的当前沙箱策略与批准策略;整个交互——上下文快照、header、旋钮事件、批准通知、批准结果——仅从会话日志即可重建,除两个旋钮事件外没有策略簿记事件
- 一次 preset 选择只记录发生变化的旋钮值,而无操作的选择不记录任何内容;下一个请求会把两个当前值共同纳入一份原子快照,已提交的沙箱切换由下一次调用的盖章兑现。
- 恢复会话的覆盖项会进入其首个新运行时上下文快照,无需追赶状态;进程停止期间变更的组合默认值也会出现在该快照中
- 两个并发会话永远看不到彼此的状态或通知。
- 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。
- `agent-loop` 未被触及——一切搭载 `systemPrompt.section``SessionEventMap` 合并`agent.inject()``agent/step``agent/prompt-submit` 和由能力拥有的策略解析
- 策略归属仍通过 `systemPrompt.context``SessionEventMap` 合并和由能力归属方拥有的解析留在插件中;通用循环变更会将每个归属方的有序上下文具体化为一条带来源的消息
代价与已接受的限制:
@@ -178,9 +178,8 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
- **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。
- **公布的目标集是静态的,而有效模式是按会话的**schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。
- **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——在平台没有链或所有探测失败时于 `confine()` 阶段,在未探测的唯一 runner 拒绝时于执行阶段(归类为沙箱失败而非命令失败)——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。
- **批准叙述器的重启基线解析提示词文本。** 封闭的候选句子由写入模块本身拥有,因此措辞变更是同一文件中写入器+解析器的协调编辑header 早于该段落的会话静默采用当前策略而不发通知
- **批准段落仍是动态提示词表面**`'never'` 切换会破坏该会话的提供方提示词前缀缓存)。已接受:策略切换罕见,且模型基于过时的 `'never'` 行动更糟。沙箱旋钮不再触及提示词
- **模型可能持有关于沙箱模式的过时信念**(没有任何东西宣布切换)。有意接受:下一次尝试的标记或成功会纠正它,而宣布的观察到的失败模式——预防性拒绝——比一次浪费的重试更糟。
- **运行时上下文历史仅追加。** 策略切换会在保留的历史之后追加一份用于取代先前快照的完整快照,从而保留稳定的系统与对话前缀;状态不变时不添加消息
- **旧策略快照仍保留在历史中。** 每份完整快照都会明确取代更早的运行时上下文快照因此回放与压缩compaction只需保留最新具体化的消息
## FAQ
@@ -191,8 +190,8 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
- **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作bwrap profile 刻意不 unshare pid没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。
- **哪些工具实际在约束下运行?** 通过 `ctx.bash` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md)bash 通过 OS runner 约束fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制web 的唯一效果是网络,不在文件效果模式词汇内)。
- **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `task_output` 呈现,并且可以作为一次新的精确命令重试的依据。
- **运行时模式切换何时生效?** 一旦其会话事件提交,紧接着的下一次能力解析会折叠并盖章新模式。模型不被告知常驻模式;其下一个命令直接在新策略下运行,任何拒绝都会在使用点命名策略。
- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值以与切换相同的方式改变行为(批准策略因被声明,还额外以运维人员/配置归因叙述)
- **运行时模式切换何时生效?** 一旦其会话事件提交,下一个运行时上下文快照与下一次能力解析会折叠新模式。带来源的上下文消息会记录模型收到的内容,之后的任何拒绝都会在使用点命名同一策略。
- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值会进入下一份完整运行时上下文快照
- **结果上的 `enforcement: 'partial'` 是什么意思?** 所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作,因此始终报告 `full`
## 先例

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md
2026-07-20-dsh-cli-personal-config.md: 3331e36c86002d91fb272868268707fed014d01a
2026-07-20-dsh-cli-personal-config.zh.md: 172d84b075ed7ecc127c317b47e30581db67f89a
2026-07-20-dsh-cli-personal-config.md: 259c3865a9edcbc77949a9fe401af9a77e1e32c4
2026-07-20-dsh-cli-personal-config.zh.md: 8f7c15c3c683cc855c6e3b704bfde8f87d4009d2

View File

@@ -39,9 +39,9 @@ Hot-reload interplay: the include re-applies its `patches` on every config re-re
## Consequences
- `dsh` from any directory (and `pnpm run demo:tui`) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip.
- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings are the only diagnostics.
- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](2026-07-30-dsh-dump-config.md) (which prints the composed tree those patches produce) are the diagnostics.
- Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred.
- `dsh-app-boot` depends on `js-yaml` (plus a load-only copy of the include's `!!js` YAML type) and, like `apps/cli`, on `@deepseek-ai/dsh-paths` for `resolveDshHome`.
- `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`.
- When PR #443 lands, `apps/cli/src/bin.ts`'s dispatch chain and `apps/cli/package.json`'s dependency list conflict textually; both resolve as unions (their `web`/`-p` branches plus our default-TUI branch).
## Testing

View File

@@ -39,9 +39,9 @@ PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录
## Consequences
- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`)即可零仓库改动地使用个人提供方/模型;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。
- 由于按 id 定位的补丁替换整个 `config`个人覆盖必须复述它保留的基础字段并可能随基础配置项形态变化而漂移loader 的「配置项未找到/名称不匹配」警告是仅有的诊断
- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](2026-07-30-dsh-dump-config.md)(打印这些补丁合成出的配置树)
- 个人补丁只在被启动文件自身的树里解析 id因此嵌套 include 的 overlayCode Mode不会被个性化这些叶子的实际运行等价性暂缓。
- `dsh-app-boot` 依赖 `js-yaml`(外加一份只用于加载的 include `!!js` YAML 类型副本),并`apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`
- `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include `!!js` YAML 方言(`entryListSchema``apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`
- PR #443 落地时,`apps/cli/src/bin.ts` 的分发链与 `apps/cli/package.json` 的依赖列表会产生文本冲突;两者都按并集解决(他们的 `web`/`-p` 分支加上我们的默认 TUI 分支)。
## Testing

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md
2026-07-25-session-list-browsing-and-manual-order.md: 831aa53e532a75392690c330837482bb0f9c32b1
2026-07-25-session-list-browsing-and-manual-order.zh.md: 9ad074d59c13585aa4fca46ae4d40e2deb15cde6
2026-07-25-session-list-browsing-and-manual-order.md: 5af9cf026c5a0e32837822b3d8cff827c052b07f
2026-07-25-session-list-browsing-and-manual-order.zh.md: 2cfa4efe2f0e53b419b264c9af0aff878d321cac

View File

@@ -34,7 +34,7 @@ The UI is HTML5 drag on session rows inside a group (workspace grouping only, ou
### Shell/region split
ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/new-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspaceCreateFlow` (composed directly inside the region) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it.
ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/add-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspacePickFlow` (composed directly inside the region; named `WorkspaceCreateFlow` until the [one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it.
## Alternatives considered

View File

@@ -34,7 +34,7 @@ UI 为组内 session 行的 HTML5 拖拽(仅 workspace 分组、非搜索态f
### 壳/区域切分
ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/新建图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspaceCreateFlow`(区域内直接组件组合)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。
ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/添加工作区图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspacePickFlow`(区域内直接组件组合;在[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)之前名为 `WorkspaceCreateFlow`)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。
## Alternatives considered

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md
2026-07-25-workspace-ui-product-flow.md: b8e1ec1efe19127cad8a12405dddeec38a4ff91e
2026-07-25-workspace-ui-product-flow.zh.md: b80b75a80671e9aa2ab59ff72c44c18a8ec5c16e
2026-07-25-workspace-ui-product-flow.md: 7e3338cfcb04c1588bbc97f6e8aa1cd2dac59141
2026-07-25-workspace-ui-product-flow.zh.md: 8c092151f737b6bc10aa8424281eeda359f07c1d

View File

@@ -50,9 +50,9 @@ On initial entry, the application waits until both the Workspace and Session bas
When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order.
Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message.
Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message.
Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow.
A new Workspace takes its display name from the directory it was created in, and the Host rejects a title already registered (the UI's own duplicate-name pre-check went with the create-by-name dialog). Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow.
### First send and recovery

View File

@@ -50,9 +50,9 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预
完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Hostcomposer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。
顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace再把前端 Session 定位到该 Workspace即使用户不发送消息显式创建的空 Workspace 也保留。
顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace再把前端 Session 定位到该 Workspace即使用户不发送消息显式创建的空 Workspace 也保留。
Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认Host 继续拒绝绕过 UI 或并发产生的同名请求。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。
新建 Workspace 的显示名取自其所在目录Host 拒绝已注册的同名 titleUI 侧的重名预检随按名称创建的对话框一并删除)。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。
### 首次发送与恢复

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md
2026-07-27-native-workspace-directory-picker.md: 847b7a7c7844a47e534183008acfdc32e2e08125
2026-07-27-native-workspace-directory-picker.zh.md: e484a2ba5860d43925aba82be31c803716a33bc5
2026-07-27-native-workspace-directory-picker.md: 8dd7084eb8ffa417d9366cbc54bcbc59170c64fe
2026-07-27-native-workspace-directory-picker.zh.md: 9efc6571207b1f6a6996b9990c2a55afb70872bd

View File

@@ -10,7 +10,7 @@ The desktop GUI asks users to type an absolute path when they add an existing wo
## Decision
Add a single-folder `host.pickDirectory` RPC and expose it through `WorkspacesService`. The workspace menu presents two flat actions: **Open local folder...** and **Create a new workspace**. Selecting a folder reuses the existing `workspace.create({ path })` flow, selects the returned workspace, and starts a blank session.
Add a single-folder `host.pickDirectory` RPC and expose it through `WorkspacesService`. The workspace menu presents the flat **Add workspace...** action (two actions when this was decided — **Open local folder...** beside a create-by-name entry the [one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md) later removed). Selecting a folder reuses the existing `workspace.create({ path })` flow, selects the returned workspace, and starts a blank session.
The workspace manager must upsert the returned workspace before the selection callback runs. A newly adopted directory therefore renders its basename immediately. Reopening an already registered path preserves its existing workspace title.
@@ -21,7 +21,7 @@ The workspace manager must upsert the returned workspace before the selection ca
- A duplicate path selects the existing workspace.
- A different path whose derived title conflicts with another workspace shows a focused error with **Choose again** and **Cancel** actions.
- Other picker failures show a compact retryable error.
- The existing create-by-name flow remains unchanged.
- The create-by-name flow this decision left untouched is gone; picking a directory is now the whole of adding a workspace ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)).
## Host boundary
@@ -41,7 +41,7 @@ Platform adapters invoke native tools without a shell:
## Consequences
The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. Existing workspace creation by name remains available.
The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. This picker is now the only route to a workspace ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)): the operator picks an existing directory, or creates one inside the chooser.
The added host, runtime, component, and GUI tests cover the native boundary, request trust checks, cancellation and failure handling, existing-path reuse, title conflicts, and the immediate visible-name update. The privileged RPC remains specific to the local desktop carrier; a remote Web directory browser is outside this decision.

View File

@@ -10,7 +10,7 @@ Status: implemented
## 决策
新增一个用于选择单个文件夹的 `host.pickDirectory` RPC并通过 `WorkspacesService` 暴露该 RPC。工作区菜单提供两个平铺操作**打开本地文件夹…** 和 **创建新工作区**。选定文件夹后,系统复用现有的 `workspace.create({ path })` 流程,选中返回的工作区,并启动一个空白会话。
新增一个用于选择单个文件夹的 `host.pickDirectory` RPC并通过 `WorkspacesService` 暴露该 RPC。工作区菜单提供平铺操作 **添加工作区…**(本决策做出时是两个操作:**打开本地文件夹…** 与一个按名称创建的入口,后者已被[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)删除)。选定文件夹后,系统复用现有的 `workspace.create({ path })` 流程,选中返回的工作区,并启动一个空白会话。
工作区管理器必须在选择回调运行前插入或更新返回的工作区。因此,新纳入的目录会立即显示其 basename。再次打开已注册的路径时则保留该工作区现有的标题。
@@ -21,7 +21,7 @@ Status: implemented
- 路径重复时,选中现有工作区。
- 如果路径不同,但其派生标题与另一个工作区冲突,则显示明确指出该冲突的错误提示,其中包含 **重新选择****取消** 操作。
- 选择器的其他故障会显示简洁且可重试的错误提示。
- 现有的按名称创建流程保持不变
- 本决策当时未触碰的按名称创建流程现已删除;选择目录就是添加工作区的全部(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)
## 宿主边界
@@ -41,7 +41,7 @@ Status: implemented
## 后果
当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。现有的按名称创建工作区功能仍可使用
当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。该选择器现已是获得工作区的唯一路径(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)):操作者要么选一个已有目录,要么在选择器内新建一个
新增的宿主、运行时、组件和 GUI 测试覆盖原生边界、请求信任校验、取消与故障处理、已有路径复用、标题冲突和可见名称即时更新。该特权 RPC 仍仅面向本地桌面载体;远程 Web 目录浏览器不属于本次决策范围。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md
2026-07-28-cross-workspace-resume.md: 1ef6195b6f269a6dc8cf869902b7772ad48f00c2
2026-07-28-cross-workspace-resume.zh.md: d31dc5f0470e20f86062edb4ad75c37a6160fe23
2026-07-28-cross-workspace-resume.md: e8a8636911015a5b300b45a30e268ee1be5d47d1
2026-07-28-cross-workspace-resume.zh.md: 9e5e126f4b204927d7fb134a196dd0d0a98468d4

View File

@@ -18,7 +18,7 @@ Finally, resume never changed directory. The host re-execs `dsh --resume=<id>` t
The shared CLI configuration supplies one session root under the Harness home, the picker gains a workspace scope, and the handoff carries the target directory.
**Storage.** The shared base owns the default in `apps/cli/config/base.cordis.yml`: its `session-persistence-jsonl` row resolves `sessions` beneath `DSH_HOME`, with the standard `~/.dsh` fallback when the environment has not been initialized. TUI, Web, and headless therefore consume one default without launcher patches or a boot slot. An overlay or personal patch that states an explicit root replaces that row's whole `config` and remains the deployment's authoritative choice.
**Storage.** The shared base owns the default in `apps/cli/config/base.cordis.yml`: its `session-persistence-jsonl` row calls the app-boot-provided `dshHomePath('sessions')`, which uses the canonical `DSH_HOME` resolver and its standard `~/.dsh` fallback. TUI, Web, and headless therefore consume one default without a session-specific launcher patch or slot. An overlay or personal patch that states an explicit root replaces that row's whole `config` and remains the deployment's authoritative choice.
**Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for.

View File

@@ -18,7 +18,7 @@ Status: implemented
共享 CLI 配置提供 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。
**存储。** 共享 base 在 `apps/cli/config/base.cordis.yml` 中拥有默认值:其 `session-persistence-jsonl` 配置项`sessions` 解析到 `DSH_HOME` 下;环境尚未初始化时则使用标准的 `~/.dsh` 回退值。因此 TUI、Web 与 headless 使用同一个默认值,无需启动器补丁或启动槽位。若 overlay 或个人 patch 显式声明根目录,它会整体替换该配置项的 `config`,并继续作为部署的权威选择。
**存储。** 共享 base 在 `apps/cli/config/base.cordis.yml` 中拥有默认值:其 `session-persistence-jsonl` 配置项调用由 app-boot 提供的 `dshHomePath('sessions')`,该函数使用规范的 `DSH_HOME` 解析器及其标准的 `~/.dsh` 回退值。因此 TUI、Web 与 headless 使用同一个默认值,无需针对会话的启动器补丁或槽位。若 overlay 或个人 patch 显式声明根目录,它会整体替换该配置项的 `config`,并继续作为部署的权威选择。
**是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'``scope`,默认为当前 workspace因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项使高亮行始终属于可见列表而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
2026-07-29-persistent-bash-str-replace-editor.md: a97af750bdd80ddf38dc2d126e70c245ed035f35
2026-07-29-persistent-bash-str-replace-editor.zh.md: 0c2ab26693d90c91d5c41a128ebb77a3c6cc2e7f
2026-07-29-persistent-bash-str-replace-editor.md: 22851078c1cc8fa9d5716afa41c8a2e2b7e7725c
2026-07-29-persistent-bash-str-replace-editor.zh.md: cf4d18f26d11380a637d573e8ea98cb3ccfcdb59

View File

@@ -18,6 +18,8 @@ Some deployments need a one-call Bash schema whose shell state survives across m
Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable.
The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay composes both plugins over the ordinary Web surface, disables its other model-facing consumers, and leaves the Web host, browser, Workspace, persistence, sandbox, and permission stack in place. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox.
## Alternatives considered
**One combined compatibility plugin.** Rejected because neither tool requires the other and the combined name would tie reusable capabilities to one benchmark.
@@ -30,4 +32,4 @@ Both plugins are included in the Python runtime closure. The persistent Bash clo
## Consequences
Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper.
Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. The Core Web profile retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper.

View File

@@ -18,6 +18,8 @@
两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行macOS 构建器会在发布前失败。
已交付的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) 覆盖层在常规 Web 界面之上组合这两个插件,禁用该界面的其他面向模型的消费方,并保留 Web 宿主、浏览器、Workspace、持久化、沙箱与权限栈。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn另一种权限模式就会在对应的会话事件提交前遭到拒绝编辑器则继续经由 Web 文件系统沙箱运行。
## 考虑过的替代方案
**单一组合兼容插件。** 被拒绝,因为两个工具互不依赖,组合命名还会把可复用能力绑定到某个基准。
@@ -30,4 +32,4 @@
## 后果
Profile 可以通过配置 persona 和描述复现外部 Agent而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。运行时 wheel 包的消费方仍无需安装 NodeLinux wheel 包包含一个可执行文件macOS wheel 包还包含其私有原生 helper。
Profile 可以通过配置 persona 和描述复现外部 Agent而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。Core Web profile 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 NodeLinux wheel 包包含一个可执行文件macOS wheel 包还包含其私有原生 helper。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-current-sandbox-policy-context.md
2026-07-30-current-sandbox-policy-context.md: 093f9a8a0b58256bb4f2301cc62fa83629de4fbd
2026-07-30-current-sandbox-policy-context.zh.md: 729ea9d50c7dbbfa027e1cad11929ae1c2b379b1

View File

@@ -0,0 +1,63 @@
# Agent Note: Current sandbox policy context
Status: implemented
English | [中文](2026-07-30-current-sandbox-policy-context.zh.md)
## Problem
The sandbox policy already enforced and logged each session's file-effect mode, but a fresh model request did not contain that state. In a Web session under `read-only`, write and edit schemas remained visible, so the model claimed it could write and learned otherwise only after a denied call. After `/permission danger-full-access`, the next request carried the approval-policy change but still omitted the sandbox mode. Denial results were therefore the first model-visible policy source even when the user asked about capability before any operation.
## Decision
`dsh-sandbox-policy`, the owner of mode and workspace-root resolution, registers one `sandbox:policy` cache-safe context contribution. Every agent request resolves the active session directly through `ctx.sandboxPolicy.resolve({ session })`; there is no denial-history scan or process-local “last told” state.
The policy contribution is capability-neutral and present for every agent session. It does not maintain a second inventory of mounted backends or tools; model-visible schemas remain the authority for available operations, while the context conditions its claims on any available operation that the DSH file sandbox enforces. The [capability-neutral policy context decision](../simplification/2026-07-31-capability-neutral-sandbox-policy-context.md) supersedes the earlier family-registration mechanism while retaining this note's cache-safe delivery and durable snapshot design.
The contribution states only facts shared by every enforcement dialect. `read-only` says an available sandbox-enforced operation cannot modify files in the standing mode and directs the model to try an available tool normally, then follow any denial and escalation guidance that tool returns. `workspace-write` states the canonical session workspace with non-exclusive wording and summarizes, without enumerating, that some platform temporary areas may also be writable. `danger-full-access` says the DSH file sandbox does not restrict file modifications by available operations. Backend-selected temporary paths, `/dev/null`, runner readiness, exact tool availability, and other policy domains are absent because `resolve()` cannot establish them at request assembly.
The existing `dsh-system-prompt` assembly now has ordered dynamic contexts alongside stable system sections and tool schemas. After assembling one step, agent-loop renders all active contexts as one full snapshot with an explicit supersession statement. It appends a sourced `user/message` only when no retained snapshot exists, the bytes changed, compaction removed the retained message, or the final contribution disappeared and needs one clearing snapshot. The snapshot is appended after existing history and before `step/start`, so a changed policy preserves the preceding system-and-conversation cache prefix. The session event itself reconstructs the exact model input; `request/header` remains byte-identical when only policy context changes.
Ownership stays narrow. Approval policy contributes its complete current `ask` or `never` fact to the same full snapshot; migrating sandbox alone would not preserve cache because `/permission` changes both owners. Plan mode remains `plan:policy`, and tool plugins continue to own schemas plus attempt, denial, and escalation guidance. Context states standing policy; filesystem, one-shot bash, and terminal backends remain the enforcement boundaries.
The cache decision follows current source rather than analogy alone. Codex models permissions as a developer-role `WorldState` section with a persisted fingerprint, emits it only when state changes or retained history lost the fragment, and records the snapshot transition. Hermes keeps its system prompt fixed for a session and explicitly prepends changing skill, model, and voice notices to the next user message to avoid invalidating prompt cache. Pi has no comparable built-in sandbox state, and Claude Code's current native implementation is not publicly inspectable; Anthropic's public cache guidance nevertheless places changing per-request context after the stable cached prefix.
The earlier real-provider Web fixture quantified the defect in the system-section version. The first `danger-full-access` and `workspace-write` requests each reported only 256 cache-read tokens against 14,691 and 14,782 uncached input tokens. Later steps under an unchanged policy reported approximately 14.7k15.5k cache-read tokens. Moving only the sandbox sentence would not fix those misses because the same preset switch also rewrote the approval-policy system section.
## Wording evidence
The wording experiment pre-registered preemptive refusal as its primary endpoint and required the old standing sentence to produce at least one refusal in twelve fresh sessions before any replacement could be judged. On 2026-07-30, commit `2bf41990401b194bd8637f07bbd90c67a9eeac75` ran `deepseek-v4-flash` through the shipped Web composition with the exact positive-control sentence `Bash commands run under the "read-only" file sandbox.` and the current tool-owned attempt guidance. The control produced zero preemptive refusals and zero speculative escalations; all twelve sessions made an ordinary bash call, observed a denial, escalated in the same turn, received approval, and landed the requested file. No sample was excluded.
After the cache-safe delivery change, commit `10d4e0ff7b68d38fc4403403b644aac442b97a00` repeated the same twelve-session positive control through the new tail-context channel. It again produced zero preemptive refusals and zero speculative escalations; all twelve sessions made an ordinary first call, observed denial, escalated in the same turn, and received approval. Eight landed the exact requested file, and no sample was excluded.
Both positive controls therefore failed the pre-registered sensitivity gate. The formal twelve-session Candidate A and B arms were not run, and these experiments do not select or validate the current wording. They establish that the earlier five-of-twelve result is not reproducible under this task and current tool guidance, and that a stronger positive control or different task distribution is required before making model-behavior rate claims. Deterministic tests below establish truthful request construction and replay only.
The cache-safe delivery rework then supplied a separate, non-statistical acceptance comparison over the neutral Web task `Create the relative path policy-neutral.txt ...`; it does not replace the pre-registered twelve-session experiment. Candidate A's categorical read-only statement produced a text refusal with zero tool calls. Candidate B added one composition-conditioned sentence only for enforced families whose tools expose escalation. A fresh real-provider run then issued an ordinary `write`, observed the read-only denial, retried the same operation in the same turn with `sandbox_permissions: "workspace-write"`, received approval, read the file back, and verified the exact contents. It made no speculative escalation. Across the permission switches and four mutation steps, cache reads were 14,84815,872 tokens while uncached input was 59306 tokens per request, directly demonstrating the stable-prefix benefit.
## Alternatives considered
**Narrate only mode changes.** Rejected because it leaves a fresh session uninformed and makes the first denied operation the policy-discovery mechanism. It also requires a baseline definition that is unnecessary when current state can be rendered directly.
**Scan denial history or remember the last narrated mode.** Rejected because denial events describe attempted operations, not authoritative current state, while process-local bookkeeping does not survive resume. The owner can fold the durable policy directly on every request.
**Put current policy in a dynamic system section.** Rejected after real provider evidence showed that a first-time permission switch reduced cache reads to 256 tokens while roughly 14.7k input tokens missed. DeepSeek matches complete prefixes; changing the first wire message prevents reuse of the longer system-plus-history prefix.
**Call `agent.inject()` independently from each policy owner.** Rejected because sibling listener order would define model order, separate messages could expose mismatched intermediate snapshots, and every owner would need its own compaction-retention scan. The existing assembly owner can order contributions and materialize one atomic full snapshot.
**A generic runtime-facts package.** Rejected because the existing system-prompt assembly already owns sections, schemas, variables, scope, and the authoritative per-step waterfall. Extending that owner with ordered contexts adds no package or second registry service.
**Repeat tool schemas or plan guidance in the context.** Rejected because those surfaces already have owners and independent lifecycles. Approval current state joins the snapshot only because the same `/permission` switch changes it and leaving its system section would retain the cache defect.
**Keep Candidate A after the cache-safe move.** Rejected by the neutral real-provider task: the model returned a pure text refusal and made no tool call despite the existing bash attempt guidance. The surviving anti-refusal principle states no escalation mechanics itself; it tells the model not to infer impossibility from the standing label, then delegates denial and escalation behavior back to the available tool.
**Keep sandbox mode absent because a standing mode label once caused preemptive refusal.** Rejected because a fresh Web request otherwise exposes mutation tools while withholding their standing policy, producing false capability claims before the first operation. The earlier live measurement remains a required counter-test: five of twelve turns ended without a tool call under `Bash commands run under the "read-only" file sandbox.` The committed tool-owned attempt guidance postdates that measurement, so the replacement is selected through a new positive-control experiment under the current tool contract rather than assuming the old and current conditions match.
**A separate model-context package.** Rejected because the policy owner can resolve current session state directly and the existing assembly service can order it. A new package would add a shallow composition seam and documentation/gate surface around the same request boundary.
**Enumerate writable temporary roots.** Rejected because the backend is selected later at `confine()`: bwrap, Landlock, Seatbelt, and the in-process filesystem fence do not grant one common temporary-path set. Host-specific paths in a standing request would be both unstable and overclaimed.
## Consequences
A model receives the standing file policy before probing a tool, and the next request after `/permission` reflects the committed mode. The stable system prompt no longer changes for sandbox or approval state; a changed full context snapshot is append-only after retained history, and unchanged state adds no message. Older snapshots remain in history but are explicitly superseded by the latest full snapshot. The statement is guidance, not an enforcement guard: runtime safety still comes from filesystem, one-shot bash, and terminal backends consuming the same resolved policy.
Focused tests pin all modes, canonical roots, switch timing, service disposal, context ordering, clearing, stable request headers, resume, and byte stability across different `TMPDIR` values. Keyless assembled snapshots pin the durable context message through real Loader compositions. Keyless replay owns the neutral denial-to-escalation trajectory; it is a structural regression proof, not wording-selection evidence.

View File

@@ -0,0 +1,63 @@
# Agent Note: 当前沙箱策略上下文
Status: implemented
[English](2026-07-30-current-sandbox-policy-context.md) | 中文
## 问题
沙箱策略已经强制执行并记录每个会话的文件操作模式,但新的模型请求并不包含这一状态。在 `read-only` 下的 Web 会话中write 与 edit schema 仍然可见,因此模型会声称自己能够写入,直到一次被拒绝的调用后才发现事实并非如此。执行 `/permission danger-full-access` 后,下一个请求带有批准策略变更,却仍省略沙箱模式。因此,即使用户在任何操作前询问能力,拒绝结果也是模型可见的首个策略来源。
## 决策
`dsh-sandbox-policy` 负责解析模式与工作区根目录,并注册一项缓存安全的 `sandbox:policy` 上下文贡献。每次 agent智能体请求都通过 `ctx.sandboxPolicy.resolve({ session })` 直接解析当前会话;不存在拒绝历史扫描或进程本地的「上次告知」状态。
该策略贡献不依赖具体能力,并存在于每个 agent 会话中。它不会另行维护一份已挂载后端或工具清单;模型可见的 schema 仍是可用操作的权威来源,而上下文会将其声明限定在 DSH 文件沙箱所强制执行的任何可用操作上。[不依赖具体能力的策略上下文决策](../simplification/2026-07-31-capability-neutral-sandbox-policy-context.md)取代了较早的家族注册机制,同时保留本 Agent Note 的缓存安全交付与持久快照设计。
该贡献只说明所有强制执行方言所共有的事实。`read-only` 表明受沙箱强制执行的可用操作在常驻模式下无法修改文件,并指示模型正常尝试可用工具,随后遵循该工具返回的任何拒绝与升权引导。`workspace-write` 用非排他措辞说明规范化的会话工作区,并概述某些平台临时区域可能也可写,而不逐一列举。`danger-full-access` 表明 DSH 文件沙箱不会限制可用操作修改文件。后端选择的临时路径、`/dev/null`、runner 就绪状态、确切的工具可用情况和其他策略领域都不会出现,因为 `resolve()` 无法在请求组装时确定它们。
现有 `dsh-system-prompt` 组装在稳定系统段与工具 schema 之外还包含有序的动态上下文。组装一个步骤后agent loop智能体循环会将所有活动上下文渲染成一份带显式取代声明的完整快照。仅当不存在保留快照、字节发生变化、压缩compaction移除了保留消息或最后一项贡献消失而需要一份清除快照时它才会追加一条带来源的 `user/message`。快照追加在现有历史之后、`step/start` 之前,因此策略变化时仍会保留此前的系统与对话缓存前缀。会话事件本身可以重建确切的模型输入;只有策略上下文变化时,`request/header` 仍逐字节相同。
归属范围保持收敛。批准策略会将其完整的当前 `ask``never` 事实贡献给同一份完整快照;只迁移沙箱无法保留缓存,因为 `/permission` 会同时改变两方。计划模式仍由 `plan:policy` 负责,工具插件也继续负责各自的 schema以及尝试、拒绝与升级引导。上下文负责说明常驻策略文件系统、一次性 bash 与终端后端仍是强制执行边界。
缓存决策依据当前源码而不只依靠类比。Codex 将权限建模为 developer 角色的 `WorldState` 段并保存其指纹只有状态变化或保留的历史丢失该片段时才发出它同时记录快照转换。Hermes 在会话期间保持系统提示词不变,并明确将不断变化的 skill技能、模型与语音通知前置到下一条用户消息以免提示词缓存失效。Pi 没有可比的内置沙箱状态Claude Code 当前的原生实现也无法公开检视不过Anthropic 的公开缓存指南仍将不断变化的逐请求上下文放在稳定缓存前缀之后。
先前接入真实提供方的 Web fixture测试前置数据量化了系统段版本的缺陷。首次 `danger-full-access``workspace-write` 请求分别只有 256 个缓存读取 token而未缓存输入 token 为 14,691 和 14,782 个。相同策略下的后续步骤报告约 14.7k15.5k 个缓存读取 token。只移动沙箱语句无法修复这些未命中因为同一次 preset 切换还会改写批准策略系统段。
## 措辞证据
措辞实验预先登记「预防性拒绝」为主要终点,并要求旧常驻句子在十二个 fresh session 中至少产生一次拒绝之后才能评判任何替代措辞。2026-07-30commit `2bf41990401b194bd8637f07bbd90c67a9eeac75` 通过已交付的 Web 组合运行 `deepseek-v4-flash`,使用精确的阳性对照句子 `Bash commands run under the "read-only" file sandbox.` 与当前工具归属方的尝试引导。对照组产生零次预防性拒绝和零次推测性升级;十二个会话全部先发起普通 bash 调用、观察到拒绝、在同一轮次升级、获得批准,并让所请求文件实际落盘。没有样本被排除。
缓存安全交付变更后commit `10d4e0ff7b68d38fc4403403b644aac442b97a00` 通过新的尾部上下文通道重复了同一项十二会话阳性对照。结果再次为零次预防性拒绝和零次推测性升权;十二个会话的首次调用均为普通调用,随后观察到拒绝、在同一轮次升权并获得批准。其中八个会话让所请求文件按确切要求落盘,没有样本被排除。
因此两项阳性对照均未通过预先登记的灵敏度门槛。Candidate A 与 B 的正式十二会话实验组均未运行,这些实验不选择也不验证当前措辞。它们说明先前十二次中五次的结果无法在本任务与当前工具引导下复现;在声明模型行为率之前,需要更强的阳性对照或不同的任务分布。下述确定性测试只证明请求构造与回放真实一致。
随后,缓存安全交付重做针对中性 Web 任务 `Create the relative path policy-neutral.txt ...` 提供了一次独立的非统计验收对比它不取代预先登记的十二会话实验。Candidate A 的绝对化只读声明导致模型以纯文本拒绝工具调用为零。Candidate B 只针对受强制执行、且其工具公开升权能力的家族增加一句按组合条件化的文案。随后一次全新的真实提供方运行先发出普通 `write`,观察到只读拒绝,再在同一轮次用 `sandbox_permissions: "workspace-write"` 重试同一操作,获得批准、读回文件并核验确切内容。它没有进行推测性升权。在权限切换和四个变更步骤中,每个请求的缓存读取为 14,84815,872 个 token未缓存输入为 59306 个 token直接证明了稳定前缀的收益。
## 曾考虑的替代方案
**仅叙述模式变更。** 不予采用,因为这会让新会话不了解策略,并把首次被拒绝的操作变成策略发现机制。如果可以直接渲染当前状态,也就无需额外定义基线。
**扫描拒绝历史或记住上次叙述的模式。** 不予采用,因为拒绝事件描述的是尝试过的操作,而不是权威的当前状态;进程本地的簿记也无法跨恢复保留。归属方可以在每次请求时直接折叠持久策略。
**把当前策略放入动态系统段。** 不予采用,因为真实提供方证据显示,首次权限切换后缓存读取降至 256 个 token而约 14.7k 个输入 token 未命中缓存。DeepSeek 匹配完整前缀;改变第一条 wire 消息会阻止复用更长的系统与历史前缀。
**由每个策略归属方独立调用 `agent.inject()`。** 不予采用,因为同级监听器的顺序会决定模型所见顺序,分开的消息可能暴露不匹配的中间快照,并且每个归属方都需要各自扫描压缩后的保留状态。现有组装归属方可以对贡献排序,并具体化一份原子化的完整快照。
**通用运行时事实包。** 不予采用因为现有系统提示词组装已经拥有段、schema、变量、作用域和权威的逐步骤 waterfall瀑布式事件。为该归属方增加有序上下文无需新增包或第二个注册表服务。
**在上下文中重复工具 schema 或计划引导。** 不予采用,因为这些接口已有各自归属方和独立生命周期。批准的当前状态加入快照,仅仅是因为同一个 `/permission` 切换会改变它,而把它留在系统段会保留缓存缺陷。
**缓存安全迁移后仍保留 Candidate A。** 不予采用,因为中性的真实提供方任务中,尽管已有 bash 尝试引导,模型仍以纯文本拒绝,且没有调用工具。保留下来的反预防性拒绝原则本身不说明任何升权机制;它告诉模型不要从常驻标签推断操作不可能完成,然后把拒绝与升权行为交还给可用工具。
**继续省略沙箱模式,因为常驻模式标签曾引发预防性拒绝。** 不予采用,因为新的 Web 请求否则会暴露变更工具,却隐去这些工具的常驻策略,导致模型在首次操作前错误声称自身能力。先前的线上测量仍是必须执行的反证测试:使用 `Bash commands run under the "read-only" file sandbox.` 时,十二个轮次中有五个没有调用工具。已提交的工具归属方尝试引导晚于该测量,因此应通过当前工具契约下的新阳性对照实验选择替代文案,而不能假设旧条件与当前条件相同。
**独立的模型上下文包。** 不予采用,因为策略归属方可以直接解析当前会话状态,现有组装服务也可以对其排序。新包只会围绕同一个请求边界引入浅层组合 seam 和额外的文档/门禁表面。
**枚举可写临时根目录。** 不予采用,因为后端要到稍后的 `confine()` 才会选定bwrap、Landlock、Seatbelt 和进程内文件系统围栏并不授予一套共同的临时路径。常驻请求中的主机特定路径既不稳定,也会作出过度承诺。
## 后果
模型在试探工具前就会收到常驻文件策略,且 `/permission` 后的下一个请求会反映已提交的模式。稳定的系统提示词不再随沙箱或批准状态变化;变化后的完整上下文快照会在保留的历史之后仅追加,状态不变时不增加消息。较旧的快照仍保留在历史中,但最新的完整快照会明确取代它们。该声明是引导,而不是强制执行护栏:运行时安全仍来自文件系统、一次性 bash 与终端后端消费同一项解析完成的策略。
聚焦测试固定了所有模式、规范化根目录、切换时机、服务释放、上下文顺序、清除、稳定的请求 header、恢复以及不同 `TMPDIR` 值下的字节稳定性。无密钥的组装快照通过真实 Loader 组合固定持久上下文消息。无密钥回放负责固定中性的拒绝到升级轨迹;它是结构回归证明,而不是措辞选型证据。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md
2026-07-30-dsh-dump-config.md: bc6504541c7868bad019a1bcd9f551435109e4c6
2026-07-30-dsh-dump-config.zh.md: 5e173305a6cd03de3db4c763f26eeda6fba68ec7

View File

@@ -0,0 +1,31 @@
# Agent Note: dsh --dump-config prints the composed config tree
Status: implemented
English | [中文](2026-07-30-dsh-dump-config.zh.md)
## Problem
The booted tree is a composition the user never sees: the shipped base, a surface overlay, and the `--config` or personal `~/.dsh/config.yaml` overlay apply as sibling patch lists where each id-targeted patch replaces the row's whole `config` and an unmatched id only warns. Debugging a misbehaving personal overlay (a restated field dropped, a row id typo, a patch applying to the wrong surface) required mentally replaying the patch algorithm across three files. There was no way to see the effective tree or to diff it against the shipped defaults.
## Decision
`dsh --dump-config` and `dsh web --dump-config` print the composed entry list — base, surface overlay, then the `--config` or personal overlay, exactly the layers that surface's boot assembles — as YAML on stdout and exit without booting. `dsh --dump-default-config` / `dsh web --dump-default-config` stop at the surface overlay, so diffing the two outputs shows precisely what the user layer changes.
The dump cannot drift from what boots because it shares the mounting code: the vendored include exports its patch algorithm as the pure `applyEntryPatches(data, patches, warn)` (the private `applyPatches` method now delegates to it) and its `!!js` YAML dialect as `entryListSchema`; `dsh-app-boot`'s `renderConfigDump()` composes labeled layers and renders through both, and `apps/cli/src/dump-config.ts` is a thin surface-selection wrapper. `!!js` expressions print verbatim and unevaluated — the dump shows composition, not one process's environment — and a patch whose target row is absent goes to stderr with its layer label, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, web CLI-flag patches, the frontend dist path) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) and each other, and `--dump-default-config` takes no `--config`.
Each run of same-provenance rows is preceded by a `# ==` comment naming the file that contributed the rows and the layers that patched them (`# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows which section comes from which file while remaining one loadable YAML document. Composition is one flattened `applyEntryPatches` call over all layers — boot's exact call shape, so even patch-visibility corner cases (a later layer targeting a group child that a plain `config` replacement introduced, invisible to the single-pass id index) compose identically; applying one call per layer would rebuild the index between layers and print a tree boot never mounts. Provenance is derived from single-call prefix snapshots (base + layers 1..k) diffed positionally: the patch algorithm only rewrites rows in place or appends, so a top-level index identifies one row across snapshots, and a layer counts as having patched a row when adding it changed that row (config replacement, disable, group insert). Patch lists are cloned per snapshot because `applyEntryPatches` pushes `insert` rows by reference from the patch list.
`dsh-app-boot` previously duplicated the include's `!!js` YAML type for patch parsing; it now imports `entryListSchema`, so the dialect has one owner.
## Alternatives considered
**Boot the tree and dump `ctx.loader.entries()`.** Rejected: booting evaluates `!!js` expressions (leaking one machine's environment into the printed config), starts adapters and sessions as side effects, requires a TTY-independent teardown path, and is slow. The dump is for debugging composition, which is a pure function of the files.
**Reimplement the patch merge in the CLI.** Rejected: a second implementation of `applyPatches` would silently drift from the vendored include — the exact failure mode the feature exists to debug. Exporting the include's own algorithm costs one logged vendor modification and guarantees identity.
**A `/dump-config` TUI command instead of flags.** Rejected as the only form: the primary use is a piped `dsh --dump-config | diff - <(dsh --dump-default-config)` style workflow, which needs a boot-free non-TTY surface. A TUI command can be added later over the same `renderConfigDump`.
## Consequences
Config debugging becomes one command instead of mental patch replay, and support can ask for `--dump-config` output. The vendored include carries one more logged local modification (the `applyEntryPatches`/`entryListSchema` exports; behavior-preserving for mounting) to re-apply on upstream sync. Provenance tracking re-composes one prefix snapshot per layer and diffs rows by JSON stringify, so the dump does extra work proportional to layers² × rows; that cost lives only in the boot-free dump path. `renderConfigDump` is unit-tested for layer ordering, verbatim `!!js` round-tripping, provenance separators and grouping, labeled unmatched-patch warnings, and loud read/parse/shape failures; the built-bin e2e drives all four flag forms through `lib/bin.js` including the personal-overlay layer, its provenance label, and its stderr warning.

View File

@@ -0,0 +1,31 @@
# Agent Note: dsh --dump-config 打印合成后的配置树
Status: implemented
[English](2026-07-30-dsh-dump-config.md) | 中文
## Problem
启动的配置树是一份用户从未见过的合成结果:已交付的基础配置、界面覆盖层,以及 `--config` 或个人 `~/.dsh/config.yaml` 覆盖层作为同级补丁列表依次应用,其中每个按 id 定向的补丁替换目标行的整个 `config`,未匹配的 id 只产生警告。调试一个行为异常的个人覆盖层(漏掉需要重述的字段、行 id 拼错、补丁应用到了错误的界面)需要在脑中跨三个文件重放补丁算法。既没有办法看到生效的树,也没有办法把它与已交付的默认值做 diff。
## Decision
`dsh --dump-config``dsh web --dump-config` 把合成后的条目列表——基础配置、界面覆盖层、再叠 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西。`dsh --dump-default-config` / `dsh web --dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。
dump 不可能与实际启动漂移因为它复用挂载代码vendored include 把补丁算法导出为纯函数 `applyEntryPatches(data, patches, warn)`(私有的 `applyPatches` 方法现在委托给它),并把 `!!js` YAML 方言导出为 `entryListSchema``dsh-app-boot``renderConfigDump()` 通过这两者对带标签的层完成合成与渲染,`apps/cli/src/dump-config.ts` 只是选择界面的薄封装。`!!js` 表达式原样打印、不求值——dump 展示的是合成结果,不是某个进程的环境——目标行不存在的补丁会连同其层标签报到 stderr与 Loader 启动时的警告一致。由启动器持有的启动上下文值会话身份、web 的 CLI 标志补丁、前端 dist 路径是每次调用的事实位于配置树之外不会出现。dump 标志拒绝仅用于启动的标志(`-p``--resume``--config-replace`)且两个 dump 标志互斥,`--dump-default-config` 不接受 `--config`
每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献这些行的文件以及修补过它们的层(`# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示每一节来自哪个文件,又仍是一份可加载的 YAML 文档。合成是对所有层展平后的一次 `applyEntryPatches` 调用——与启动的调用形状完全一致,因此即便是补丁可见性的边角情况(后一层定位到前一层通过普通 `config` 替换引入的组内子项,而单遍 id 索引看不到它)也与启动合成完全相同;若按层各调用一次,会在层与层之间重建索引,打印出一棵启动从不挂载的树。来源从单次调用的前缀快照(基础 + 第 1..k 层)按位置 diff 得出:补丁算法只会原地改写行或在末尾追加,因此顶层索引在各快照之间标识同一行;加入某层后该行发生变化(替换 config、禁用、组内插入即视为该层修补了这一行。每个快照都会克隆补丁列表因为 `applyEntryPatches` 会把 `insert` 行按引用从补丁列表推入结果。
`dsh-app-boot` 之前为解析补丁复制了 include 的 `!!js` YAML 类型;现在改为导入 `entryListSchema`,方言只有一个归属者。
## Alternatives considered
**启动整棵树后 dump `ctx.loader.entries()`。** 拒绝:启动会求值 `!!js` 表达式(把某台机器的环境泄漏进打印的配置)、以副作用启动适配器和会话、需要独立于 TTY 的拆卸路径而且慢。dump 是用来调试合成的,而合成是那些文件的纯函数。
**在 CLI 里重新实现补丁合并。** 拒绝:`applyPatches` 的第二个实现会与 vendored include 悄然漂移——这恰恰是该功能要调试的失败模式。导出 include 自己的算法只花费一条记录在案的 vendor 修改,却保证了同一性。
**用 `/dump-config` TUI 命令代替标志。** 作为唯一形式被拒绝:主要用法是 `dsh --dump-config | diff - <(dsh --dump-default-config)` 这类管道工作流,需要免启动、非 TTY 的界面。之后可以在同一个 `renderConfigDump` 之上再加 TUI 命令。
## Consequences
配置调试从脑中重放补丁变成一条命令,支持工作也可以直接索要 `--dump-config` 输出。vendored include 多出一条记录在案的本地修改(导出 `applyEntryPatches`/`entryListSchema`;对挂载行为无影响),上游同步时需重新应用。来源追踪为每层重新合成一次前缀快照并按 JSON stringify 对行做 diff因此 dump 有与层数²×行数成正比的额外开销;该开销只存在于免启动的 dump 路径。`renderConfigDump` 的单元测试覆盖层叠顺序、`!!js` 原样往返、来源分隔与分组、带标签的未匹配补丁警告以及读取解析形状失败的大声报错built-bin e2e 通过 `lib/bin.js` 驱动全部四种标志形式,包括个人覆盖层、其来源标签及其 stderr 警告。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md
2026-07-30-versioned-gui-welcome-onboarding.md: 0705469e02ddb9068722ae5d500c151f077c83fd
2026-07-30-versioned-gui-welcome-onboarding.zh.md: bdd21d635f824b8c4a4813e6bff7798b34ec9677
2026-07-30-versioned-gui-welcome-onboarding.md: 8155838f3b6b50f3474ef6c30065ad0d79e6f8af
2026-07-30-versioned-gui-welcome-onboarding.zh.md: c221a6d663af60b03757f135045961bcbcdd0da7

View File

@@ -12,7 +12,7 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check,
**The Settings shell coordinates ordered steps.** `settings.onboarding` remains a root-scoped list, but `ui-settings` projects its entry ids and order into one coordinator and mounts only the first incomplete step. The active registrant receives `complete()` and `openSection(id)`; no later step mounts until ownership transfers. The product welcome registers at order `-100`, while `ui-models` retains only the conditional DeepSeek readiness and credential-routing step at order `0`.
**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete Chinese notice, its faithful English counterpart, the Continue labels, and `WELCOME_NOTICE_VERSION`. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content.
**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
**Acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once.

View File

@@ -12,7 +12,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
**设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list`ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()``openSection(id)`;所有权转移前,不会挂载后续步骤。产品欢迎步骤的顺序为 `-100``ui-models` 则只保留顺序为 `0` 的 DeepSeek 条件式就绪状态与凭据跳转步骤。
**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整中文通知、忠实英文对侧文案、两种语言的「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI它不会创建会话事件也不会贡献任何模型可见内容。
**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI它不会创建会话事件也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
**确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。

View File

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

View File

@@ -0,0 +1,54 @@
# Agent Note: Web read card frontend — the read tool's line window renders line-numbered and highlighted
Status: implemented
English | [中文](2026-07-30-web-read-card-frontend.zh.md)
## Problem
The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent card, `card: 'read'`, to `ToolResultView`: a settled read now carries `{ path, lines: [{ number, text }], totalLines, lang? }` onto the conversation snapshot as `resultView`. That data reaches the browser, but the Web client had no consumer for it. Every read row derived from args alone and the details panel flattened the result's content blocks into one `<pre>`, so a read showed as `N: text`-prefixed plain text with no gutter, no syntax highlighting, and no "showing N of M" affordance for a windowed read. The [web terminal card](2026-07-28-web-terminal-card.md) established the pattern for consuming a structured card; the read card follows it, result-side only.
## Decision
`ReadBlock` is a `ui-primitives` component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. `ui-conversation/src/client/contract/read-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so the two sites cannot disagree.
**A new `ReadBlock` primitive, not an extension of `CodeBlock`.** `CodeBlock` already does shiki highlighting with a language banner and a copy control, but a read view needs a per-line gutter carrying each line's own file number, which `CodeBlock` renders as a single `<pre>` tree with no per-line structure. Extending `CodeBlock` with an optional gutter would push a read-specific concern (windowed line numbers, a "showing N of M" note, a height cap) onto every markdown fence and every `run_code` body that shares that component. Instead `ReadBlock` reuses the part that is genuinely shared: the shiki grammar singleton in `markdown/highlight.ts`. A new `highlightLines(code, lang)` there tokenizes into shiki's own per-line token arrays (`codeToTokens`) rather than the single-`<pre>` HTML `highlightToHtml` produces, so the block can place one gutter number per line and still color the content through the same `--shiki-*` custom properties on the same grammar allowlist. The height cap and its head/tail expand arithmetic are copied from `TerminalBlock` (`ceil(max/2)` head plus the remaining tail), so a long read and a long command output collapse at the same place. The copy control writes the window's raw text (the lines joined by newlines), never the gutter numbers or the banner.
`readCardModel` is result-side only, mirroring the backend: a read call carries no content until `execute` returns, so the pending call stays a `GenericCallView` (`kind: 'read'`) and this returns null for a running read — the row keeps its args-derived summary until the result arrives. It also returns null for a settled call whose result view is not a read card, including a `card` value this UI version does not know (which arrives over the wire and cannot be trusted to be a compiled variant) and the read tool's own generic fallback for an error result. The card's banner label is the read view's `title` when the tool supplied one (the contract's replacement-title rule), otherwise the file path relativized to the session workspace so a workspace-rooted absolute path shows the same short form the row summary shows. The model copies the frozen line array into the primitive's own line shape, so the card never holds a reference into the runtime's snapshot cache.
The chat row renders the card **resident** under the summary line, capped at `CHAT_READ_MAX_LINES` (8, half the primitive's default), the same posture `BashRow` gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed `ReadRow` (registered under `read` in `apply.ts`, the load-order seam being `inject: ['slots', 'conversation']` exactly as the bash sample) whose summary is the file path as an openable host link, and `GenericToolCard`'s fallback for a read-declaring tool without its own keyed row (e.g. `web_fetch`, which classifies to the `read` variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface.
Whole-row collapse/expand (defaulting every tool call to collapsed) is a separate later change that will flip every resident card at once; this note's card is resident, matching the terminal card it sits beside.
**Read-card grammars load lazily; only the boot three stay eager.** `highlight.ts` is a platform seed `ui-primitives` loads on every Web boot, and its warm-up unconditionally builds the shiki singleton. The read card's `langFromPath` hints span the full source/config/markup extension set (python, rust, yaml, html, …); registering all of them eagerly would add ~1.6 MB of grammar modules to the boot chunk and their synchronous init to every session, including sessions that never open a read card. So only the three grammars every session already renders — TypeScript, shell, JSON (the markdown-fence and `run_code` languages) — load at boot. Each read-card extension grammar sits behind a dynamic `import()` in `LAZY_GRAMMARS`, keyed by the grammar id its aliases resolve to. On the first `highlightLines`/`highlightToHtml` call for a lazy language, `ensureGrammar` starts the import (once) and returns not-ready, so the card renders plain that frame; when the import resolves it registers the grammar with `loadLanguageSync`, bumps a load counter, and notifies subscribers. `ReadBlock` and `CodeBlock` subscribe through `useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)`, so the card re-renders with highlighting the moment the grammar is ready. An unknown/absent language still returns undefined synchronously (plain, never an error).
**The empty-window copy control is hidden, matching `TerminalBlock`.** A successful read of an empty file returns `lines: []`, `totalLines: 0`, and `presentResult` still projects `card: 'read'`, so the empty-window branch is reachable — the read card is not, as an earlier draft assumed, unreachable for an empty result. `ReadBlock` therefore hides the copy control when `lines` is empty, exactly as `TerminalBlock` hides copy on empty output, so the button can never wipe the clipboard with an empty string.
## Alternatives considered
**Extend `CodeBlock` with an optional line-number gutter and `startLine`.** Rejected: it imposes a read-specific gutter, a windowed-count note, and a height cap on every markdown fence and `run_code` body that shares `CodeBlock`, for no benefit to those callers. The genuinely shared surface is the shiki grammar singleton, which both blocks reuse through `highlight.ts`; the chrome around it differs (a read has a gutter and a window note, a fence has neither), so a second small primitive is the correct split, exactly as `TerminalBlock` is a second primitive over the same tokens rather than a `CodeBlock` mode.
**Reuse `highlightToHtml` and inject gutter numbers with CSS counters.** Rejected: the single-`<pre>` HTML shiki emits has no per-line boundary a gutter can hang a file line number off (a windowed read's numbers start above 1 and are not a simple CSS counter increment), and parsing the numbers back out of the HTML would be fragile. `codeToTokens` gives the per-line token structure directly.
**Register all read-card grammars eagerly in the boot warm-up.** Rejected: it puts ~1.6 MB of grammar modules and their synchronous init on every Web boot for a card most sessions never open. The lazy path costs a single plain-first frame the first time a given language is read, then highlights on the grammar-load re-render; the boot cost is paid only for the three grammars every session already renders.
## Consequences
`ui-primitives` gains `ReadBlock` and `highlightLines`; no new runtime dependency (shiki was already present for `CodeBlock`). `ReadBlock` reads only the read 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 read capability still gets the backend's `content` fallback (the envelope-stripped text) through the generic card, unchanged.
A read row in the Web chat now carries the file content resident, a deliberate density increase over a summary-only row, bounded by the chat cap. A `run_code` sub-dispatch does not reach a read card on the shipped wire for the same reason a nested bash call does not reach a terminal card: `session.ts` folds `tool/code-dispatch(-start)` with `resultView: null`, so a nested read keeps the generic flattened form.
## Testing
`packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs.
`packages/client/ui-conversation/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so it is written against no gate pressure.
The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`. The turn 64 `run_code` sample's nested read sub-dispatches do not exercise the render-site fallback read card: `session.ts` folds them with `resultView: null`, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by `read-card.spec.tsx`'s `web_fetch` case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`.
## Related
- [Read card backend](2026-07-30-web-read-card.md) — adds the `card: 'read'` result view this consumes; produces the `lines`/`totalLines`/`lang` this renders.
- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this follows: a `ui-primitives` block, a `contract/*-card-model.ts` derivation, a keyed row, and making `GenericToolCard`/`DetailsPanel` card-aware.
- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md) — owns `CodeBlock` and the shiki `highlight.ts` singleton this extends with a per-line token path.
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `read` arm.

View File

@@ -0,0 +1,54 @@
# Agent Note: Web 读取卡片前端 —— 读取工具的行窗口以带行号、语法高亮的形式渲染
Status: implemented
[English](2026-07-30-web-read-card-frontend.md) | 中文
## Problem
[读取后端](2026-07-30-web-read-card.md)给 `ToolResultView` 增加了第四种渲染意图卡片 `card: 'read'`:一次已结算的读取现在会把 `{ path, lines: [{ number, text }], totalLines, lang? }` 作为 `resultView` 带到会话快照上。这份数据能到达浏览器,但 Web 客户端没有消费者。每个读取行都仅从参数派生,详情面板把结果的 content block 摊平进一个 `<pre>`,于是读取显示为带 `N: text` 前缀的纯文本,没有行号栏、没有语法高亮,也没有窗口读取的"显示 N / M"提示。[web 终端卡片](2026-07-28-web-terminal-card.md)确立了消费一个结构化卡片的模式;读取卡片沿用它,只在结果侧。
## Decision
`ReadBlock` 是一个 `ui-primitives` 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。`ui-conversation/src/client/contract/read-card-model.ts` 是把快照的 `resultView` 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。
**新建一个 `ReadBlock` primitive而不是扩展 `CodeBlock`。** `CodeBlock` 已经带语言横幅和复制控件做 shiki 高亮,但读取视图需要一个每行带该行自身文件行号的行号栏,而 `CodeBlock` 把内容渲染为单个 `<pre>` 树、没有逐行结构。给 `CodeBlock` 加一个可选行号栏会把读取专属的关切(窗口行号、"显示 N / M"提示、高度上限)强加给共享该组件的每个 markdown 代码围栏和每个 `run_code` 程序体。`ReadBlock` 转而复用真正共享的部分:`markdown/highlight.ts` 里的 shiki 语法单例。那里新增的 `highlightLines(code, lang)` 把代码切成 shiki 自己的逐行 token 数组(`codeToTokens`),而不是 `highlightToHtml` 产出的单 `<pre>` HTML于是该 block 能每行放一个行号、同时用同一套 `--shiki-*` 自定义属性、同一份语法白名单给内容上色。高度上限及其头/尾展开算法照抄自 `TerminalBlock``ceil(max/2)` 行头部加剩余的尾部),因此长读取和长命令输出在同一处折叠。复制控件写入窗口的原始文本(各行以换行拼接),绝不含行号栏或横幅。
`readCardModel` 只在结果侧,与后端对称:一次读取调用在 `execute` 返回前不带任何内容,因此挂起中的调用保持为 `GenericCallView``kind: 'read'`),本函数对运行中的读取返回 null —— 该行保持其从参数派生的摘要,直到结果到达。它对结果视图不是读取卡片的已结算调用也返回 null包括本 UI 版本不认识的 `card` 值(它从线路到来、不能被信任为一个已编译的变体)以及读取工具对错误结果自己的通用回退。卡片横幅标签在工具提供 `title` 时取它(契约的替换标题规则),否则取相对于会话工作区化简后的文件路径,使工作区根下的绝对路径显示为与行摘要相同的短形式。该 model 把冻结的行数组复制进 primitive 自己的行形状,因此卡片绝不持有指向运行时快照缓存的引用。
聊天行把卡片**常驻**渲染在摘要行之下,上限 `CHAT_READ_MAX_LINES`8是 primitive 默认值的一半),与 `BashRow` 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它keyed `ReadRow`(在 `apply.ts` 里以 `read` 键注册,加载顺序接缝为 `inject: ['slots', 'conversation']`,与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 `GenericToolCard` 对没有自己 keyed 行的读取声明工具(例如归到 `read` 变体的 `web_fetch`)的回退。详情面板以 primitive 自己的全高上限16渲染同一张卡片因为面板是单次调用的阅读界面。
整行折叠/展开(把每个工具调用默认折叠)是一个单独的后续改动,它会一次性翻转每张常驻卡片;本 note 的卡片是常驻的,与它旁边的终端卡片一致。
**读取卡片的语法按需 lazy 加载,只有 boot 三种保持 eager。** `highlight.ts``ui-primitives` 在每次 Web 启动都加载的平台 seed其预热会无条件构建 shiki 单例。读取卡片的 `langFromPath` 提示覆盖完整的源码/配置/标记扩展集python、rust、yaml、html……把它们全部 eager 注册会给启动 chunk 增加约 1.6 MB 的语法模块、并把它们的同步初始化摊给每个会话,包括从不打开读取卡片的会话。因此只有每个会话本就渲染的三种语法 —— TypeScript、shell、JSONmarkdown 围栏与 `run_code` 语言)—— 在 boot 时加载。每种读取卡片扩展语法置于 `LAZY_GRAMMARS` 中一个动态 `import()` 之后,以其别名解析到的语法 id 为键。对某个 lazy 语言首次调用 `highlightLines`/`highlightToHtml` 时,`ensureGrammar` 启动 import仅一次并返回未就绪于是卡片该帧渲染纯文本import 解析后用 `loadLanguageSync` 注册该语法、递增一个加载计数、并通知订阅者。`ReadBlock``CodeBlock` 通过 `useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)` 订阅,因此语法就绪的那一刻卡片就重渲染带上高亮。未知/缺省语言仍同步返回 undefined纯文本绝不报错
**空窗口的复制控件被隐藏,与 `TerminalBlock` 对齐。** 成功读取一个空文件会返回 `lines: []``totalLines: 0`,且 `presentResult` 仍投出 `card: 'read'`,因此空窗口分支是可达的 —— 读取卡片并非如早前草稿所假设的对空结果不可达。故 `ReadBlock``lines` 为空时隐藏复制控件,正如 `TerminalBlock` 对空输出隐藏复制,使按钮绝不会用空字符串清空剪贴板。
## Alternatives considered
**给 `CodeBlock` 加一个可选行号栏和 `startLine`。** 拒绝:这会把读取专属的行号栏、窗口计数提示和高度上限强加给共享 `CodeBlock` 的每个 markdown 围栏和 `run_code` 程序体,对那些调用者毫无好处。真正共享的界面是 shiki 语法单例,两个 block 都通过 `highlight.ts` 复用它;围绕它的外壳各不相同(读取有行号栏和窗口提示,围栏两者都没有),因此第二个小 primitive 是正确的切分 —— 正如 `TerminalBlock` 是基于同一套 token 的第二个 primitive而不是 `CodeBlock` 的一种模式。
**复用 `highlightToHtml`,用 CSS counter 注入行号。** 拒绝shiki 产出的单 `<pre>` HTML 没有可供行号栏挂上文件行号的逐行边界(窗口读取的行号从大于 1 处开始,不是简单的 CSS counter 自增),而从 HTML 里把行号解析回来又很脆弱。`codeToTokens` 直接给出逐行 token 结构。
**在 boot 预热里 eager 注册所有读取卡片语法。** 拒绝:这会给每次 Web 启动摊上约 1.6 MB 语法模块及其同步初始化只为一张多数会话从不打开的卡片。lazy 路径的代价是某个语言首次被读取时的一帧纯文本随后在语法加载的重渲染里高亮boot 代价只为每个会话本就渲染的三种语法付出。
## Consequences
`ui-primitives` 增加 `ReadBlock``highlightLines`没有新的运行时依赖shiki 已因 `CodeBlock` 存在)。`ReadBlock` 只读取读取视图的字段,因此保持为渲染意图所承载内容的纯函数 —— 无会话查询,与产出该视图的 presenter 一样可安全回放。没有读取能力的 UI 仍通过通用卡片拿到后端的 `content` 回退(剥掉外壳的文本),保持不变。
Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行的一次刻意的密度增加,受聊天上限约束。`run_code` 子派发在已发布的线路上到不了读取卡片,与嵌套 bash 调用到不了终端卡片同因:`session.ts``tool/code-dispatch(-start)` 折叠为 `resultView: null`,因此嵌套读取保持通用的摊平形式。
## Testing
`packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径lazy 语法首次触碰返回纯文本import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx``highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。
`packages/client/ui-conversation/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-conversation/src/*`),因此不承受门槛压力。
fixture`packages/client/connection/src/client/fixture.ts`)增加 turn 66一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`。turn 64 的 `run_code` 样例中的嵌套读取子派发并不驱动渲染点回退读取卡片:`session.ts` 把它们折叠为 `resultView: null`,因此它们只覆盖回退行的通用行形状,而非回退行内的读取卡片;回退行读取卡片由 `read-card.spec.tsx``web_fetch` 用例钉住。turn 66 排在 todo turn现为 67之前与终端样例同因常驻计划在下一次 `turn/start` 退场。
## Related
- [读取卡片后端](2026-07-30-web-read-card.md) —— 增加本文消费的 `card: 'read'` 结果视图;产出本文渲染的 `lines`/`totalLines`/`lang`
- [Web 终端卡片](2026-07-28-web-terminal-card.md) —— 本文遵循的先例:一个 `ui-primitives` block、一个 `contract/*-card-model.ts` 派生、一个 keyed 行,以及让 `GenericToolCard`/`DetailsPanel` 感知卡片。
- [Web 客户端语法高亮](../process/2026-07-26-web-syntax-highlighting-shiki.md) —— 拥有 `CodeBlock` 与 shiki `highlight.ts` 单例,本文以逐行 token 路径扩展它。
- [工具调用呈现的标签式渲染意图联合](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇表Web 客户端现在是 `read` 分支的完整消费者。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-search-card.md
2026-07-30-web-search-card.md: 4c7ae6c8c658f4f10f0667b12853cb2e70df15b1
2026-07-30-web-search-card.zh.md: 714a2979730dc2c83f6cfc1cf6d21978755a2d95

View File

@@ -0,0 +1,65 @@
# Agent Note: Web search card — the grep and glob render intent reaches the browser
Status: implemented
English | [中文](2026-07-30-web-search-card.zh.md)
## Problem
The `grep` and `glob` tools declare a result-time `card: 'search'` render intent ([search render card](2026-07-30-search-render-card.md)): a `SearchMatchesResultView` (`shape: 'matches'`) carrying grep's matches grouped by file, or a `SearchPathsResultView` (`shape: 'paths'`) carrying glob's flat path list, both with a `truncated`/`total` capping signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: every non-terminal, non-diff tool result fell through to the generic card, which renders the model-facing text. A web frontend that wants an expandable per-file group of matches, or a scannable path list, had only the pre-formatted text.
This is the follow-up the search render card note names: that PR was the backend contract and its two producers; this PR is the web consumer.
## Decision
`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation.
The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card.
One component draws both shapes, discriminated by `kind`, because `grep` and `glob` are the same visual object — a search result. `SearchMatchesBlockProps` (`kind: 'matches'`) and `SearchPathsBlockProps` (`kind: 'paths'`) keep each shape's fields required rather than a single interface with everything optional. The component flattens whichever shape it holds into one list of render rows — a file header row plus its match rows for the matches shape, one path row per path for the paths shape — so the height cap counts a file header as one row exactly as a match line or a path, and the head/tail slice arithmetic is `TerminalBlock`'s (`ceil(max/2)` head, the remainder tail), so a long search result and a long command output cut at the same place across the two cards.
The component's contract:
- **Grouped matches, collapsible per file.** Each file is a header row (a bold path plus its match count, the whole row the collapse control) followed by its `lineNumber: line` rows. Collapsing a group drops its match rows from the flattened list and from the height cap's arithmetic, but never from the copy text.
- **Flat path list.** The paths shape renders one path per row, no headers.
- **A capped indicator.** When `truncated`, the banner summary folds the pre-cap total in — `显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob — so the card never presents a capped page as the complete result. When not `truncated` the summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`).
- **A recovery footer for a capped result.** The card holds only the retained page, but the locator to the rest — grep/glob's `Full … stored at: <locator>` footer — lives only in the raw `tool/result` content (the search view carries no result text; a UI without a card falls back to that raw content), not in the structured matches/paths. Because every render site replaces the raw result with the card, `searchCardModel` surfaces the block's own flattened result text as `SearchCardModel.recovery` when (and only when) the result was capped, and each render site draws it below the card. Without this the one path to the dropped rows would vanish from the UI; an uncapped result carries every row, so its raw text adds nothing and is dropped.
- **No soft wrapping.** Result rows are `white-space: pre` inside a horizontally scrolling box, so a long match line or a deep path scrolls sideways rather than folding.
- **Height cap with an expand control.** More than `DEFAULT_SEARCH_MAX_LINES` (16) rows shows a head/tail slice with a button reporting the hidden count, the same shape and arithmetic as `TerminalBlock`.
- **Copy.** The copy control writes the whole structured result — every file and match, or every path — regardless of the height cap or which groups are collapsed, so the clipboard carries the result rather than what the card happens to be showing.
Geometry, radius, and fonts mirror `CodeBlock` and `TerminalBlock`, so a search card reads as one family with them; `white-space: pre` plus horizontal scroll is the shared deliberate divergence.
### Render sites
Three sites consume the derivation, mirroring the terminal card's placement exactly:
- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. A capped result's recovery footer sits below the card. Because the keyed row owns this render slot, a settled call with no search card — an errored search (grep/glob emit no result view on error), a successful nested `run_code` sub-dispatch (the backend computes no `presentationMeta`, so `resultView` is null), or a legacy generic result — would otherwise show only its summary with its content lost; the row surfaces that model-facing text as a fallback body, keyed on `search === null && settled` rather than on the error state alone. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.)
- **The generic fallback** (`chat/GenericToolCard``chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card, with the recovery footer, behind the row's expand toggle.
- **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, with the recovery footer below it, keeping the JSON Input section.
`CHAT_SEARCH_MAX_LINES` (8) is the row cap, half the primitive's default the panel keeps, for the same reason as `CHAT_TERMINAL_MAX_LINES`: the chat flow is a summary surface read across many calls, the panel is the single-call reading surface.
## Alternatives considered
**Two card components, one per tool.** Rejected: `grep` and `glob` are the same visual object discriminated only by `kind`, so two components would duplicate the banner, the height cap, the copy control, and the no-wrap geometry. One component switching on `kind` is what the backend's single `card: 'search'` view is for.
**A `SearchCallView` so the row renders a card while the search runs.** Rejected: the backend contract deliberately has no call-time search view — a search has no matches or paths before `execute`. The running row shows its summary alone, and `searchCardModel` returns null for a running block, which is faithful to what exists.
**Reuse `TerminalBlock` or `CodeBlock`.** Rejected: neither models per-file collapsible groups or a folded capped-result summary, and both would need the grouped-matches shape bolted on. The three blocks share their geometry and font tokens instead, which is the only part where one implementation is correct for all.
## Consequences
`SearchBlock` reads only the search 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 search capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed. Extending `ToolRow` with a `search` body prop adds one arm beside `terminal`; a call carries at most one card kind, so the two are never both present on a row.
## Testing
`packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths.
`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot.
## Related
- [Search render intent — grep and glob emit a structured search card](2026-07-30-search-render-card.md) — the backend contract and its two producers; this is its named web-consumer follow-up.
- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a tool's render intent reaches the browser through a `ui-primitives` block, a single `contract/*-card-model.ts` derivation, and the same three render sites.
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary both cards consume.

View File

@@ -0,0 +1,65 @@
# Agent NoteWeb 搜索卡片 —— grep 与 glob 的 render intent 到达浏览器
Status: implemented
[English](2026-07-30-web-search-card.md) | 中文
## Problem
`grep``glob` 工具声明了一个仅在结果阶段存在的 `card: 'search'` render intent[search render card](2026-07-30-search-render-card.md)`SearchMatchesResultView``shape: 'matches'`)携带 grep 按文件分组的匹配,或 `SearchPathsResultView``shape: 'paths'`)携带 glob 的扁平路径列表,两者都带 `truncated`/`total` 截断信号。该视图已经到达浏览器 —— host、connection、runtime 把它作为 `resultView` 投递到 `ConversationSnapshot` 上 —— 但 Web 客户端忽略了它:每个非终端、非 diff 的工具结果都落到 generic 卡片,渲染面向模型的文本。想把搜索结果渲染成可展开的按文件匹配分组、或可扫读的路径列表的 web 前端,只有那段预格式化文本。
这正是 search render card note 指名的后续:那个 PR 是后端契约和它的两个生产者,本 PR 是 web 消费者。
## Decision
`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`shape` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `shape` 和分组/扁平内容与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `shape` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock``.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(不是 `kind` —— 后端把 `kind` 留给 call view 的选图标签);`SearchBlock` 自身的 prop 仍是 `kind`,由本推导从 `shape` 映射得到。
与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView``resultView`因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。
一个组件绘制两种形态,用 `kind` 区分,因为 `grep``glob` 是同一个视觉对象 —— 一个搜索结果。`SearchMatchesBlockProps``kind: 'matches'`)和 `SearchPathsBlockProps``kind: 'paths'`)让每种形态的字段保持必填,而不是所有字段都可选的单一接口。组件把它持有的形态压平成一个渲染行列表 —— matches 形态是一个文件头行加它的匹配行paths 形态是每个路径一行 —— 于是高度上限把一个文件头当作一行来计,与一条匹配行或一个路径相同,头/尾切片算术就是 `TerminalBlock` 的(`ceil(max/2)` 头,其余为尾),因此一个长搜索结果和一段长命令输出在两张卡片间在同一处截断。
组件契约:
- **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。
- **扁平路径列表。** paths 形态每行一个路径,无头行。
- **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。
- **被截断结果的恢复脚注。** 卡片只持有保留的那一页,但通往其余部分的定位符 —— grep/glob 的 `Full … stored at: <locator>` 脚注 —— 只存在于原始 `tool/result` 内容里(搜索视图不携带结果文本;没有卡片的 UI 回退到那段原始内容),而非结构化的 matches/paths 中。由于每个渲染点都用卡片替换了原始结果,`searchCardModel` 在(且仅在)结果被截断时把 block 自身压平后的结果文本作为 `SearchCardModel.recovery` 暴露出来,每个渲染点把它画在卡片下方。没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截断的结果携带了每一行,其原始文本不增加任何信息,因此被丢弃。
- **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。
- **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`16行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。
- **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。
几何、圆角、字体镜像 `CodeBlock``TerminalBlock`,因此搜索卡片与它们读作同一族;`white-space: pre` 加横向滚动是它们共享的刻意分歧。
### 渲染点
三个渲染点消费该推导,与终端卡片的落位完全一致:
- **keyed `SearchRow`**`toolviews/search-row.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep``glob` 键下并把卡片作为常驻resident渲染在摘要行下方上限为 `CHAT_SEARCH_MAX_LINES`8—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。被截断结果的恢复脚注画在卡片下方。因为 keyed 行占据了这个渲染槽,一个没有搜索卡片的已结算调用 —— 出错的搜索grep/glob 出错时不产出结果视图)、成功的嵌套 `run_code` 子派发(后端不为其计算 `presentationMeta`,故 `resultView` 为 null、或旧日志的 generic 结果 —— 否则只会显示摘要而丢失内容;该行把这段面向模型的文本作为 fallback body 暴露出来,判据是 `search === null && 已结算`,而非仅凭错误状态。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。)
- **generic fallback**`chat/GenericToolCard``chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片,并带恢复脚注。
- **details panel**`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,恢复脚注画在其下方,保留 JSON Input 段。
`CHAT_SEARCH_MAX_LINES`8是行内上限为 primitive 默认值的一半panel 保留默认值),理由与 `CHAT_TERMINAL_MAX_LINES` 相同chat 流是跨多次调用扫读的摘要表面panel 是单次调用的阅读表面。
## Alternatives considered
**两个卡片组件,每个工具一个。** 否决:`grep``glob` 是仅由 `kind` 区分的同一视觉对象,两个组件会重复横幅、高度上限、复制控件与不换行几何。一个按 `kind` 分支的组件正是后端那个单一 `card: 'search'` 视图的用途。
**加一个 `SearchCallView`,让行在搜索运行时就渲染卡片。** 否决:后端契约刻意没有调用阶段的搜索视图 —— 搜索在 `execute` 前没有匹配或路径。运行中的行只显示摘要,`searchCardModel` 对运行块返回 null忠实于实际存在的东西。
**复用 `TerminalBlock` 或 `CodeBlock`。** 否决:两者都不建模逐文件可折叠的组或折叠式截断摘要,都需要把按文件分组的形态硬塞进去。三个块转而共享几何与字体 token那是唯一一处一个实现对三者都正确的部分。
## Consequences
`SearchBlock` 只读搜索视图的字段,因此保持为 render intent 所携内容的纯函数 —— 无会话查询,与产生该视图的 presenter 一样可重放。没有搜索能力的 UI 仍得到 bridge 的围栏回退;工具的结果形态没有任何改变。给 `ToolRow` 扩一个 `search` body prop 只在 `terminal` 旁加一个分支;一次调用至多携带一种卡片,因此两者绝不同时出现在一行。
## Testing
`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。
`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind通过 `GenericToolCard` 的展开门控 matches 与 paths body含恢复脚注对照非搜索的 args-JSON body`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级以及一个组件在 `grep``glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind含恢复脚注对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'``grep` turn三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'``glob` turn两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库契约要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke按契约只测启动无法捕获它。
## Related
- [Search render intent —— grep 与 glob 发出结构化搜索卡片](2026-07-30-search-render-card.md) —— 后端契约与它的两个生产者;本 note 是它指名的 web 消费者后续。
- [Web 终端卡片](2026-07-28-web-terminal-card.md) —— 本 note 镜像的先例:工具的 render intent 通过一个 `ui-primitives` 块、一个 `contract/*-card-model.ts` 推导、以及同样的三个渲染点到达浏览器。
- [工具调用呈现的标签化 render-intent 联合](../architecture/2026-07-02-tool-render-intent-union.md) —— 两张卡片都消费的 `card` 标签词汇。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md
2026-07-31-browser-derived-initial-locale.md: 0c49a6bbfec0ab33a5aa3ce53dde0cac747f3816
2026-07-31-browser-derived-initial-locale.zh.md: c013d24dcd3bb49d176eaddd42ff41dde320ff1f

View File

@@ -0,0 +1,36 @@
# Agent Note: The Settings language a fresh browser opens in comes from the browser
Status: implemented
English | [中文](2026-07-31-browser-derived-initial-locale.zh.md)
## Problem
The Settings Language row opened every first visit in Chinese: `LocaleService` read `dsh.locale` from localStorage and fell straight back to `zh` when nothing was stored. The browser already states which languages its user reads — `navigator.languages` is that statement — and the app ignored it, so an English reader met a Chinese product and had to find a Chinese-labelled settings row to escape it. The fallback was doing two jobs at once: the last resort for an unresolvable locale, and the answer for every user who had simply never chosen.
## Decision
**The initial locale resolves through three ordered sources: the persisted preference, then the browser, then `FALLBACK_LOCALE`.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and is the only place the order is expressed; `restorePreference()` now returns `LocaleId | undefined` (an absent, unparseable, or unreachable store reads as *no preference*) so the next source can speak.
**Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express.
**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`.
**An explicit choice is permanent.** `setLocale` persistence is untouched, and the persisted value is consulted first, so a user who picked a language keeps it even when travelling between browser profiles or system languages. Nothing writes the detected locale back to storage: detection is re-derived every boot and stays invisible to the "has the user chosen?" question.
**The browser e2e lane now pins the browser language, not just storage.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` keeps pinning `dsh.locale=en`, which still wins over any browser language. `settings-chrome.e2e.ts` gained a scenario opening a second `en-US` page with empty storage and asserting the settings surface comes up English — the assembled-app proof of this feature.
## Alternatives considered
- **`Intl.DateTimeFormat().resolvedOptions().locale` or a single `navigator.language` read**: both collapse the user's ordered preference list to one tag, so a `['de', 'en', 'zh']` reader gets zh instead of en. The list is the part of the browser statement worth reading.
- **Persisting the detected locale on first boot**: it would make detection a one-time event and let a stale first visit outlive a changed browser language, and it destroys the distinction the resolution order rests on — a stored value would no longer mean "the user chose this".
- **Full BCP 47 negotiation (`Intl.LocaleMatcher`-style lookup, region and script weighting)**: with exactly two shipped locales that differ in language, primary-subtag matching is the whole of the correct answer; a negotiation layer would be untestable surface with no behavior to justify it.
- **A cordis config key for the default locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer.
- **Keeping the e2e lane's zh scenarios on storage pinning (`dsh.locale=zh`)**: it would keep the suite green while removing the only place the browser-derived path runs in an assembled app; pinning the browser language instead exercises the new resolution end to end.
## Consequences
- A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction.
- `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen".
- Tests that construct a `LocaleService` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all).
- Detection cost is one array walk per service construction, and no storage write, so boot behavior and the persisted-state surface are unchanged.

View File

@@ -0,0 +1,36 @@
# Agent Note: 全新浏览器打开的设置语言由浏览器决定
Status: implemented
[English](2026-07-31-browser-derived-initial-locale.md) | 中文
## Problem
设置里的语言行在每一次首访时都以中文开场:`LocaleService` 从 localStorage 读取 `dsh.locale`,读不到就直接回落到 `zh`。浏览器本已声明其使用者阅读哪些语言——`navigator.languages` 就是这份声明——而应用对此视而不见,于是英文读者迎面撞上一个中文产品,还得先找到一行中文标签的设置项才能脱身。回落值当时同时承担两份职责:既是无法解析出 locale 时的最后兜底,也是所有从未做过选择的用户拿到的答案。
## Decision
**初始 locale 依次经三个来源解析:已持久化的偏好、浏览器、`FALLBACK_LOCALE`。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,是这一顺序的唯一表达处;`restorePreference()` 现在返回 `LocaleId | undefined`(存储项缺失、无法解析或不可访问,一律读作*没有偏好*),后一个来源才有开口的机会。
**浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN``zh-TW` 同归 `zh``en-GB``en`;而只请求本应用不提供的语言(`fr``de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。
**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`
**显式选择是永久的。** `setLocale` 的持久化未作改动,且持久化值最先被查询,因此选过语言的用户即便在不同浏览器配置或系统语言之间辗转也保留原选择。没有任何代码把探测到的 locale 写回存储:探测在每次启动时重新推导,对"用户是否做过选择"这一问题始终不可见。
**浏览器 e2e 车道现在钉住浏览器语言,而不只是存储项。** 断言中文文案的场景(`access-confirmation``models-settings``onboarding-deepseek-config``settings-chrome`)以 `apps/web/tests/support.ts``locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 仍然钉 `dsh.locale=en`,它依旧压过任何浏览器语言。`settings-chrome.e2e.ts` 新增一个场景:另开一个存储项为空的 `en-US` 页面,断言设置界面以英文呈现——这是本功能在组装后应用中的证据。
## Alternatives considered
- **`Intl.DateTimeFormat().resolvedOptions().locale` 或单读 `navigator.language`**:两者都把用户的有序偏好列表塌缩成一个标签,于是 `['de', 'en', 'zh']` 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。
- **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着"用户选了它"。
- **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:在只提供两个语言互异的 locale 时,主子标签匹配就是正确答案的全部;协商层只会带来无行为支撑、也无从测试的表面积。
- **为默认 locale 增加一个 cordis config key**:此处部署之间并无差异——回落值是产品对"完全没有信号"给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费者、且随部署变化的选择。
- **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。
## Consequences
- 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。
- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当"用户尚未选择"。
- 在 jsdom 下构造 `LocaleService` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`dsh-client-test-runtime声明其浏览器今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。
- 探测的代价是每次服务构造遍历一次数组,且不写存储,因此启动行为与持久化状态面均无变化。

View File

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

View File

@@ -0,0 +1,29 @@
# Agent Note: Hover cards copy their primary value on activation
Status: implemented
English | [中文](2026-07-31-hover-card-click-copy.zh.md)
## Problem
Workspace and Session rows clip the two values their hover cards expose in full: the Workspace directory path and Session title. The [reachable card](../bug-fix/2026-07-30-hover-popup-pointer-grace.md) permits text selection, but selecting and copying a single known value is a needlessly precise gesture, and the card gives no confirmation that the clipboard accepted it.
## Decision
`HoverCard` accepts an optional `copyText` plus `copyLabel` and `copiedLabel`. With `copyText`, the whole card has button semantics for pointer and keyboard activation; its accessible name combines the localized action prefix with the exact value, it writes that value through the shared clipboard helper, and it replaces its content with the success label for up to one second only after the host accepts the write. The feedback retains the pre-copy card height and clears with the card. Without `copyText`, the atom retains its read/select-only behavior.
The Workspace browser chooses the payload rather than making the primitive infer it from rendered text: a Workspace card passes the full directory path, and a non-blank Session card passes the full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder, not session content. The browser's locale seat supplies `Copy`/`复制` and the success state `Copied`/`已复制`.
Press and activation remain separate contracts. A pointer press inside the card keeps it mounted so text selection can begin; a completed non-collapsed selection intersecting the card suppresses pointer-click activation, while a plain click or button key activates copy. Anchor-region presses still dismiss immediately, and clipboard rejection leaves the original content visible without claiming success.
## Alternatives considered
**Copy the card's rendered `textContent`.** That would concatenate the primary value with creation time or running status, making the clipboard payload depend on presentation and localization.
**Implement clipboard state in both Workspace card bodies.** The two consumers would duplicate host fallback, keyboard behavior, timer ownership, and success rendering even though the card owns the activation surface.
**Change the common Chinese `copied` label from `复制成功` to `已复制`.** That would alter every existing copy control to satisfy one card interaction. The Workspace dictionary owns the card-specific wording instead.
## Consequences
Both non-placeholder hover-card variants gain the same click and keyboard affordance while retaining consumer-owned payload semantics and localized feedback. The generic atom adds one optional behavior path and a one-second timer; it clears copied state on close, ignores completion after close or unmount, and never reports a rejected write as success. Focused component coverage pins pointer selection precedence, activation, failure, feedback geometry and expiry, and cleanup, while the real-browser Workspace scenario verifies the English label, stable feedback height, and browser clipboard.

View File

@@ -0,0 +1,29 @@
# Agent Noteagent 决策记录):悬浮卡片激活时复制主要值
Status: implemented
[English](2026-07-31-hover-card-click-copy.md) | 中文
## 问题
Workspace 与 Session 行会截断对应悬浮卡片完整展示的两个值Workspace 目录路径和 Session 标题。这张[可抵达的卡片](../bug-fix/2026-07-30-hover-popup-pointer-grace.md)支持文本选择,但复制单个已知值仍需精确选择,操作没有必要地繁琐;卡片也不会确认剪贴板是否接受了写入。
## 决策
`HoverCard` 接收可选的 `copyText`,以及 `copyLabel``copiedLabel`。传入 `copyText` 后,整个卡片都会为指针与键盘激活提供按钮语义;其无障碍名称由本地化操作前缀和原值组成,卡片通过共享剪贴板辅助函数写入该值,并且只有宿主接受写入后,才会用成功标签替换内容最长一秒。反馈保持复制前的卡片高度,并会随卡片关闭一同清除。未传入 `copyText` 时,该原子组件维持只读且可选择文本的行为。
Workspace 浏览器选择复制载荷不让基础组件从渲染文本中推断Workspace 卡片传入完整目录路径,非空白 Session 卡片传入完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。浏览器的 locale 席位提供 `Copy``复制`,成功状态则使用 `Copied``已复制`
按下与激活仍是两份独立契约。卡片内发生指针按下时,卡片保持挂载,以便用户开始选择文本;文本选择完成后,若非折叠选区与卡片相交,就会阻止指针点击激活,而普通点击或按钮激活键会激活复制。锚点区域内发生指针按下时,卡片仍会立即消失;剪贴板拒绝写入时,卡片继续显示原内容,不会声称复制成功。
## 备选方案
**复制卡片渲染后的 `textContent`。** 这会把主要值与创建时间或运行状态拼接起来,使剪贴板载荷依赖表现形式和本地化结果。
**在两个 Workspace 卡片主体中分别实现剪贴板状态。** 两个消费方会重复实现宿主回退、键盘行为、计时器所有权和成功状态渲染,尽管激活表层由卡片持有。
**将通用中文 `copied` 标签从 `复制成功` 改为 `已复制`。** 这样会为了满足一种卡片交互而改变所有现有复制控件。卡片专用文案应由 Workspace 字典持有。
## 后果
两种非占位悬浮卡片都获得相同的点击与键盘操作能力,同时保留由消费方决定载荷的语义和本地化反馈。通用原子组件增加一条可选行为路径和一个一秒计时器;卡片关闭时会清除已复制状态,关闭或卸载后到达的完成结果会被忽略,写入被拒绝时绝不会报告成功。聚焦组件测试会固定指针选择文本的优先级、激活、失败、反馈尺寸与到期清除以及清理行为,真实浏览器中的 Workspace 场景则验证英文标签、反馈期间高度稳定和浏览器剪贴板。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md
2026-07-31-third-party-memory-mcp-examples.md: e82d65a3a5a60cafcc47df5a6873cf5768cd8b8f
2026-07-31-third-party-memory-mcp-examples.zh.md: ee0e9f2e1378f9787e09e32cc05dd13a3648254c

View File

@@ -0,0 +1,78 @@
# Agent Note: Third-party memory MCP examples
Status: implemented
English | [中文](2026-07-31-third-party-memory-mcp-examples.zh.md)
## Problem
A direct vendor integration made one provider's API, configuration, health behavior, and tool semantics part of DSH. That was too much product surface for a capability already expressible through MCP, and it would require repeating the same adaptation for every memory system. Users instead need a small, inspectable way to opt into one external memory server while preserving the generic MCP boundary.
The acceptance bar is stronger than "the socket connects": each reference must support a real DSH write in session A, recall from the provider in a fresh DSH session B, and use of the recalled value. At the same time, provider downloads, accounts, models, embeddings, storage initialization, and separate HTTP processes must remain upstream responsibilities.
## Decision
Ship three default-off Cordis overlay examples under `examples/mcp-memory`: Memorix, MCP Reference Memory, and Engram. Every file inserts exactly one `@deepseek-ai/dsh-mcp-client` row. None is referenced by the shipped composition, and the CLI declares the generic bridge only so an explicitly selected overlay can resolve it.
These third-party configurations are provided as interoperability examples only. Their inclusion does not imply endorsement, recommendation, partnership, or ongoing support by DeepSeek. There is no memory preset registry, vendor-specific DSH plugin, universal memory service, installation UI, migration layer, health checker, or reconnect controller. Another memory MCP server uses the same documented stdio or Streamable HTTP row.
## Responsibility boundary
| Concern | DSH | Upstream provider or user |
|---|---|---|
| Parse selected overlay | Yes | Select one file |
| Start stdio command and stop it on plugin disposal | Yes | Install the pinned executable |
| Connect to Streamable HTTP and discover tools | Yes | Run and supervise the HTTP service |
| Register tools as `mcp__<serverName>__<rawName>` | Yes | Define tool schemas and behavior |
| Account, auth, model, embedding, storage initialization | No | Yes |
| Vendor data migration, retry, crash recovery | No | Yes |
The generic stdio transport scrubs ambient credential-shaped and `DSH_*` variables while inheriting other ambient variables. Baseline examples add only required overrides; optional provider secrets must be added to `config.env` or configured in the provider's own files.
## Pins, storage, and identity
| Provider | Tested contract |
|---|---|
| Memorix | npm `1.3.0`, tag commit `500792cad3144142293bfbb20acb4841c9f7fcfa` |
| MCP Reference Memory | npm `2026.7.4`, package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` |
| Engram | tag `v1.20.0`, commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` |
Storage remains provider-owned. Memorix uses `~/.memorix/data` and Engram uses `~/.engram` by default. The Reference Memory example sets a stable `$HOME/.dsh-mcp-reference-memory.jsonl` path instead of writing into the installed npm package directory. Each provider's own environment variable can override these locations before DSH starts.
Project identity remains provider-owned: Memorix and Engram use the DSH working directory's Git project, with Engram optionally accepting `ENGRAM_PROJECT`.
## Model guidance
The examples do not patch `@deepseek-ai/dsh-system-prompt`: a config patch replaces a row's complete config and could erase an existing persona. The README instead offers one optional additive instruction:
> When the user asks you to remember something, call a memory write tool. When historical information may be relevant, search memory and use relevant results.
Provider tool descriptions remain authoritative.
## Validation contract
Remote CI never contacts third-party services or consumes secrets. The keyless suite parses all three overlay files, checks their generic bridge and secret boundary, replaces the upstream endpoint with the package-owned MCP fixture server, boots the real Cordis Loader, and proves tool discovery.
Before merge, manual evidence for every pinned provider must separately show:
1. DSH session A calls a write tool and receives success for a unique value.
2. Fresh DSH session B, under the same provider storage scope, calls search or recall and returns that value without session A's transcript.
3. Session B uses the recalled value in a subsequent answer.
"Fresh session" means a new DSH session in the same Host. No Host restart is required. The generic MCP client discovers asynchronously and has no automatic reconnect after a child or HTTP transport closes; validation waits for tools before the first turn and uses HMR or a Host restart only after a crash.
## Alternatives considered
**One DSH plugin per provider.** Rejected because it repeats auth, configuration, lifecycle, and tool wrappers that MCP already standardizes and expands ownership for every added provider.
**A memory-provider preset registry.** Rejected because a registry would make third-party versions and recommendations look like a supported DSH product surface. Copyable overlays keep ownership and drift visible.
**Run `npx` or `go run` inside the MCP row.** Rejected after probes showed first-run npm downloads can exceed the MCP initialization timeout and an interrupted `npx` cache can become unusable. DSH starts a server process; it is not the provider package manager. Pinned installation commands are explicit prerequisites.
**Inject the common instruction from the generic MCP client.** Rejected because the bridge serves non-memory MCP servers too, and generic prompt mutation would reintroduce provider semantics into shared runtime code.
## Consequences
Selecting a file gives the model the provider's complete discovered MCP tool surface, with schema/token cost determined by that provider. Removing `--config` removes the memory server. Users accept each upstream license, data policy, cloud cost, and operational model directly.
The earlier vendor-specific change is superseded by this generic path. Future provider drift is handled by updating and revalidating a small example pin rather than adding runtime branches to DSH.

View File

@@ -0,0 +1,78 @@
# Agent Note: 第三方记忆 MCP 示例
Status: implemented
[English](2026-07-31-third-party-memory-mcp-examples.md) | 中文
## 问题
直接集成某个提供方会使该提供方的 API、配置、健康状态行为和工具语义成为 DSH 的一部分。对于已经可以通过 MCP 表达的功能,这会让产品接口过于庞大,而且每接入一个记忆系统都需要重复同样的适配工作。用户需要的是一种精简、可检查的方式,在保留通用 MCP 边界的同时,选择启用一个外部记忆服务器。
验收标准不止于「套接字可以连接」:每份参考配置都必须支持 DSH 在会话 A 中实际写入,在新的 DSH 会话 B 中从提供方召回并使用召回的值。与此同时提供方下载、账户、模型、embedding、存储初始化和独立 HTTP 进程仍由上游负责。
## 决策
`examples/mcp-memory` 下交付三份默认关闭的 Cordis overlay 示例Memorix、MCP Reference Memory 和 Engram。每个文件只插入一个 `@deepseek-ai/dsh-mcp-client` 配置项。交付组合不会引用这些文件CLI命令行界面仅声明通用桥接器使用户显式选择 overlay 时可以解析它。
这些第三方配置仅作为互操作参考;收录不代表 DeepSeek 的认可、推荐、合作关系或持续支持承诺。系统没有记忆预设注册表、提供方专属 DSH 插件、通用记忆服务、安装 UI、迁移层、健康检查器或重连控制器。其他记忆 MCP 服务器可以使用同一份文档中的 stdio 或 Streamable HTTP 配置项。
## 职责边界
| 事项 | DSH | 上游提供方或用户 |
|---|---|---|
| 解析选中的 overlay | 是 | 选择一个文件 |
| 启动 stdio 命令,并在插件 dispose资源释放时将其停止 | 是 | 安装固定版本的可执行文件 |
| 连接 Streamable HTTP 并发现工具 | 是 | 运行并监管 HTTP 服务 |
| 以 `mcp__<serverName>__<rawName>` 注册工具 | 是 | 定义工具 schema 和行为 |
| 账户、认证、模型、embedding、存储初始化 | 否 | 是 |
| 提供方数据迁移、重试、崩溃恢复 | 否 | 是 |
通用 stdio 传输会清除环境中名称类似凭据的变量和 `DSH_*` 变量,同时继承其他环境变量。基线示例仅添加必需的覆盖项;可选的提供方密钥必须添加到 `config.env`,或配置在提供方自己的文件中。
## 版本固定、存储与身份
| 提供方 | 已测试契约 |
|---|---|
| Memorix | npm `1.3.0`tag commit `500792cad3144142293bfbb20acb4841c9f7fcfa` |
| MCP Reference Memory | npm `2026.7.4`package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` |
| Engram | tag `v1.20.0`commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` |
存储仍由提供方负责。Memorix 默认使用 `~/.memorix/data`Engram 默认使用 `~/.engram`。Reference Memory 示例设置稳定的 `$HOME/.dsh-mcp-reference-memory.jsonl` 路径,而不是写入已安装的 npm 包package目录。每个提供方自己的环境变量都可以在 DSH 启动前覆盖这些位置。
项目身份仍由提供方负责Memorix 和 Engram 使用 DSH 工作目录中的 Git 项目,其中 Engram 还可以选择接受 `ENGRAM_PROJECT`
## 模型指导
示例不会修改 `@deepseek-ai/dsh-system-prompt`:配置 patch 会替换某个配置项的完整配置,可能抹除已有 persona。README 改为提供一条可选的附加指令:
> 用户要求记住时调用写入工具;涉及历史信息时,主动检索并使用相关记忆。
提供方的工具描述仍然是权威定义。
## 验证契约
远程 CI 不会访问第三方服务或消耗密钥。无密钥套件解析全部三份 overlay 文件,检查其通用桥接器和密钥边界,将上游端点替换为包自带的 MCP fixture测试前置数据服务器通过真实 Cordis Loader 启动,并验证工具发现。
合并前,每个固定版本的提供方都必须分别提供以下人工证据:
1. DSH 会话 A 调用写入工具,为一个唯一值写入记忆,并收到成功结果。
2. 新的 DSH 会话 B 在相同的提供方存储范围下调用搜索或召回,不借助会话 A 的 transcript文本记录便可返回该值。
3. 会话 B 在后续回答中使用该召回值。
「新会话」是指同一个 Host 中新建的 DSH 会话,不需要重启 Host。通用 MCP 客户端以异步方式发现工具,子进程或 HTTP 传输关闭后不会自动重连;验证会在第一轮之前等待工具出现,并且只在崩溃后使用 HMR 或重启 Host。
## 考虑过的替代方案
**每个提供方使用一个 DSH 插件。** 不予采纳,因为这会重复 MCP 已经标准化的认证、配置、生命周期和工具包装层,并随着每增加一个提供方而扩大维护范围。
**记忆提供方预设注册表。** 不予采纳,因为注册表会让第三方版本和推荐看起来像受支持的 DSH 产品接口。可复制的 overlay 让所有权和版本偏移保持可见。
**在 MCP 配置项内运行 `npx` 或 `go run`。** 不予采纳,因为探测表明首次 npm 下载可能超过 MCP 初始化超时,而中断的 `npx` 缓存可能变得不可用。DSH 负责启动服务器进程,不是提供方的包管理器。固定版本的安装命令属于显式前置条件。
**由通用 MCP 客户端注入共用指令。** 不予采纳,因为该桥接器也服务于非记忆类 MCP 服务器,而且通用提示词变更会把提供方语义重新带入共享运行时代码。
## 后果
选择一个文件后,模型可以使用提供方发现到的完整 MCP 工具接口;工具 schema 和 token 成本由提供方决定。移除 `--config` 就会移除记忆服务器。用户直接接受各上游的许可证、数据政策、云服务费用和运维模式。
通用方案取代了早期针对特定提供方的改动。未来出现提供方版本偏移时,只需更新并重新验证一份小型示例的固定版本,不必向 DSH 添加运行时分支。

View File

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

View File

@@ -0,0 +1,32 @@
# Agent Note: Card tool rows collapse through one ToolRow
Status: implemented
English | [中文](2026-07-31-web-cards-toolrow.zh.md)
## Problem
The Web client grew five card render intents over successive PRs — terminal, diff, read, search, web — each landing as a keyed toolview registrant under `packages/client/ui-conversation/src/client/toolviews/`. They diverged in two ways the earlier PRs each acknowledged but deferred:
- **Chrome duplication.** `read-row`, `search-row`, `web-row`, and `file-mutation-row` each hand-drew the summary row (leading state slot, visually-hidden status, title, separator dot, path-link/summary) as their own `<div className={css.root}>` with a private `.module.css`, instead of composing the shared `ToolRow`. `read-row` carried a `jscpd:ignore` marker naming the duplication and pointing at "a separate change tracked for all rows at once" — this change.
- **Resident vs. collapsed.** Those four rows kept their card (`ReadBlock`/`SearchBlock`/`WebBlock`/`DiffBlock`) resident below the summary — always expanded — while the terminal card (via `GenericToolCard`/`BashRow`) and every text row started collapsed behind ToolRow's whole-row expand. A conversation with several read/search/web/edit calls became a wall of always-open cards, defeating the summary-surface purpose of the message flow.
## Decision
`ToolRow` owns every card kind, and every keyed card row composes it. ToolRow already took `terminal` and `diff` card material; it now also takes `read`, `search`, and `web`, rendering whichever is present in its collapsed-by-default expanded body through the matching primitive (capped at the chat `CHAT_*` bounds). A call carries at most one card kind, so the props are mutually exclusive and the body picks the first present.
The four keyed rows — `ReadRow`, `SearchRow`, `WebRow`, `FileMutationRow` — drop their hand-drawn chrome and private CSS and become thin `ToolRow` compositions, exactly like `AskQuestionRow`: derive the card model, pass it as the matching ToolRow prop, forward `filePath`/`onOpenFile` for the file tools and `output`/`errorSummary` for the cardless failure paths. Each row is now `ToolRowProps & PropsLocale<'conversation'>` and registers with `locale: NS`, because ToolRow needs the conversation `t` for its terminal/code body copy. `GenericToolCard` (the render-site fallback) does the same for read/search/web, so a card-declaring tool without its own keyed row collapses identically.
The `DetailsPanel` Output section is unchanged: the panel is the single-call reading surface, so it renders each card resident at the primitive's full height, and a capped search keeps its recovery footer there.
## Consequences
- One expand interaction across all tool rows: collapsed one-line summary, whole row toggles the card. The card is not in the DOM until expanded (`DisclosureRow` renders `children` only when open), so tests assert absent-then-present around a `[data-expandable]` click.
- Deleted: `read-row.module.css`, `search-row.module.css`, `web-row.module.css`, `file-mutation-row.module.css`, `GenericToolCard.module.css`. The rows carry no CSS of their own; ToolRow's module owns the chrome and the card-body indentation.
- The cardless failure paths (an errored mutation, an errored/nested/legacy search) no longer draw their own `.failure`/recovery `<div>`; they ride ToolRow's `output` (Output section) and `errorSummary` (collapsed summary first line), which already flatten the result text with the `error.name: error.code` fallback.
- `bash-sample` keeps its own local expand chrome deliberately (the third-party-posture exemplar that never imports the chat domain); it was already collapsed, so its behavior is unchanged.
## Alternatives considered
- **Keep the rows resident, only unify chrome.** Rejected: the user's requirement is default-collapsed, and resident cards are what made the flow unscannable.
- **A shared `CardRow` wrapper between the rows and ToolRow.** Rejected: ToolRow already is that wrapper once it takes every card kind; a second layer would be the premature extraction the package rules warn against.

View File

@@ -0,0 +1,32 @@
# Agent Note卡片工具行通过同一个 ToolRow 折叠
Status: implemented
[English](2026-07-31-web-cards-toolrow.md) | 中文
## 问题
Web 客户端在连续几个 PR 里长出五种卡片渲染意图——terminal、diff、read、search、web每一种都作为一个键控 toolview 注册项落在 `packages/client/ui-conversation/src/client/toolviews/` 下。它们在两处出现分歧,之前每个 PR 都承认却推迟处理:
- **Chrome 重复。** `read-row``search-row``web-row``file-mutation-row` 各自把摘要行(行首状态槽、视觉隐藏状态、标题、分隔点、路径链接/摘要)手绘成自己的 `<div className={css.root}>`,配一份私有 `.module.css`,而不是组合共享的 `ToolRow``read-row` 带着一个 `jscpd:ignore` 标记,点名这处重复并指向"一处针对所有行一次性处理的独立改动"——就是本次改动。
- **常驻 vs 折叠。** 那四个行把卡片(`ReadBlock`/`SearchBlock`/`WebBlock`/`DiffBlock`)常驻在摘要下方——始终展开——而终端卡片(经 `GenericToolCard`/`BashRow`)与每个文本行都从折叠状态起步,藏在 ToolRow 的整行展开之后。一个有多个 read/search/web/edit 调用的对话就成了一堵始终打开的卡片墙,违背了消息流作为摘要面的目的。
## 决策
`ToolRow` 拥有每一种卡片而每个键控卡片行都组合它。ToolRow 原本就接收 `terminal``diff` 卡片材料;现在还接收 `read``search``web`,在其默认折叠的展开 body 里用对应原语渲染当前存在的那一种(按 chat 的 `CHAT_*` 上限截断)。一次调用最多携带一种卡片,因此这些 prop 互斥body 取第一个存在的。
四个键控行——`ReadRow``SearchRow``WebRow``FileMutationRow`——丢掉手绘 chrome 与私有 CSS成为薄薄的 `ToolRow` 组合,与 `AskQuestionRow` 完全一样:推导卡片模型,作为对应的 ToolRow prop 传入,为文件工具转发 `filePath`/`onOpenFile`,为无卡片的失败路径转发 `output`/`errorSummary`。每个行现在是 `ToolRowProps & PropsLocale<'conversation'>` 并以 `locale: NS` 注册,因为 ToolRow 需要对话的 `t` 来渲染其终端/代码 body 文案。`GenericToolCard`(渲染点兜底)对 read/search/web 做同样的事,所以一个没有自己键控行的卡片声明工具也以同样方式折叠。
`DetailsPanel` 的 Output 区不变:面板是单次调用的阅读面,因此它以原语的完整高度常驻渲染每张卡片,被截断的搜索也把恢复脚注留在那里。
## 后果
- 所有工具行共享一套展开交互:折叠时是单行摘要,整行切换卡片。卡片在展开前不在 DOM 里(`DisclosureRow` 只在打开时渲染 `children`),因此测试围绕一次 `[data-expandable]` 点击断言"先无后有"。
- 已删除:`read-row.module.css``search-row.module.css``web-row.module.css``file-mutation-row.module.css``GenericToolCard.module.css`。这些行不再带自己的 CSSToolRow 的 module 拥有 chrome 与卡片 body 的缩进。
- 无卡片的失败路径(出错的改动,出错/嵌套/旧日志的搜索)不再画自己的 `.failure`/恢复 `<div>`;它们改走 ToolRow 的 `output`Output 区)与 `errorSummary`(折叠摘要首行),后者已经用 `error.name: error.code` 兜底压平结果文本。
- `bash-sample` 有意保留自己本地的展开 chrome第三方姿态的范例从不引入 chat 域);它本来就是折叠的,因此行为不变。
## 考虑过的替代方案
- **保持行常驻,只统一 chrome。** 否决:用户的要求是默认折叠,而常驻卡片正是让流不可扫读的原因。
- **在行与 ToolRow 之间加一层共享的 `CardRow` 包装。** 否决ToolRow 一旦接收每一种卡片,它本身就是那层包装;再加一层就是 package 规则警告的过早抽取。

View File

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

View File

@@ -0,0 +1,35 @@
# Agent Note: Default Web search in the Web/headless composition
Status: implemented
English | [中文](2026-07-31-web-default-search.zh.md)
## Problem
The harness had a complete Web capability family—provider registry, DeepSeek/Exa/Perplexity search providers, local fetch, stable model tools, and structured result presentation—but the shipped `dsh web` composition mounted none of it. The model could not discover current information unless a deployment supplied a custom overlay. Merely mounting the existing DeepSeek provider would not complete the WebUI path: the Models page stores `DEEPSEEK_API_KEY` through `ctx.credentials`, while the search provider froze only the process environment at plugin load, so a key entered or rotated in the running UI would not reach search.
## Decision
`apps/cli/config/web.cordis.yml` explicitly mounts `dsh-web` with `searchProvider: deepseek-official`, `dsh-web-search-deepseek`, and `dsh-tool-web` with `fetch: false`. It does not mount `dsh-web-fetch-local` or select a fetch provider. The shared overlay makes only `web_search` a default for browser and headless sessions; the TUI composition remains unchanged. The explicit search provider id keeps selection independent of registration order and leaves personal or `--config` overlays able to replace or disable the rows.
DeepSeek search uses the same `DEEPSEEK_API_KEY` credential reference as the official conversation adapter. The provider resolves that reference inside every search through the optional `ctx.credentials` service; only a composition without the seam falls back to the launching process environment, and a non-empty literal `apiKey` remains the programmatic last resort. A stored or rotated Web Models key therefore reaches the next search without restarting or retaining the value on the provider. Because `WebSearchProvider.available()` is synchronous, it treats an installed resolver as locally usable and missing dynamic credentials fail the operation with the provider-specific `WEB_PROVIDER_CREDENTIAL_MISSING` code while the stable tool schema stays registered.
Search keeps its endpoint distinct from chat completions: `DEEPSEEK_SEARCH_BASE_URL` overrides the Anthropic-compatible base, while `DEEPSEEK_BASE_URL` continues to configure conversation requests. Each `web_search` performs an auxiliary DeepSeek Messages call with the native search server tool. Immediately before dispatch, the provider appends a log-only `web/deepseek-search-llm-request` event to the initiating Agent session with the resolved endpoint, API version, and exact secret-free JSON body. Credential preflight remains provider-local and races caller cancellation; neither concern expands the generic Web or credentials seams.
The default mount does not create a Web-specific permission policy. `web_search` executes outside the bash/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. It does not mount `web_fetch` or a local fetch provider, so the default does not grant model-selected arbitrary URL retrieval. The shipped deployment already defaults to `danger-full-access`; a future restricted-network product stance must add a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls.
## Alternatives considered
**Mount only `dsh-tool-web`.** Rejected because stable schemas without registered providers would make every default call fail; enablement and backend availability are deliberately separate, but a shipped default must supply its intended implementations.
**Read `$DSH_HOME/.env` from `cordis.yml` or hoist it into `process.env`.** Rejected because the credential provider owns that document, environment values are read-only overrides, and hoisting would make stored keys unrotatable while bypassing the audited secret boundary.
**Freeze `process.env.DEEPSEEK_API_KEY` at provider load.** Rejected because the Web Models page writes through `ctx.credentials`; the product's documented first-run path must make the next operation work without a restart.
**Mount Web tools in `base.cordis.yml`.** Rejected because that would also change the TUI deployment. The browser and headless entries already share `web.cordis.yml`; they gain the capability together while TUI remains an explicit later decision.
**Enable search and fetch together.** Rejected because default `web_fetch` would allow model-selected anonymous outbound HTTP(S) retrieval to arbitrary URLs. Search covers discovery; deployments that accept broader retrieval can opt into `dsh-web-fetch-local` and set `dsh-tool-web`'s `fetch` option to `true` in their overlay.
## Consequences
Web/headless model requests carry only the `web_search` schema and search-only prompt guidance in native mode; Code Mode exposes the same search capability beneath `run_code`. The prompt tells the model to use returned snippets and never advertises the disabled `web_fetch` tool. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The default offers search-result snippets and source metadata but no arbitrary page retrieval; deployments that need full-page fetch must opt in. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. The real-composition smoke test pins the absence of `web_fetch`; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility.

View File

@@ -0,0 +1,35 @@
# Agent Note: Web无头组合中的默认 Web 搜索
Status: implemented
[English](2026-07-31-web-default-search.md) | 中文
## 问题
该 harness 已具备完整的 Web 能力体系提供方注册表、DeepSeek、Exa 和 Perplexity 搜索提供方、本地抓取、稳定的面向模型工具,以及结构化结果呈现,但已交付的 `dsh web` 组合没有挂载其中任何一项。除非部署提供自定义覆盖层,否则模型无法发现最新信息。仅挂载现有 DeepSeek 提供方仍无法打通 WebUI 链路Models 页面通过 `ctx.credentials` 存储 `DEEPSEEK_API_KEY`,而搜索提供方只会在插件加载时固定读取进程环境,因此在运行中的 UI 输入或轮换的密钥无法用于搜索。
## 决策
`apps/cli/config/web.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official`,同时挂载 `dsh-web-search-deepseek`,并以 `fetch: false` 挂载 `dsh-tool-web`。它不挂载 `dsh-web-fetch-local`,也不选择抓取提供方。共享覆盖层只将 `web_search` 设为浏览器与无头会话的默认工具TUI 组合保持不变。显式搜索提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。
DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据引用。提供方在每次搜索内部通过可选的 `ctx.credentials` 服务解析该引用;只有未挂载该 seam 的组合才会回退到启动进程的环境变量,非空的 `apiKey` 字面值仍作为程序化配置的最后兜底。因此,由 Web 的 Models 页存储或轮换的密钥无需重启即可用于下一次搜索,提供方也无需保留该值。由于 `WebSearchProvider.available()` 是同步方法,它会将已安装解析器视为本地可用;若动态凭据缺失,操作会以提供方专属错误码 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,而稳定的工具 schema 仍保持注册。
搜索端点与 chat completions 保持独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基址,`DEEPSEEK_BASE_URL` 则继续配置会话请求。每次 `web_search` 都会发起一次辅助 DeepSeek Messages 调用,并携带原生搜索服务器工具。发出请求前一刻,提供方会向发起请求的 agent智能体会话追加仅用于日志的 LLM大语言模型请求事件 `web/deepseek-search-llm-request`其中包含已解析端点、API 版本,以及不含密钥的精确 JSON 请求体。凭据预检仍留在提供方内部,并与调用方取消存在竞态;这两项关注点都不会扩展通用 Web seam 或凭据 seam。
默认挂载不会创建 Web 专用权限策略。`web_search` 在 bash文件系统沙箱及审批预设之外执行并遵循 `dsh-tool-web` 的现有契约。组合不挂载 `web_fetch` 或本地抓取提供方,因此默认配置不会允许模型自行选择任意 URL 进行抓取。已交付部署的默认值本就是 `danger-full-access`;未来如果产品采取受限网络策略,必须添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。
## 考虑过的替代方案
**仅挂载 `dsh-tool-web`。** 不予采纳:稳定的 schema 如果没有已注册提供方,每次默认调用都会失败。启用状态与后端可用性刻意分离,但已交付的默认配置必须提供其预期实现。
**从 `cordis.yml` 读取 `$DSH_HOME/.env`,或将其提升到 `process.env`。** 不予采纳:凭据提供方拥有该文件,环境变量值是只读覆盖;提升后存储的密钥将无法轮换,还会绕过经审计的密钥边界。
**在提供方加载时固定读取 `process.env.DEEPSEEK_API_KEY`。** 不予采纳Web Models 页面通过 `ctx.credentials` 写入密钥;产品文档规定的首次运行路径必须保证下一次操作无需重启即可生效。
**在 `base.cordis.yml` 中挂载 Web 工具。** 不予采纳:这也会改变 TUI 部署。浏览器与无头入口已经共享 `web.cordis.yml`;两者会一同获得该能力,是否为 TUI 启用则仍留作后续显式决策。
**同时启用搜索和抓取。** 不予采纳:默认启用 `web_fetch` 会允许模型自行选择任意 URL执行匿名出站 HTTP(S) 抓取。搜索负责发现信息;接受更广泛抓取范围的部署可以在覆盖层中选择启用 `dsh-web-fetch-local`,并将 `dsh-tool-web``fetch` 选项设为 `true`
## 后果
Web无头模型请求在原生模式下只会携带 `web_search` schema以及仅用于搜索的提示词指引Code Mode 通过 `run_code` 公开相同的搜索能力。该提示词要求模型使用返回的 snippet且绝不会向模型提及已禁用的 `web_fetch` 工具。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。默认配置会提供搜索结果 snippet 与来源元数据但不支持任意页面抓取需要抓取完整页面的部署必须自行选择启用抓取。Web 快照通道会启动已交付配置树,使用本地 Messages fixture测试前置数据经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。真实组合冒烟测试固定了不提供 `web_fetch` 这一事实;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-20-gui-testing-system.md
2026-07-20-gui-testing-system.md: 8c6dafb18fc207fc4eac780ba18e108267bc28b1
2026-07-20-gui-testing-system.zh.md: 9a0de4bfa8fa2f8de55beef53bedde51649c5d9c
2026-07-20-gui-testing-system.md: 4a1600bbef7ef795677a446228fcc279a4b53f39
2026-07-20-gui-testing-system.zh.md: 2aa5d7f66783c69964cabf7eb18a018b54528a33

View File

@@ -22,7 +22,7 @@ Cut along the architecture's natural test seams into three tiers, bottom-up:
| 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` |
| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md), [required CI gate](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/transcript-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
- **Host and client source** are under the repo-wide per-file 100% coverage gate except the narrow browser-grade exclusions annotated in `vitest.config.ts`; component suites use per-file jsdom pragmas and Testing Library without changing Node suites.
- **App-owned semantic snapshots** read built client bundles, execute them through the real loader, and drive only deterministic fixture hooks. They own stable visible state such as sidebar labels, breadcrumbs, and `document.title`, not CSS pixels or lower-layer state-machine details.

View File

@@ -22,7 +22,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境
| 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` |
| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)、[必需 CI 门禁](../testing/2026-07-30-web-browser-snapshot-ci-gate.md) | `apps/web/tests/*.snapshot.ts``apps/web/tests/smoke-{fixture,real}.e2e.ts``apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` |
层间纪律:**下层各测各的,上层不重测下层**应用语义快照只固定组装后插件边界上的用户可见投影Playwright 冒烟测试负责验证浏览器与承载层是否存活wire 语义归 1 层,数据语义归 2 层。纯函数层lineage/partial/notifier/fold-adapter随 2 层同包 tests/ 零假体直测。
层间纪律:**下层各测各的,上层不重测下层**应用语义快照只固定组装后插件边界上的用户可见投影Playwright 冒烟测试负责验证浏览器与承载层是否存活wire 语义归 1 层,数据语义归 2 层。纯函数层lineage/partial/notifier/transcript-adapter随 2 层同包 tests/ 零假体直测。
- **host 与 client 源码**均纳入全仓 per-file 100% 覆盖率门禁,仅排除 `vitest.config.ts` 中带注释的少量浏览器级例外;组件套件通过逐文件 jsdom pragma 和 Testing Library 运行,不会改变 Node 套件。
- **归应用所有的语义快照**读取已构建的 client bundle通过真实 loader 执行它们,并且只驱动确定性的 fixture 钩子。它们负责固定侧边栏标签、面包屑和 `document.title` 等稳定可见状态,而不固定 CSS 像素或下层状态机细节。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md
2026-07-30-generated-third-party-notices.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043
2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561

View File

@@ -0,0 +1,53 @@
# Agent Note: Generated third-party notices
Status: implemented
English | [中文](2026-07-30-generated-third-party-notices.zh.md)
## Problem
Open-sourcing this repository requires disclosing the third-party software it depends on, with each project's license. The disclosure has to be complete, has to stay true as dependencies change, and has to say something a reader can act on — which of these packages end up on a user's machine, and which only build and test the repository.
A hand-written inventory answers none of those durably. Roughly a hundred rows of names and license strings derived from manifests drift silently the moment a package is added, removed, or relicensed, and nothing would notice.
## Decision
[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, and `pnpm-workspace.yaml`. The root README pair links the file from its License section.
**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check.
One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions was tried and does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case.
The file discloses **direct** dependencies only. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy.
**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the `bin/dsh` launcher execs through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly).
The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. `scripts/install.sh` installs the repository itself, so a user's `cordis.yml` can mount any plugin package; `@modelcontextprotocol/sdk` and the OpenTelemetry packages reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice.
The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed.
## Testing
The same spec that asserts freshness pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts. It also pins the parsers against the shapes that would otherwise drop a package without a word: a `vendor/README.md` table that stops covering a vendored directory, a requirement array holding extras (`"httpx[http2]"`), a requirement with no version at all, an author-named `[dependency-groups]` table, and a workspace member area absent from any hardcoded list. Each of those is a silent-omission path, which is the failure mode a disclosure file cannot afford.
## Alternatives considered
**Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases.
**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost.
**Enumerate the full transitive closure.** The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the `pnpm licenses list` renderer instead.
**Tier by manifest section (`dependencies` vs `devDependencies`).** Mechanically simple and wrong on real data in both directions, as the tiering paragraph above records.
**Tier by reachability from the shipped assemblies only** (`apps/*` plus `python/sdk-runtime`). This produces a tighter runtime tier, but classifies the MCP client and the OpenTelemetry exporter as development-only even though a user running the installed repository can mount them. It understates the disclosure, which is the wrong direction to err for a legal notice.
**Emit the notices as a bilingual pair.** Every other root document is paired, but the file is a table of upstream package names, SPDX identifiers, and URLs; the translatable surface is a handful of section blurbs. `scripts/translation-pairing.ts` scopes discovery to `README*`, `.agents/notes/**`, `docs/**`, and `python/**`, so a root non-README file is outside the bilingual corpus by construction, and the README pair carries the bilingual entry points into it.
## Consequences
A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command.
The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy.
The tiering rule is a policy encoded in one constant. Adding a workspace area that never ships — a second test-infrastructure tier, another site — requires extending `DEV_ONLY_AREAS`, or its dependencies will be disclosed as runtime.

View File

@@ -0,0 +1,53 @@
# Agent Note: Generated third-party notices
Status: implemented
[English](2026-07-30-generated-third-party-notices.md) | 中文
## Problem
本仓库开源需要披露所依赖的第三方软件及各自的许可证。这份披露必须完整,必须随依赖变化保持为真,还必须给出读者用得上的信息:哪些包最终会进到用户机器上,哪些只用于构建和测试。
手写清单无法长期满足其中任何一条。约一百行从各清单文件推导出来的包名与许可证标识,只要有依赖新增、移除或换用许可证就会悄悄失真,而没有任何检查会察觉。
## Decision
[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区清单、`vendor/README.md``pyproject.toml``pnpm-workspace.yaml` 生成。根 README 双语两侧都从「许可证」一节链到该文件。
**新鲜度靠维护而非拦截。** 只要暂存了生成器的任一输入——任何清单文件、工作区声明、根锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成并一并入库,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。
有一处触发缺口是接受而非绕过的lefthook 只检视磁盘上存在的文件,因此**删除**清单文件不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法试过不成立——无论怎么给列表lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。
文件只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml``pnpm licenses list` 可渲染Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。
**分层依据是声明方所在区域,而非清单字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一清单——即根清单、`packages/support/``packages/client/test-runtime/``website/``examples/``native/` 之外——在 `dependencies``optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而 `bin/dsh` 启动器 exec 经过的 `tsx`,根本没有任何清单把它声明为运行时依赖,只能由生成器显式标记。
运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` 可以挂载任何插件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。
清单集合由两个 `pnpm-workspace.yaml`——根工作区与嵌套的 Landlock 工作区——各自声明的 `packages:` 成员派生,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自已安装的 pnpm store根 store 与 Landlock 工作区的 store 都会查;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml``patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui``node-pty`,补丁文件本身就是改动的完整记录。
## Testing
断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它还把各解析器钉在那些原本会让某个包无声消失的形态上:不再覆盖全部收编目录的 `vendor/README.md` 表、含 extras 的依赖数组(`"httpx[http2]"`)、完全不带版本的依赖、作者自取名字的 `[dependency-groups]` 表,以及任何硬编码列表都不含的工作区成员区域。这些都是静默漏报路径——正是披露文件最担不起的失败方式。
## Alternatives considered
**保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。
**用专门的 `doc-sync` 门禁校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位;更糟的是,它唯一的失败方式,就是在别人推完一个无关的依赖升级几分钟后,通知对方回去重跑一次生成器。改为提交时重新生成消除了这次打断,而把断言放进测试 lane 本就会跑的 spec 里,则以零额外 CI 成本保住了这项保证。
**列出完整传递闭包。** 闭包有数千个包,锁文件里已带精确版本,铺开只会淹没读者真正要评估的直接依赖。文件转而指向锁文件与 `pnpm licenses list`
**按清单字段分层(`dependencies` 与 `devDependencies`)。** 机械上最省事,但在真实数据上两个方向都会出错,理由见上文分层段落。
**只按已交付装配的可达性分层**`apps/*``python/sdk-runtime`)。这样得到的运行时层更紧凑,但会把 MCP 客户端与 OpenTelemetry 导出器判为仅开发用途——而运行已安装仓库的用户完全可以挂载它们。这会低估披露,对法务通告来说错在了更危险的一侧。
**把披露文件做成双语对。** 其他根文档都是成对的但这份文件是上游包名、SPDX 标识与网址构成的表格,可翻译的只有寥寥几段章节导语。`scripts/translation-pairing.ts` 的发现范围限定在 `README*``.agents/notes/**``docs/**``python/**`,根目录下的非 README 文件在构造上就不属于双语语料;双语入口由 README 对承担。
## Consequences
此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及清单文件的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。
生成器需要已安装的工作树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。
分层规则是编码在一个常量里的政策。若新增了不参与交付的工作区区域——第二层测试基础设施、另一个站点——就要同步扩展 `DEV_ONLY_AREAS`,否则其依赖会被当作运行时依赖披露出去。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-capability-neutral-sandbox-policy-context.md
2026-07-31-capability-neutral-sandbox-policy-context.md: d45c6c652fb7a061c2f92b7e03c41cce39da2a30
2026-07-31-capability-neutral-sandbox-policy-context.zh.md: 9a1bb7a13d934826dddf89b409b25ba892a31ea3

View File

@@ -0,0 +1,35 @@
# Agent Note: Capability-neutral sandbox policy context
Status: implemented
English | [中文](2026-07-31-capability-neutral-sandbox-policy-context.zh.md)
## Problem
The current-policy context originally mirrored runtime composition through separate enforced-family and escalatable-family registries. Six backend, tool, and example call sites contributed `filesystem`, `bash`, or `terminal`; the policy service retained token sets for independent disposal, intersected and ordered the two registries, invalidated prompt assemblies on every lifecycle change, and tested every family combination.
That inventory was neither needed to state the file policy nor authoritative for model-visible capability. A backend contribution could name a family whose model-facing tool was absent or hidden by request scope, while tool schemas already told the model which exact operations were available. The registries therefore widened the public service and lifecycle contract to maintain an approximate English sentence.
## Decision
`dsh-sandbox-policy` contributes one capability-neutral `sandbox:policy` context for every agent session. It derives the text only from `resolve({ session })`; there is no backend or tool family registration API, contribution map, ordering rule, or registration-driven prompt invalidation.
The text conditions capability claims on available operations that the DSH file sandbox enforces. Under `read-only`, it states that such operations cannot modify files in the standing mode and tells the model to try an available tool normally, then follow any denial and escalation guidance that tool returns. Under `workspace-write`, it states the canonical session workspace and the qualified temporary-area allowance. Under `danger-full-access`, it states that the DSH file sandbox does not restrict file modifications by available operations.
Tool schemas remain the authority for which operations are available. Tool results remain the authority for operation-specific denials and approved wider retries. Filesystem, one-shot bash, and terminal implementations continue to resolve and enforce the same per-call policy; only the redundant model-facing capability inventory is removed.
This decision partially supersedes the family-registration and composition-conditioned wording in [the current sandbox policy context decision](../feature/2026-07-30-current-sandbox-policy-context.md). That note remains the owner of cache-safe context delivery, durable snapshot materialization, wording evidence, and the separation between guidance and enforcement.
## Alternatives considered
**Keep the registries but reduce their tests.** Rejected because the public methods, retained lifecycle state, six contribution sites, and approximate capability claim would remain. The combinatorial tests reflected the design cost; they did not create it.
**Derive an exact inventory from the tool registry.** Rejected because current policy needs only a truthful conditional statement, while exact availability already appears in the assembled tool schemas and can vary by request scope. Mapping each schema back to an enforcement backend would introduce another derived relation with no current consumer.
**Omit policy context unless a backend advertises enforcement.** Rejected because it recreates the registration problem and makes policy visibility depend on optional contributors. The conditional wording remains truthful even when no applicable operation is available.
## Consequences
The policy service has one owner-derived context path instead of two public registries and their disposal lifecycle. Capability additions and removals no longer churn the runtime-context snapshot, while mode and workspace changes still do. Exact mode wording, canonical roots, switching, resume, service disposal, and no-agent assembly remain covered; family-combination and contribution-lifecycle tests disappear with the behavior they protected.
The model no longer receives a prose list of sandboxed capability families. It receives exact tool schemas plus one standing file-policy statement. If a future product needs a separate capability inventory, it must be derived from the authoritative per-request assembly rather than reconstructed through backend registration side channels.

View File

@@ -0,0 +1,35 @@
# Agent Note: 不依赖具体能力的沙箱策略上下文
Status: implemented
[English](2026-07-31-capability-neutral-sandbox-policy-context.md) | 中文
## 问题
当前策略上下文最初通过受强制执行家族与可升权家族两个独立注册表来映射运行时组合。后端、工具与示例中的六个调用点会贡献 `filesystem``bash``terminal`;策略服务保留 token 集合,以便独立释放各项注册,对两个注册表的内容求交集并排序,在每次生命周期变化时使提示词组装失效,并且需要测试覆盖所有家族组合。
这份清单既不是说明文件策略所必需的,也不能作为模型可见能力的权威依据。后端贡献可能声明某个家族,而该家族面向模型的工具并不存在,或被请求作用域隐藏;工具 schema 已经向模型准确说明哪些操作可用。因此,为了维护一句近似的英文说明,注册表扩大了公开服务与生命周期契约。
## 决策
`dsh-sandbox-policy` 为每个 agent智能体会话贡献一项不依赖具体能力的 `sandbox:policy` 上下文。上下文文本只根据 `resolve({ session })` 派生;不存在后端或工具家族注册 API、贡献映射、排序规则也不会因注册变化而使提示词失效。
该文本将能力声明限定于 DSH 文件沙箱所强制执行的可用操作。在 `read-only` 下,它说明这类操作在常驻模式下无法修改文件,并指示模型正常尝试可用工具,随后遵循该工具返回的任何拒绝与升权引导。在 `workspace-write` 下,它说明规范化的会话工作区,以及带有适用条件的临时区域写入许可。在 `danger-full-access` 下,它说明 DSH 文件沙箱不会限制可用操作修改文件。
哪些操作可用仍以工具 schema 为准。针对具体操作的拒绝以及获批后的更宽松模式重试,仍以工具结果为准。文件系统、一次性 bash 与终端实现继续解析并强制执行相同的逐调用策略;移除的只有面向模型的冗余能力清单。
本决策取代了[当前沙箱策略上下文决策](../feature/2026-07-30-current-sandbox-policy-context.md)中关于家族注册与按组合条件化措辞的部分内容。缓存安全的上下文交付、持久快照具体化、措辞证据,以及引导与强制执行之间的边界,仍以该 Agent Note 为归属文档。
## 曾考虑的替代方案
**保留注册表,但减少对应测试。** 不予采用,因为公开方法、保留的生命周期状态、六个贡献点和近似的能力声明仍会存在。组合测试只是体现了这一设计成本,并非成本的成因。
**从工具注册表派生确切清单。** 不予采用,因为当前策略只需要一句符合事实的条件性声明,而确切的可用情况已经体现在组装后的工具 schema 中,并且可能随请求作用域变化。若再把每个 schema 映射回其强制执行后端,就会引入另一项派生关系,而当前没有消费方需要它。
**仅在后端声明会强制执行时提供策略上下文。** 不予采用,因为这会重现注册问题,并使策略是否可见取决于可选贡献方。即使没有可适用的操作,这段带条件的措辞仍然符合事实。
## 后果
策略服务只有一条由归属方直接派生的上下文路径,无需维护两个公开注册表及其释放生命周期。增加或移除能力不再导致运行时上下文快照反复变化,模式和工作区变化仍会触发快照更新。聚焦测试仍覆盖各模式的确切措辞、规范化根目录、模式切换、恢复、服务释放与无 agent 组装;家族组合与贡献生命周期测试则随其所保护的行为一同删除。
模型不再收到以自然语言列出的沙箱能力家族清单,而是收到确切的工具 schema 与一项常驻文件策略声明。未来若产品需要独立的能力清单,该清单必须从权威的逐请求组装结果中派生,不能通过后端注册这种旁路机制重建。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md
2026-07-31-drop-user-message-edit-stub.md: 5a34be1dd285f4aced9e6cfb2e324b20ec734bed
2026-07-31-drop-user-message-edit-stub.zh.md: 768cb4618b4a85f61e6348f8c6fb6a93d686e7d3

View File

@@ -0,0 +1,27 @@
# Agent Note: Drop the user-message edit stub
Status: implemented
English | [中文](2026-07-31-drop-user-message-edit-stub.zh.md)
## Problem
The user bubble's IconActions row carried an edit button beside copy and branch. Nothing backed it: the control had no click handler, no client mutation, and no host operation for resending an edited message. A user who found it saw an affordance the product cannot honor.
## Decision
`MessageIconActions` renders clock / copy / branch only, and its `edit` prop is gone with the button; `MessageItem` no longer passes it. The user bubble and the assistant chrome now differ only by clock side. The package README records the missing capability under Known Limitations, and the web message-actions golden pins the row without the control.
The common locale keeps its generic `edit` term, which is shared vocabulary rather than this component's copy.
Reintroduce the control together with the capability: a client mutation that edits a settled user message and the host behavior that decides what the edited message does to the turn that already consumed it.
## Alternatives considered
**Disable the button with a tooltip.** A visible-but-dead control still advertises editing and costs the same explaining; removal is the honest state.
**Wire it to the queue editor.** The queue edits a message that has not been sent. A settled user message is already in the transcript and in the model's context, so reusing that editor would silently mean something else.
## Consequences
Web offers no way to correct a sent message; branching from the message is the nearest available gesture. Reintroduction is a UI-only change once the mutation exists, since the row composes its actions from props.

View File

@@ -0,0 +1,27 @@
# Agent Note: 移除 user 消息的编辑存根
Status: implemented
[English](2026-07-31-drop-user-message-edit-stub.md) | 中文
## 问题
user 气泡的 IconActions 行在复制和分支旁边还有一个编辑按钮,但其背后什么都没有:该控件没有点击处理、没有 client 侧变更,也没有 host 侧重新发送已编辑消息的操作。用户找到它时,看到的是一个产品无法兑现的可供性。
## 决策
`MessageIconActions` 只渲染时钟/复制/分支,其 `edit` prop 随按钮一并删除;`MessageItem` 不再传入该 prop。现在 user 气泡与 assistant chrome 只在时钟位置上不同。包 README 在 Known Limitations 中记录这项缺失的能力web 的 message-actions 金样固定了不含该控件的动作行。
公共 locale 保留通用的 `edit` 词条:它是共享词汇,而非本组件的文案。
重新引入该控件时要与能力一起落地:既需要编辑已定稿 user 消息的 client 变更,也需要 host 侧决定这条编辑后的消息对已经消费过它的轮次意味着什么。
## 曾考虑的替代方案
**把按钮置灰并加提示。** 一个可见但无效的控件仍在宣告可以编辑,解释成本相同;直接移除才是诚实的状态。
**接到队列编辑器上。** 队列编辑的是尚未发送的消息。已定稿的 user 消息已经进入转录和模型上下文,复用该编辑器会让同一个动作悄悄变成另一件事。
## 后果
Web 没有任何途径修正已发送的消息;从该消息分支是最接近的现有手势。由于动作行的内容完全由 props 组合而来,能力就绪后重新引入只是一次纯 UI 改动。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md
2026-07-31-one-route-to-add-a-workspace.md: 0d3f4096b11ec2d0bd1339268bfb8567380d3e1a
2026-07-31-one-route-to-add-a-workspace.zh.md: b9b25b15680cb54ae980e217e6218a91cd1e366a

View File

@@ -0,0 +1,57 @@
# Agent Note: One route to add a Workspace
Status: implemented
English | [中文](2026-07-31-one-route-to-add-a-workspace.zh.md)
## Problem
Both Workspace surfaces — the sidebar region header's `+` and the conversation hero's chip — offered two ways to get a Workspace: **Open local folder…**, which raised the composed directory flow, and **Create a new workspace**, which took a name and created `<workspaceRoot>/<name>`. The two overlapped: the browse occupant carries its own **New folder** affordance, so picking a directory already covered creating one. Two entries meant two vocabularies for one outcome, a name dialog with its own duplicate-name rule, and a create target the operator could neither see nor choose.
Removing the weaker entry leaves the sidebar header with exactly one action, which raised the presentation question this Note also settles: what a popover with a single row should look like.
## Decision
Adding a Workspace has one route: pick a host directory through the composed directory flow, new or existing. `menu.addWorkspace` ("添加工作区…" / "Add workspace…") is the entry; the create-by-name dialog and its `create.*` / `menu.createWorkspace` / `workspace.new` strings are gone. The label names the outcome, not the mechanism, because it is now the only door to that outcome — a user looking for "新建" must find it.
**A menu exists to disambiguate between targets.** When the only entry left is the add action — the add-only sidebar surface, or the hero with an empty list — the anchor gesture *is* that action: the flow opens directly and no popover renders. A one-row popover costs a click and offers nothing to choose between. The rule is one predicate (`addIsTheOnlyEntry`) covering both surfaces rather than a per-surface special case.
Two boundaries fall out of that rule and are part of it:
- **An empty list is only final once the baseline lands.** While `phase` is `pending` the hero keeps its menu and loading status instead of jumping into a flow that the arriving workspaces would have made unnecessary. The add-only surface lists nothing and never waits.
- **An unoccupied directory-flow hole leaves nothing to add with.** The sidebar header then renders no button at all rather than a dead one; the hero's menu keeps working as a picker over whatever is listed, and shows nothing when nothing is listed either — an empty popover would claim a choice that does not exist. This is the seam's documented no-flow default reaching its conclusion: with the occupant gone, so is the only creation affordance. The hero's anchor chip belongs to ui-conversation, so this package can suppress the popover but cannot hide the chip.
The direct-open path carries the busy rule the menu entry states: while a pick is still being adopted (`flowBusy`), the anchor gesture is held exactly as the entry is disabled, so a late outcome cannot race a second flow.
`WorkspaceCreateFlow` is now `WorkspacePickFlow` and its `createOnly` prop is `addOnly`; the injected `createWorkspace` narrows from `{ name } | { path }` to `{ path }`.
## Wire and CLI residue
The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace-root` still feeds its target directory, but no product surface reaches either any more. The same is true of the client seam that carried the name to the wire: `WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, `intentName`'s name branch, and the manager's "name under workspaceRoot" contract. `apps/cli/README.md` and its Chinese counterpart still document `--workspace-root` as creating named Workspaces. The whole set is marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: it is backend, client-seam, and CLI surface with its own reviewer and its own test fallout (the api-proxy workspace suite, the runtime workspace suite, the config catalog), and the release-blocking part of this decision is the UI.
## Testing
`connectFreshWorkspace` — the helper every web e2e scenario boots through — stages `<root>/workspace` and adopts it through the dialog's path editor, so the produced session cwd stays identical to what create-by-name produced and scenario goldens stay valid. Staging rather than creating in-dialog keeps the helper idempotent across the repeated connects a scenario may make (a second create of the same folder fails, and the create dialog holds the flow open on that failure). Creating a folder from inside the chooser — the other half of the same route — is covered by `workspace-management.e2e.ts`, which owns the focused coverage: two workspaces added on folders the dialog creates, a deleted title reused on a different directory, and the browser-dialog aria golden.
`smoke-real.e2e.ts` is the one scenario booting the unpatched shipped tree, where the `-auto` row resolves per host; it now pins `-browse` through a `--config` overlay so the developer's display environment cannot decide whether the picker is drivable at all.
## Alternatives considered
**Keep `Open local folder…` as the label.** Rejected: after the merge the entry both opens and creates, and naming it after the mechanism hides the creation half from exactly the users whose entry we removed. The counter-argument — "本地" usefully disambiguates the browser's machine from the harness's — is answered one step later by the dialog's own title and breadcrumbs.
**Keep the two-entry menu and make `Create a new workspace` open the same flow.** Rejected: two labels for one action is the confusion we were asked to remove, not a smaller version of it.
**Keep a one-row popover for consistency with the hero's menu.** Rejected: a popover that offers no choice is a wasted click and reads as unfinished. Consistency here is the *rule* (menu ⇔ a choice exists), not the widget.
**Keep the menu shell for entries we might add later (clone a repo, remote directory).** Rejected under "require a current owner and need": no such entry exists, and restoring a menu when one arrives is a smaller change than shipping an empty frame now.
**Delete the wire's create-by-name branch in the same change.** Rejected for this PR: it is backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision is the UI. See the residue section — it is marked, not forgotten.
**Register the workspace through the host in the e2e scaffold instead of driving the dialog.** Rejected: it would have decoupled all 15 scenarios from the picker, so nothing in the lane would prove the surviving route reaches a live composer. Every scenario now walks the real dialog to adopt its directory; only the create-a-folder half is concentrated in one scenario, because repeating it everywhere makes the shared helper non-idempotent for no extra signal.
## Consequences
- Creating a Workspace outside the operator's chosen directory is no longer possible from the UI; the server-controlled `--workspace-root` target was the one way to constrain where new workspace folders land, and nothing replaces it. A deployment that needs that constraint has to re-introduce it deliberately.
- The one remaining route browses the host filesystem, so the picker's reach is now the whole host rather than one configured parent. That is already the browse occupant's contract; this change makes it the only contract.
- A composition that mounts `ui-workspace` without any directory-picker package can no longer add a Workspace at all, and now says so by omitting the button instead of offering a create-by-name fallback.
- The hero chip still announces `aria-haspopup="menu"` while the direct-open path raises a dialog instead. Making that truthful means routing the flow's presentation choice up through the `conversation.hero.workspace` owner contract — the flow owns the decision, the chip owns the announcement, and they sit in different packages — so it is a named follow-up rather than a silent inconsistency. The sidebar button this change added makes no popup claim at all.

View File

@@ -0,0 +1,57 @@
# Agent Note: One route to add a Workspace
Status: implemented
[English](2026-07-31-one-route-to-add-a-workspace.md) | 中文
## Problem
两处 Workspace 表层——侧边栏区头的 `+` 与会话主视觉区的 chip——都提供了两条获得 Workspace 的路径:**打开本地文件夹…** 拉起组合的目录流程,**新建工作区** 接收一个名称并创建 `<workspaceRoot>/<name>`。两者重叠:浏览占用者自带 **新建文件夹** 能力,因此"选一个目录"本就覆盖了"建一个目录"。两个入口意味着同一结果有两套词汇、一个自带重名规则的名称对话框,以及一个操作者既看不到也选不了的创建位置。
删掉较弱的那个入口后,侧边栏区头只剩一个动作,于是引出了本 Note 一并裁定的展示问题:只有一行的浮层应该长什么样。
## Decision
添加 Workspace 只有一条路径:通过组合的目录流程选一个宿主机目录,新建的或已存在的都可以。入口是 `menu.addWorkspace`"添加工作区…" / "Add workspace…");按名称创建的对话框及其 `create.*``menu.createWorkspace``workspace.new` 文案全部删除。标签命名的是结果而非机制,因为它现在是通往该结果的唯一一扇门——找"新建"的用户必须能找到它。
**菜单的存在是为了在多个目标之间消歧。** 当只剩添加这一个入口时——仅添加的侧边栏表层,或列表为空的主视觉区——锚点手势*就是*该动作:直接拉起流程,不渲染浮层。只有一行的浮层多花一次点击,却没有任何可选项。这条规则是一个谓词(`addIsTheOnlyEntry`)覆盖两处表层,而不是逐表层特判。
由该规则派生出两条边界,它们同属这个决定:
- **列表为空只有在基线落地后才算最终结果。** `phase` 仍为 `pending` 时,主视觉区保留菜单与加载状态,而不是跳进一个即将到达的 workspace 会使其变得多余的流程。仅添加表层不列任何东西,因此从不等待。
- **目录流程的洞没有占用者时,就没有任何可添加的手段。** 此时侧边栏区头干脆不渲染按钮,而不是留下一个点了没反应的按钮;主视觉区的菜单则继续作为选择器工作,列出已有内容——而当同样没有内容可列时,它什么也不弹:空浮层会宣称一个并不存在的选择。这是 seam 文档化的无流程默认行为走到它的结论:占用者不在,唯一的创建能力也就不在。主视觉区的锚点 chip 归 ui-conversation 所有,因此本包能压掉浮层,却无法隐藏该 chip。
直接拉起的这条路径同样遵守菜单项声明的 busy 规则:某次选取仍在接纳中(`flowBusy`)时,锚点手势会被按住,正如该菜单项会被禁用,从而使迟到的结果无法抢开第二个流程。
`WorkspaceCreateFlow` 现更名为 `WorkspacePickFlow`,其 `createOnly` prop 更名为 `addOnly`;注入的 `createWorkspace``{ name } | { path }` 收窄为 `{ path }`
## Wire and CLI residue
Host 侧的 `workspace.create` 仍接受 `{ name }``dsh web --workspace-root` 也仍在为它提供目标目录,但已没有任何产品表层会走到它们。把名称送到 wire 的客户端一段同样如此:`WorkspaceCreateInput``WorkspacesService.create``{ name }` 分支、`intentName` 的名称分支,以及 manager 中"workspaceRoot 下的 name"这一契约。`apps/cli/README.md` 及其中文对照本也仍把 `--workspace-root` 记为"创建具名 Workspace"。这一整套都在 `packages/host/apiproxy/src/api-proxy.ts` 的调用点标记为待删除,并留给后续改动:它横跨 backend、客户端 seam 与 CLI 面,有各自的 reviewer 和各自的测试波及面api-proxy workspace 套件、runtime workspace 套件、配置目录),而本决定中阻塞发布的部分是 UI。
## Testing
`connectFreshWorkspace`——所有 web e2e 场景启动时都会走的辅助函数——会预先备好 `<root>/workspace`,再经对话框的路径编辑器接纳它,因此产出的会话 cwd 与按名称创建时完全一致,场景 golden 保持有效。选择预先备好而不是在对话框内新建,是为了让该辅助函数在一个场景可能发生的多次连接之间保持幂等(第二次创建同名文件夹会失败,而创建对话框会在失败时把流程停在原地)。在选择器内新建文件夹——同一条路径的另一半——由 `workspace-management.e2e.ts` 覆盖,它承担针对性覆盖:在对话框自己创建的文件夹上添加两个 workspace、在另一个目录上复用已删除的标题、以及浏览对话框的 aria golden。
`smoke-real.e2e.ts` 是唯一启动未打补丁的出厂配置树的场景,其中 `-auto` 行会按宿主机解析;它现在通过 `--config` overlay 钉死 `-browse`,使开发机的显示环境无法决定选择器是否可被驱动。
## Alternatives considered
**保留 `打开本地文件夹…` 作为标签。** 否决:合并后该入口既能打开也能创建,用机制命名会恰好对那些入口被我们删掉的用户隐藏创建这一半。反方理由——"本地"二字有效区分了浏览器所在机器与 harness 所在机器——在下一步就由对话框自身的标题和面包屑回答了。
**保留双入口菜单,让 `新建工作区` 也打开同一个流程。** 否决:同一动作两个标签正是我们被要求消除的混淆,而不是它的缩小版。
**为了与主视觉区菜单保持一致而保留只有一行的浮层。** 否决:不提供选择的浮层是一次浪费的点击,读起来像半成品。这里要一致的是*规则*(有菜单 ⇔ 存在选择),不是控件。
**为将来可能新增的入口(克隆仓库、远程目录)保留菜单壳。** 否决,依据"require a current owner and need":这样的入口目前并不存在,而等它到来时再恢复菜单,比现在就发一个空壳的改动更小。
**在同一改动中删除 wire 的按名称创建分支。** 本 PR 否决:那是 backend/CLI 面reviewer 不同、测试波及面更广,而紧急的决定是 UI。见 residue 一节——它是被标记了,不是被遗忘了。
**在 e2e scaffold 中经 host 注册 workspace而不驱动对话框。** 否决:那会让全部 15 个场景与选择器解耦,整条 lane 将无法证明幸存的这条路径能走到可用的 composer。现在每个场景都会走真实对话框来接纳自己的目录只有"新建文件夹"那一半集中在一个场景里,因为处处重复只会让共享辅助函数失去幂等性,却换不来额外信号。
## Consequences
- 从 UI 已无法在操作者选定目录之外创建 Workspace服务端控制的 `--workspace-root` 目标曾是约束新 workspace 文件夹落点的唯一手段,现在没有替代品。需要该约束的部署必须有意识地重新引入它。
- 仅存的这条路径会浏览宿主机文件系统,因此选择器的可达范围现在是整台宿主机,而非一个配置好的父目录。这本就是浏览占用者的契约,本改动使它成为唯一的契约。
- 挂载了 `ui-workspace` 但未挂任何 directory-picker 包的组合,已完全无法添加 Workspace现在它通过不渲染按钮来说明这一点而不是提供一个按名称创建的兜底。
- 主视觉区的 chip 仍声明 `aria-haspopup="menu"`,而直接拉起的路径实际弹出的是对话框。要让它如实,需要把流程的展示方式经 `conversation.hero.workspace` 的 owner 契约上报——决定权在流程,播报权在 chip两者分属不同的包——因此这被列为一项具名的后续工作而不是一处无声的不一致。本次新增的侧边栏按钮完全不作任何 popup 声明。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
2026-07-24-web-gui-browser-e2e-lane.md: 107dbddbfde8ad29e22d9cba04ce2b83c1d01383
2026-07-24-web-gui-browser-e2e-lane.zh.md: e4132b2ebb3f30a9d540f47cf9416a13bc4aa9f3
2026-07-24-web-gui-browser-e2e-lane.md: cdb7de52c50733d6650202ee2117916319940738
2026-07-24-web-gui-browser-e2e-lane.zh.md: b850acf026502d054a9d8b2168f0b4f47f58f39b

View File

@@ -28,7 +28,7 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side
No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; `settings-chrome.e2e.ts` alone leaves storage unset to cover the default Chinese state and both switch directions.
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios set `dsh.locale=en` before client boot so localized role locators and goldens use one explicit language; the scenarios asserting Chinese copy leave storage unset and open a `zh-CN` browser instead, because the client derives its initial locale from `navigator` ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)), and `settings-chrome.e2e.ts` additionally covers both switch directions and the English-browser default.
### Expected outputs

View File

@@ -28,7 +28,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host视口固定交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;只有 `settings-chrome.e2e.ts` 不预设该存储项,以覆盖默认中文状态及双向切换
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host视口固定交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景在客户端启动前设置 `dsh.locale=en`,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则不预设该存储项,改为开启 `zh-CN` 浏览器,因为客户端的初始 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.md)),而 `settings-chrome.e2e.ts` 还额外覆盖双向切换与英文浏览器默认态
### 预期输出

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
2026-07-28-storage-root-and-derived-medium-recovery.md: 3937b17502d0bf640625e73822b758b1862b5391
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: ff2f196a911986d23b7a102e6336f353c6aef278
2026-07-28-storage-root-and-derived-medium-recovery.md: 2edb60d204da736bf14ace6278fa4ee7b9e4b8f7
2026-07-28-storage-root-and-derived-medium-recovery.zh.md: daef4f7661df069df3438f909f2744880a93a873

View File

@@ -8,7 +8,7 @@ English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
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 Web overlay gives the json backend a relative root `root: './.storages'` (`apps/cli/config/web.cordis.yml`)while the shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`); no equivalent global root 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 `<launch dir>/.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.
**Where the files actually live (root mismatch closed; resolve-once residual still open).** The shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`), while the shipped Web overlay used to give the json backend the relative root `./.storages`: `workspace.json` and `session_projcache.json` landed under `<launch dir>/.storages/`two launches from different directories shared their sessions yet saw different workspace registries and different projection caches, and the cache exists precisely to serve the cross-session cold listing, which missed for every session last cached under another launch directory. That mismatch is now closed: the overlay anchors `storage-json.root` to `$DSH_HOME/storages` with the same `!!js` expression the session root uses (`apps/cli/config/web.cordis.yml`). The residual hazard: `JsonStorageBackend` still never resolves its root — each unit open joins the path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts); the shipped overlay root is already absolute and unaffected, but any relative root (bare Loader boots, tests) still splits on a later cwd change — 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).
**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.
@@ -16,11 +16,11 @@ The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-
Two independent changes, one per gap.
### One global storage root, resolved once
### One global storage root (shipped, amended form); resolved once at construction (still open)
- `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 `<cwd>/.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.
- **Shipped**: the Web overlay anchors `storage-json.root` to `$DSH_HOME/storages` directly in the row through the app-boot-provided `dshHomePath('storages')` (`~/.dsh/storages` by default, beside `~/.dsh/sessions`; no leading dot — the home is already a hidden tree). The helper delegates to the canonical `dsh-paths` resolver, and the session root uses the same function without duplicating its fallback and tilde rules. The user ruled the per-row form in over this section's original launcher-patch + `storageRoot` profile key (see Alternatives); per-row overrides ride the personal `~/.dsh/config.yaml` patch layer. The web e2e scaffold already patches the row to an absolute temp root, so tests never touch the user's home.
- **Still open**: `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 (and was executed): no migration shim. A deployment that cached under `<cwd>/.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
@@ -31,7 +31,9 @@ Two independent changes, one per gap.
## 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.
**Keep per-launch-directory `.storages` (the pre-change 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.
**Launcher patch + a `storageRoot` profile key (this section's original form)** — not taken: one `!!js` yml expression reaches the global root with the same layering the session root already has; a launcher patch adds a second rewrite point, and the profile key is an empty seat until a real consumer exists (per-row overrides already have the personal config.yaml patch layer).
**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.
@@ -45,7 +47,7 @@ Two independent changes, one per gap.
## 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.
- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`) — already satisfied by the overlay expression; per-row overrides ride the personal config.yaml patch layer; the backend resolves a relative root once at construction (still to do).
- 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.

View File

@@ -8,7 +8,7 @@ Status: proposed
持久投影缓存([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))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。
**文件到底存在哪** 出厂 Web overlay 给 json 后端的是相对根目录——`root: './.storages'``apps/cli/config/web.cordis.yml`)——而共享 base 将会话存储默认为全局 harness home`$DSH_HOME/sessions`,默认 `~/.dsh/sessions``storage-json` 没有对应的全局根目录。`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。
**文件到底存在哪根错位已收口resolve-once 残余仍开放)。** 共享 base 将会话存储默认为全局 harness home`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`),而出厂 Web overlay 给 json 后端相对根 `./.storages``workspace.json``session_projcache.json` 落在 `<启动目录>/.storages/` 下——从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份,而缓存存在的意义恰恰是跨会话冷列表,凡上次在别的启动目录下缓存过的会话全部 miss。这一错位已消除overlay 现以与会话根同一段 `!!js` 表达式把 `storage-json.root` 锚定到 `$DSH_HOME/storages``apps/cli/config/web.cordis.yml`)。残余隐患:`JsonStorageBackend` 从不 resolve 根——每次打开 unit 都把路径 join 到当时的 `process.cwd()`packages/storage/storage-json/src/index.ts;出厂 overlay 的根已是绝对路径不受影响,但任何相对根(裸 Loader 启动、测试)仍会被后续 cwd 变化劈开,JSONL 会话后端用「构造时 resolve 一次」防住的正是它"later process.cwd() changes cannot split one backend across roots"packages/session-persistence/session-persistence-jsonl/src/index.ts
**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit``malformed-medium`/`version-mismatch` 失败packages/storage/storage-json/src/format.tsschema 漂移的记录让域 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` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
@@ -16,11 +16,11 @@ Status: proposed
两个独立改动,一个缺口一个。
### 全局唯一存储根构造时 resolve 一次
### 全局唯一存储根(已落地,形态修正);构造时 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 立场:不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap投影缓存惰性重折要么手动把两个 json 文件挪一次。
- **已落地**:出厂 Web overlay 通过 app-boot 提供的 `dshHomePath('storages')`,直接在 `storage-json` 行内把 `root` 锚定到 `$DSH_HOME/storages`默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩目录名不带点——home 本身已是隐藏树)。该辅助函数委托给规范的 `dsh-paths` 解析器会话根也使用同一个函数无需重复其回退和波浪号规则。用户拍板采用这种按行形态取代本节初版的「launcher patch + `storageRoot` profile 键」(见 Alternatives按行覆盖仍走个人 `~/.dsh/config.yaml` patch 层。web e2e scaffold 本就把该行 patch 到临时绝对根,测试不触用户 home
- **仍开放**`JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
- 适用 pre-release 立场(已按此执行):不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap投影缓存惰性重折要么手动把两个 json 文件挪一次。
### 声明派生介质:损坏时重置而非拒绝
@@ -31,7 +31,9 @@ Status: proposed
## Alternatives considered
**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
**保持按启动目录的 `.storages`改动前现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
**launcher patch + `storageRoot` profile 键(本节初版提案形态)**——未采:一行 yml `!!js` 表达式即达全局根与会话根的既有分层完全一致launcher patch 多引入一个改写点profile 键在有真实消费者前是空席(按行覆盖已有个人 config.yaml patch 层可用)。
**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。
@@ -45,7 +47,7 @@ Status: proposed
## Acceptance criteria
- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。
- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`——已由 overlay 表达式满足,按行覆盖走个人 config.yaml patch 层;后端对相对根在构造时 resolve 一次(待做)
- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。
- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。
- facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。

2
.gitattributes vendored
View File

@@ -5,3 +5,5 @@
# for cmd.exe), add a `*.bat text eol=crlf` override AFTER this line — the
# in-repo form stays LF; CRLF becomes checkout-time presentation only.
* text=auto eol=lf
*.pdf binary

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