Merge remote-tracking branch 'origin/master' into mergebot/pr1016

This commit is contained in:
imccyu
2026-07-31 14:43:03 +08:00
145 changed files with 2856 additions and 608 deletions

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-session-end-seed-log-boundary.md
2026-07-30-session-end-seed-log-boundary.md: 837531ba0bd3ecf404eb47ee933438546c682a54
2026-07-30-session-end-seed-log-boundary.zh.md: 33680c1845364de62e5b53ead13de418a389f908
2026-07-30-session-end-seed-log-boundary.md: 268646e192d0b8e0a5dde03957a18ef155b7038e
2026-07-30-session-end-seed-log-boundary.zh.md: dca87e16de5e567ff85d2b32b8243f76ebed1c4a

View File

@@ -14,13 +14,13 @@ Crash repair does not close the gap and must not: `interruptedTurnClosers` synth
## Decision
`Session`'s constructor appends the log-only `session/end-seed` event immediately after a non-empty constructor seed, as the seeded session's first live write at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers where this lifecycle's writes start for a consumer holding the object, while `session/end-seed` answers the same question for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history.
`Session`'s constructor appends the log-only `session/end-seed` event immediately after an explicitly supplied constructor seed, including an empty one, as the seeded session's first live write at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers where this lifecycle's writes start for a consumer holding the object, while `session/end-seed` answers the same question for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history. The seq-0 marker distinguishes an empty resumed session from a genuinely fresh session, preventing new-session defaults from being applied during resume.
A bracket owner reads it positionally: an unmatched opening marker before `session/end-seed` has a smaller seq, came from the constructor seed, and belongs to a lifecycle that has ended. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it.
The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case that must be classifiable. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating.
Two guards keep the marker from becoming noise. An empty seed writes nothing because there is no seed to end. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit.
Two guards keep the marker precise. An omitted seed writes nothing because the session is fresh. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit.
## Persistence needs no changes
@@ -48,7 +48,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si
Bought: one boundary, written in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary.
Cost: a seeded session's log is one event longer, which moved seq expectations in tests across nine packages (session, agent-loop, persistence contract, jsonl, session-query, session-title, subagent-inprocess, telemetry, token-meter). Two of those updates are load-bearing rather than mechanical: telemetry's adoption tests now assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is restated as "seed reproduced verbatim, plus one log-only boundary" with idempotence added as its own property.
Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property.
`session/end-seed` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly classifies nothing as constructor-seed history.

View File

@@ -14,13 +14,13 @@ Status: implemented
## Decision
`Session` 的构造函数紧接非空构造种子之后追加仅日志事件 `session/end-seed`,作为带种子会话的第一次实时写入,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影`firstLiveSeq` 为持有对象的消费方回答本生命周期的写入从哪里开始,`session/end-seed` 则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。
`Session` 的构造函数紧接显式传入的构造种子(包括空种子)之后追加仅日志事件 `session/end-seed`,作为带种子会话的第一次实时写入,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影`firstLiveSeq` 为持有对象的消费方回答本生命周期的写入从哪里开始,`session/end-seed` 则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。这个 seq-0 标记把从空日志恢复的会话与真正的全新会话区分开来,从而防止恢复期间应用新会话默认值。
括号所有方按位置读取它:在 `session/end-seed` 之前的未配对开启标记具有更小的 seq来自构造种子并且属于一个已结束的生命周期。核心写入该边界但不从中读取任何内容每个括号的词汇表仍归其所属插件因此在没有消费方来塑形之前核心不会先发布谓词辅助函数。
选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是必须可判定的场景。在 loop 启动时写入的边界会漏掉 `fork()``adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。
两条守卫让这个标记不至于变成噪声。空种子不写入任何内容,因为没有种子需要结束。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。
两条守卫让这个标记保持精确。省略种子不写入任何内容,因为这是全新会话。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。
## 持久化无需任何改动
@@ -48,7 +48,7 @@ Status: implemented
买到的:一条边界,在一处写入,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。`firstLiveSeq` 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。
代价:带种子会话的日志长了一个事件,这在九个包session、agent-loop、持久化契约、jsonl、session-query、session-title、subagent-inprocess、telemetry、token-meter里挪动了 seq 期望。其中两处更新是承重的而非机械的telemetry 的收养测试现在断言该边界*会*被导出,因为它是本生命周期的自有写入;属性测试套件的放不变式被重述为"种子逐字节复现,外加一个仅日志边界",并把幂等性补成一条独立属性。
代价:带种子会话的日志长了一个事件,空日志恢复也包括在内。seq 期望会随这条边界移动。两处更新是承重的而非机械的telemetry 的收养测试断言该边界*会*被导出,因为它是本生命周期的自有写入;属性测试套件的放不变式则是"种子逐字节复现,外加一个仅日志边界",并把幂等性作为独立属性。
`session/end-seed` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地判定没有任何内容属于构造种子历史。

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-web-config-plane.md
2026-07-30-web-config-plane.md: 95ede6264026f7b32e95749d00fe841f57dbf867
2026-07-30-web-config-plane.zh.md: 6e06b69218a405055621cbd40781f9fbda9f9e6b
2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633
2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9

View File

@@ -20,7 +20,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently.
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value and the wholesale `settings.replace` a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal `settings.update` merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal.
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles.
## Alternatives considered
@@ -33,4 +33,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
## Consequences
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable.
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential.

View File

@@ -20,7 +20,7 @@ PR1 让 LLM大语言模型适配器配置在 seam 层面免重启,但唯
**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原rehydrate为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值,删除所需的整体 `settings.replace` 也绝不可能丢掉兄弟条目的机密。不含删除的编辑以一次最小 `settings.update` 合并 patch 落地;把折叠区字段清回继承值或删除整行则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile而破坏性确认会提交唯一一条 unset并在其完成前阻止重复提交
## 曾考虑的替代方案
@@ -33,4 +33,4 @@ PR1 让 LLM大语言模型适配器配置在 seam 层面免重启,但唯
## 后果
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态已配置态各有 ARIA golden另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。这次重命名在一次提交中触及 239 个文件fixture测试前置数据、golden、文档、python未保留兼容别名。替换渲染器只花了一次提交且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及已记录在案的重置边界情形——`settings.replace` 无法在被替换的子树里重新补上已存储的*字面量*机密,而基于引用的默认形态让这种情况根本无从出现
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态已配置态与删除确认态各有 ARIA golden另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件fixture测试前置数据、golden、文档、python未保留兼容别名。替换渲染器只花了一次提交且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据

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-31-gui-full-access-confirmation.md
2026-07-31-gui-full-access-confirmation.md: 8208a20bee9b9ab8f1e73720790e3e5be4c27306
2026-07-31-gui-full-access-confirmation.zh.md: 8ac115034f562fe96d53bdba010b05648d4d5947
2026-07-31-gui-full-access-confirmation.md: ca89ed23fb1b5c6ea438dd22fdf20d2b82af754c
2026-07-31-gui-full-access-confirmation.zh.md: 0f487e41b544718bdf38de611a1ab2d59c44b063

View File

@@ -6,25 +6,26 @@ English | [中文](2026-07-31-gui-full-access-confirmation.zh.md)
## Problem
Switching the web client to `danger-full-access` was a single click on either permission surface (the composer's Access chip and the `/permission` popup picker), with the preset shown as the title-cased machine name `Danger Full Access`. Full access reduces confirmation steps and lets the agent run sensitive operations, modify files, or execute external commands, so an accidental pick armed the most dangerous preset with no deliberate acknowledgement step.
Switching the web client to `danger-full-access` was a single click on a permission picker, with the preset shown as the title-cased machine name `Danger Full Access`. Full access reduces confirmation steps and lets the agent run sensitive operations, modify files, or execute external commands, so an accidental pick armed the most dangerous preset with no deliberate acknowledgement step.
## Decision
**Both permission surfaces gate `danger-full-access` behind one shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
- The `/permission` popup (ui-permission over the ui-command shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
- `Full access` intentionally overrides the kebab-to-title display transform on both surfaces (option rows, trigger label, settled command rows keep the machine name on the wire); the warning body remains locale-aware in Chinese and English.
- The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched.
- `Full access` intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
## Alternatives considered
**A native/OS or separate-window confirmation.** Rejected: the dialog must stay inside the current WebUI window; a second window can appear on another display and detaches the decision from the page state it guards.
**One shared locale namespace for both surfaces' safety copy.** Rejected: the ui-permission bundle and ui-conversation load independently, so each registers the same copy under its own namespace (`permission.access` beside the conversation dictionary); the duplication is fenced with an explanatory `jscpd:ignore` block rather than a cross-bundle import.
**One shared locale namespace for every surface's safety copy.** Rejected: the ui-permission bundle and ui-conversation load independently, while the Settings warning names a different future-session lifetime. Each bundle owns its copy, and ui-permission keeps the popup and Settings dictionaries separate rather than importing across bundle boundaries.
**Gating in the host/permission backend.** Out of scope by design: the change is browser-client confirmation flow only; backend permission semantics, defaults, and the safer presets' one-click behavior are unchanged.
## Consequences
Every visible GUI path into Full access now requires a deliberate, informed acknowledgement, at the cost of one extra dialog step for users who genuinely want the preset. New pickers reuse the gate by attaching a `confirmation` payload (popup path) or the chip's state machine (composer path) instead of inventing bespoke dialogs. Acceptance: the composer flow's four gated cases in `input-bar.spec.tsx`, the popup gate in `popup-view.spec.tsx` and `popup.spec.ts`, the Modal/RiskConfirmation contract in `atoms.spec.tsx`, and the assembled `access-confirmation` web e2e whose golden pins the product-default Chinese dictionary copy.
Every visible GUI path into Full access requires a deliberate, informed acknowledgement, at the cost of one extra dialog step for users who genuinely want the preset. New pickers reuse the shared dialog through their owning state machine or attach a `confirmation` payload to the popup path. Acceptance: the composer flow's gated cases in `input-bar.spec.tsx`, the popup gate in `popup-view.spec.tsx` and `popup.spec.ts`, the default-setting gate in `permission-row.spec.tsx`, the Modal/RiskConfirmation contract in `atoms.spec.tsx`, and the assembled Web replays.

View File

@@ -6,25 +6,26 @@ Status: implemented
## Problem
Web 客户端切换到 `danger-full-access` 在两个权限面(编辑器的 Access chip 与 `/permission` popup 选择器)上都只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
Web 客户端的权限选择器中切换到 `danger-full-access` 只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
## Decision
**个权限都把 `danger-full-access` 关进同一个共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不提交任何命令**
**个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。**
- `RiskConfirmation`ui-primitives是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` 座位,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
- 编辑器 chipui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销`locked`/值缺席 effect切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale 座位以 `access.confirm.*` 键供给。
- `/permission` popupui-permission 骑在 ui-command 外壳上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`
- `Full access` 在两个面上有意覆盖 kebab 转 Title Case 的显示变换(选项行、触发器标签;落定的命令行仍在 wire 上保留机器名);警示正文保持中英文 locale 感知
- 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话取消、Escape、关闭与点击遮罩均不会改动已存默认值
- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
## Alternatives considered
**原生/操作系统或独立窗口确认。** 已拒:对话框必须留在当前 WebUI 窗口内;第二个窗口可能出现在另一块显示器上,使决策脱离其守护的页面状态。
**个面共享一个安全文案 locale namespace。** 已拒ui-permission bundle 与 ui-conversation 可独立加载,故各自在自己的 namespace 下注册同一份文案(`permission.access` 与 conversation 词典并立);这处重复以带说明的 `jscpd:ignore` 块圈护,而非跨 bundle import。
**个面的安全文案共享一个 locale namespace。** 已拒ui-permission bundle 与 ui-conversation 可独立加载,而 Settings 警示说明的是另一种只影响后续会话的生效周期。每个 bundle 各自拥有文案ui-permission 也将 popup 与 Settings 词典分开,而非跨 bundle 边界 import。
**在 host权限后端把关。** 设计上即出界:本变更只涉浏览器客户端确认流;后端权限语义、默认值与更安全预设的一键行为均不变。
## Consequences
进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器复用此门:popup 路径挂 `confirmation` 载荷、编辑器路径走 chip 的状态机,而不是各造对话框。验收:`input-bar.spec.tsx` 中编辑器流的四个门控用例、`popup-view.spec.tsx``popup.spec.ts` 的 popup 门、`atoms.spec.tsx` 的 Modal/RiskConfirmation 契约,以及组装态 `access-confirmation` web e2e——其 golden 钉住产品默认中文词典文案
进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器通过各自拥有的状态机复用共享对话框,或在 popup 路径挂 `confirmation` 载荷。验收:`input-bar.spec.tsx` 中编辑器流的门控用例、`popup-view.spec.tsx``popup.spec.ts` 的 popup 门、`permission-row.spec.tsx` 的默认设置门控、`atoms.spec.tsx` 的 Modal/RiskConfirmation 契约,以及组装态 Web 回放

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-permission-default-for-new-sessions.md
2026-07-31-permission-default-for-new-sessions.md: 35812b53d0c1448afd95b9a063eda6658fb1bef3
2026-07-31-permission-default-for-new-sessions.zh.md: a75deaec323b57f2bc88e84dd4d5c7d7d98cd177

View File

@@ -0,0 +1,35 @@
# Agent Note: Permission Settings default for new sessions
Status: implemented
English | [中文](2026-07-31-permission-default-for-new-sessions.zh.md)
## Problem
The Web General-settings page displayed Permission as a disabled skeleton even though `dsh-permission` already owned the preset table and current-session switch path. The Settings seam could persist a plugin-owned value, but the Web settings API exposed only configurable LLM-provider namespaces. More importantly, treating a user preference as a live global permission would make an existing session's execution policy change outside its durable log.
## Decision
`dsh-permission` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them.
The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session.
The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows.
ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes emit `host/settings-changed` but not `host/models-changed`.
## Consequences
Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly.
The assembled Web snapshot now contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `danger-full-access` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet.
## Alternatives considered
**Apply the Settings value live to every session.** Rejected because execution policy would change without a session event and replay could not reconstruct which permission governed an earlier tool call.
**Record only `permission/preset` on creation.** Rejected because sandbox and approval are independently owned whole-value knobs; pinning all three facts keeps their consumers independent of future composition-default changes.
**Expose all Settings registrations, or add a generic `local-client` declaration.** Rejected for this change because it expands a security boundary and the Settings contract beyond the one requested preference. The explicit `permission` allowlist entry is sufficient and leaves future namespaces to make their own exposure decision.
**Apply the latest default while resuming a seeded session.** Rejected because resume must preserve the session's prior effective execution policy; missing legacy facts are materialized from that policy instead.

View File

@@ -0,0 +1,35 @@
# Agent Note: 新会话的权限 Settings 默认值
Status: implemented
[English](2026-07-31-permission-default-for-new-sessions.md) | 中文
## 问题
Web「通用」设置页将「权限」显示为禁用的骨架控件尽管 `dsh-permission` 已经拥有 preset 表和当前会话的切换路径。Settings seam 可以持久化由插件拥有的值,但 Web Settings API 只暴露可配置 LLM 提供方的 namespace。更重要的是如果把用户偏好当成实时生效的全局权限现有会话的执行策略就会在其持久日志之外发生变化。
## 决策
`dsh-permission` 拥有一个 `permission` Settings namespace其中只有 `defaultPreset` 字段。它的基础值是 `Config.defaultPreset`;省略该配置时,则使用与组合后的沙箱和审批默认值匹配的 preset。schema 的 enum 从已配置的 preset 表派生,因此 Settings 既能校验已存储的值Web 客户端也能发现部署中的实际选项,而无需重复定义。
服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset``sandbox/mode``approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed因此不能把空的持久化日志误认为新会话。
现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable而不是绑定渲染器专用钩子权限服务挂载时会遍历并固定所有已存活会话因此 HMR热模块替换不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。
ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更会发出 `host/settings-changed`,但不会发出 `host/models-changed`
## 后果
在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已打开的会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`
组装后的 Web 快照现在包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `danger-full-access` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。
## 曾考虑的替代方案
**将 Settings 值实时应用于每个会话。** 不予采纳,因为执行策略会在没有会话事件的情况下改变,重放也无法重建先前工具调用采用了哪种权限。
**创建时只记录 `permission/preset`。** 不予采纳,因为沙箱和审批是由不同组件独立拥有的全量值调节项;固定全部三个事实,可以让其消费方不依赖未来的组合默认值变化。
**暴露所有 Settings 注册,或增加通用的 `local-client` 声明。** 本次变更不予采纳,因为这会扩大安全边界,并使 Settings 契约超出所请求的单项偏好。显式加入 `permission` allowlist 已足够,未来的 namespace 可以各自决定是否暴露。
**恢复带 seed 的会话时应用最新默认值。** 不予采纳,因为恢复操作必须保留会话先前的有效执行策略;缺失的旧版事实应从该策略中补齐。

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-session-archive-global-set.md
2026-07-31-session-archive-global-set.md: fab99a405a6f8264c36453473327e32905bac9c8
2026-07-31-session-archive-global-set.zh.md: e33f3b5272a6d8fc90cfad247ba21d4d10afb045

View File

@@ -0,0 +1,33 @@
# Agent Note: Session archive (registry-global set)
Status: implemented
English | [中文](2026-07-31-session-archive-global-set.zh.md)
## Problem
The session row menu in the sidebar workspace browser carried a purely visual "Delete session" placeholder (no handler). The product decision is **archive**, not delete: the session log and its workspace accounting stay untouched; the session merely disappears from every grouping surface (workspace groups, Ungrouped, search, the flat list). The archive record needs a home: an Ungrouped session belongs to no workspace entity, so a per-workspace field cannot carry it.
## Decision
**The archive set is a new field on the workspace domain's global singleton (`workspaceDomainState.archivedSessionIds`), layered over workspace accounting; display filtering converges entirely in the client's `tree.ts` derivation layer; the wire surface uses the full-snapshot posture.**
- Storage: `archivedSessionIds: z.array(sessionId).default([])`, domain version stays 2 — a purely additive field; pre-field media parse to an empty set through the schema default, no migration code. An archived session keeps its `sessionIds` slot (a future unarchive restores its position), so the set never touches the one-owner accounting invariant.
- Registry: `ctx.workspace.archiveSession(id)` rides `enqueueOperation`, serialized with create/delete; a session neither live nor persisted throws `WorkspaceUnknownSessionError`; an already archived id neither writes nor emits. The `archivedSessionIds` getter exposes the read-only set.
- RPC: `workspace.archiveSession({sessionId}) → {archivedSessionIds}` (answers the full updated set); the `workspace.list` response carries the set as the reconnect baseline; a new host frame `host/archived-sessions-changed` pushes the full snapshot after every durable change (same posture as `host/workspace-changed`, emitted from the `domain/changed` global-put branch by set comparison). Unknown sessions reuse the `session-not-found` error code.
- Client runtime: `WorkspaceListState.archivedSessionIds` (a `readonly SessionId[]` in Host order, reference replaced only on membership change — public snapshot state stays in the store engine's plain-data vocabulary since immer drafts reject Sets without the MapSet plugin; membership lookups build a transient Set in the derivation, the expandedProjects pattern); the list baseline, the unary echo, and the changed frame each install the complete set. the projection sweep clears the current selection whenever it lands in the archive set, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero) — one rule covering the local unary echo, another tab's changed frame, and a reconnect baseline restoring a selection archived while this client was away; a frame or echo landing during an in-flight `workspace.list` also shields the newer set from the stale baseline.
- UI: the `delete` menu row (visual-only) becomes `archive` (label "Archive session", non-danger styling, no confirmation dialog — a non-destructive action whose worst misfire is list hiding); filtering is one extra arm in `tree.ts`'s `sessionVisible` predicate, with `deriveGroups`/`deriveFlat` taking an `archived` set parameter so all four surfaces (group loop, stray bucket, search, flat) share one source.
## Alternatives considered
**Per-workspace archivedSessionIds (the original phrasing).** Rejected: Ungrouped sessions have no home; the user switched to global.
**An archived flag on SessionSummary (session.list layer).** Rejected: it joins a workspace-domain fact into the sessions-domain projection, summaries have no incremental frame so a separate notification would still be needed — cross-domain coupling outweighs the saving.
**Host-side filtering in `workspaceView`/the `sessionIds` getter.** Rejected: archiving ≠ changing accounting, and filtering the projection muddles the two concepts; a future restore surface also needs the client to see full accounting.
**Incremental frames (single archived/removed rows).** Rejected: the set is tiny and changes rarely; full snapshots spare the client merge logic and dedup state and match the existing workspace-changed posture.
## Consequences
Archived sessions have no viewing or unarchive surface yet (this iteration's scope; recorded as a README Known Limitation); data and accounting slots stay intact, so a future restore is one UI surface plus one inverse RPC. The `workspace.list` response shape change is a pre-release direct edit (no compatibility layer). The workspace-management e2e pins the full chain (archive → row disappears → still hidden after reload, log still present); domain tests pin idempotence, unknown-id rejection, restart recovery, and the pre-field media default upgrade.

View File

@@ -0,0 +1,33 @@
# Agent Note: Session 归档(注册表级全局集合)
状态implemented
[English](2026-07-31-session-archive-global-set.md) | 中文
## 问题
Sidebar workspace 浏览区的 session 行菜单里「Delete session」一直是纯视觉占位无 handler。产品口径定为**归档**而非删除session 日志与 workspace 记账都不动,只把该 session 从所有分组视图workspace 分组、Ungrouped、搜索、平铺列表里隐藏。归档记录需要一个落点Ungrouped 的 session 不属于任何 workspace 实体per-workspace 字段放不下它。
## 决策
**归档集合是 workspace domain 全局单例(`workspaceDomainState.archivedSessionIds`)上的一个新字段,覆盖在 workspace 记账之上;显示过滤全部收敛在 client 的 `tree.ts` 派生层wire 面走全快照姿态。**
- 存储:`archivedSessionIds: z.array(sessionId).default([])`domain version 保持 2——纯增量字段旧介质经 schema default 解析为空集合,无迁移代码。被归档的 session 保留其 `sessionIds` 席位(未来取消归档恢复原位置),因此与「一个 session 只被一个 workspace 记账」不变式零纠缠。
- Registry`ctx.workspace.archiveSession(id)``enqueueOperation` 与 create/delete 串行;未知 session实时与持久化都查不到`WorkspaceUnknownSessionError`;已归档 id 不写盘不发事件。`archivedSessionIds` getter 暴露只读集合。
- RPC`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应答完整更新后集合);`workspace.list` 响应携带集合作为重连基线;新 host 帧 `host/archived-sessions-changed` 在每次持久变更后推完整快照(与 `host/workspace-changed` 同姿态,从 `domain/changed` 的 global put 分支比对推帧)。未知 session 复用错误码 `session-not-found`
- client runtime`WorkspaceListState.archivedSessionIds`(按 Host 顺序的 `readonly SessionId[]`,成员不变不换引用——公有快照状态保持 store 引擎的纯数据词汇immer draft 不开 MapSet 插件就不接受 Setmembership 查询在派生函数内自建临时 Set与 expandedProjects 同款list 基线、unary 回声、changed 帧三路都整体替换安装。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线恢复出一个离线期间被归档的 selection帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。
- UI菜单项 `delete`visual-only改为 `archive`label「Archive session」非 danger 样式,无确认对话框——非破坏性操作,误触后果只是列表隐藏);过滤实现为 `tree.ts``sessionVisible` 判据加一档,`deriveGroups`/`deriveFlat` 增加 `archived` 集合入参四个视图分组循环、stray 兜底、搜索、平铺)同源生效。
## 已考虑的替代方案
**per-workspace archivedSessionIds最初表述** 否决Ungrouped session 无落点;用户改口全局。
**SessionSummary 打 archived 标session.list 层)。** 否决:要把 workspace domain 事实 join 进 sessions domain 投影summary 无增量帧还得另发通知,跨域耦合大于收益。
**host 侧在 `workspaceView`/`sessionIds` getter 过滤。** 否决:归档 ≠ 改记账,投影过滤会把两个概念搅浑;未来恢复入口也需要 client 拿到全量记账。
**增量帧archived/removed 单条)。** 否决:集合极小、变更频率低,全快照免去 client 侧合并逻辑与去重状态,与 workspace-changed 现有姿态一致。
## 后果
归档后 UI 无查看/取消归档入口本期口径README Known Limitation 记账);数据与席位完好,后续加恢复面只是 UI + 一个逆向 RPC。`workspace.list` 响应形状变化是 pre-release 直改无兼容层。e2eworkspace-management钉住了「归档→行消失→reload 后仍隐藏、日志仍在」的全链路domain 层测试钉住幂等、未知 id 拒绝、跨重启恢复与旧介质默认升级。