Merge master into office-sidebar-preview

This commit is contained in:
yudshj committed 2026-09-17 12:05:47 +08:00
commit 1c9f2e3a1f
444 files changed
+10811 -4755

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: 0f003edc4a53e7a04ea4f5613abe42c2d3926566
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1bae16cfaa1c54eebc6c8709ac6db5323b68d222
2026-07-25-web-client-session-scope-and-provide-channel.md: 6126d06e48111a551a0dc57247659a3086986c66
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: e7bf4aa6952b4fd7b187c91e2ac5d64aac32cabb
@@ -19,6 +19,8 @@ Hard constraints: the host is the single source of truth; every registration goe
## Decision
The reference-owned lifetime and Provider targeting now follow [Client Session references](2026-09-15-client-session-references.md). This note retains the blank-Session and adoption rationale and describes their current realization.
### The parity model: client and host share one root state axis
Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted):
@@ -48,13 +50,13 @@ id→ctx handoff is allowed in only three kinds of places (business providers ne
- Root coordination services self-addressing: from a projection's sessionId back to the actx via `sessions.scope(id)`.
- Root untagged listeners: looking up their own store by the payload's sessionId.
### Scope lifecycle: anchored to the list mirror — birth is entering view, death is prune
### Scope lifecycle: anchored to explicit references
Session instances share the scope's lifecycle; liveness eligibility = host-listed (one criterion, shared by mint and prune):
Session instances share the scope's lifecycle, while the catalog reports discoverability without retaining a generation:
- Birth = a session row entering client view (the list baseline pull / the local `create()` echo / the `host/session-added` frame); a lazy first resolve mints the scope (resolution is a pure function, render-safe).
- One prune tears down three things together: the Session instance, the scope fiber (cascading through every consumer hung on the actx), and the session-keyed slot store. The staged session (= `list.current`) is the exception: removed while still on stage, it keeps a frozen read-only view, torn down only once the stage moves away.
- Reopening = lazily rebuilding the instance + `open()` pulling history (the host session log is the durable truth).
- Birth = the first explicit `sessions.retain(target, options)`; it synchronously returns a reference and mints the Session binding and scope before history is ready.
- Final release withdraws the exact generation before tearing down its Session instance, scope fiber (cascading through every consumer hung on the actx), and session-keyed slot store. Catalog removal does not end a generation while references remain.
- Reopening = a later retain lazily rebuilding the generation and exposing history readiness through `reference.ready` (the Host Session log is the durable truth).
- Remaining TODO: approval/question frames never enter history and cannot be recovered across a prune (the manager-level pendingBuffers cover only the never-instantiated window).
### The blank bit: the empty session's visible projection, conversion, and reuse
@@ -67,7 +69,7 @@ A session "materialized but with no first prompt" is governed by the summary-der
- The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
- Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank;
- Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank.
- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the one with `session.id === sessions.current`, its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's current blank shows; the user-visible surface therefore holds at most one blank row globally.
- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the row retained by the `mainView` source, with its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's main blank shows; the user-visible surface therefore holds at most one blank row globally.
- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.
### connectWorkspace: the sole entry point of New Session
@@ -77,23 +79,23 @@ A session "materialized but with no first prompt" is governed by the summary-der
- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing.
- The create arm: on a miss, `session.create({workspaceId})` returns the new id.
- An unknown workspaceId fails loud (never silently creating somewhere else).
- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store and `sessions.binding(id)` resolves synchronously — `SessionRuntime.create` projects the list synchronously after RPC success before resolving, so a draft mover can write text into the new scope's machine before open, without waiting for a notifier flush.
- The caller takes the id and does its own `sessions.open`; sending the first prompt is an ordinary `session.prompt` — the session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again.
- The global New Session button defaults to `recentWorkspaceId`: first comparing each Workspace's newest Session `updatedAt`, falling back to the Workspace `createdAt` when it has no Sessions, and keeping host order on ties; only with no Workspace at all does it `sessions.clear()` into the no-session view. Create actions inside a Workspace group still hit that Workspace explicitly.
- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store. The view owner then retains it synchronously, so a draft mover can write text through that binding before history readiness without waiting for a notifier flush.
- The caller takes the id and installs a `mainView` reference; sending the first prompt is an ordinary `session.prompt` — the Session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again.
- The global New Session button defaults to `recentWorkspaceId`: first comparing each Workspace's newest Session `updatedAt`, falling back to the Workspace `createdAt` when it has no Sessions, and keeping Host order on ties; only with no Workspace at all does it clear the main-view reference into the no-Session view. Create actions inside a Workspace group still hit that Workspace explicitly.
- At startup the runtime subscribes to the first complete baseline: a successfully restored current session is kept in place; otherwise it automatically calls `connectWorkspace(recentWorkspaceId)` and opens the returned blank session. The policy settles only once; a later user-initiated clear is never overridden by auto-selection again, and a connect failure waits for the next baseline projection to retry.
- Re-picking the Workspace in the blank Hero also goes through `connectWorkspace`; when the target id differs from the current one, the current input machine's non-empty draft moves to the target scope first, then `sessions.open(nextId)`. The old blank entity is not deleted — it merely leaves the list by no longer being current.
- Re-picking the Workspace in the blank Hero also goes through `connectWorkspace`; when the target id differs from the main one, `ui-workspace` retains the target, moves the current input machine's non-empty draft through the preparation callback, and then publishes the new main reference. The old blank entity is not deleted — it merely leaves the list when its `mainView` reference is released.
### Per-session provisioning: the `sessions.provide` standard-kit channel
### Per-session provisioning: the `uiSession.provide` standard-kit channel
The sole provisioning path by which session slot components fetch their own session data. Plugins declare a fixed key map through the static descriptor `sessions.provide({hooks, props, resolve})` (a duplicate key throws at registration); `resolve(binding)` materializes values for a specific session and tears them down with the scope. ui-renderer's `standardKit` single loop binds the hooks compartment into `use<Name>` selector hooks (`observableHook`→uSES, anti-tearing) and passes the props compartment through as-is.
The sole provisioning path by which Session slot components fetch their own Session data. Plugins declare a fixed key map through the static descriptor `uiSession.provide({hooks, props, resolve})` (a duplicate key throws at registration); `resolve(binding)` materializes values for a specific binding and tears them down with its scope. ui-renderer's `standardKit` single loop binds the hooks compartment into `use<Name>` selector hooks (`observableHook`→uSES, anti-tearing) and passes the props compartment through as-is.
Slot scope is the closed set `root | session-maybe | session`:
- `root` receives only the global standard kit, with no session identity or provisioning.
- `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`.
- `session-maybe` inherits the nearest `SessionProvider` binding with ADOPTION identity: an incarnation born Session-less keeps its React instance when that Provider receives its first binding, then remounts when the Provider switches generation or returns to absence. Component-local per-Session state clears when the Provider switches generation. Across a switch, only persisted Store values survive generation retirement; binding-owned sources survive only when another reference keeps that generation alive. With no binding, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. Provider-roster changes rematerialize the mounted binding without changing its identity, while the per-entry adoption bookkeeping lives in the renderer's `SessionMaybeEntry`.
- `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store.
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no Session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a binding appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn.
@@ -19,6 +19,8 @@ web client 只有一张全局会话面:slot 全部从根上下文渲染,插
## 决策
[Client Session 引用](2026-09-15-client-session-references.zh.md)现已定义引用所有的生命周期与 Provider 定位。本 Note 保留 blank Session 与收养语义的理由,并描述它们的当前实现。
### 对等模型:client 与 host 同一根状态轴
host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作为不可拆分的原子整体);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上):
@@ -48,13 +50,13 @@ id→ctx 换乘只许三类位置(业务提供方永不换乘):
- root 协调服务自寻址:从投影的 sessionId 经 `sessions.scope(id)` 找回 actx。
- root untagged listener:按 payload 的 sessionId 查自有 store。
### scope 生命周期:挂靠 list mirror,出生即视野、死亡即 prune
### scope 生命周期:挂靠显式引用
Session 实例与 scope 同生命周期,存活资格 = host listed(一个判据,mint 与 prune 共用):
Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持有 generation:
- 出生 = 会话行进入 client 视野(list 基线拉取 / `create()` 本地回声 / `host/session-added` 帧),lazy 首次 resolve 铸 scope(resolution 纯函数、渲染安全)。
- prune 一次同拆三样:Session 实例、scope fiber(级联挂在 actx 上的一切消费方)、会话键控 slot store。暂存会话(= `list.current`)例外:被移除仍在台上时保留冻结只读视图,stage 移走才拆。
- 重开 = lazy 重建实例 + `open()` 拉 history(host 会话日志是持久真相)。
- 出生 = 第一次显式调用 `sessions.retain(target, options)`;它同步返回 reference,并在历史就绪前铸造 Session binding 与 scope。
- 最后一份 reference 释放时,Controller 先撤下确切 generation,再拆除其 Session 实例、scope fiber(级联挂在 actx 上的一切消费方)与会话键控 slot store。仍有 reference 时,catalog 移除不会结束 generation。
- 重开 = 后续 retain 惰性重建 generation,并通过 `reference.ready` 暴露历史就绪结果(Host Session 日志是持久真相)。
- 遗留 TODO:approval/question 帧不进 history,跨 prune 不可恢复(manager 级 pendingBuffers 只覆盖「从未实例化」窗口)。
### blank 位:空会话的可见投影、转正与复用
@@ -67,7 +69,7 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判
- 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。
- 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank;
- 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示 `session.id === sessions.current` 的一条,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。
- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。
- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
### connectWorkspace:New Session 的唯一入口
@@ -77,23 +79,23 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判
- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI(命令行界面)/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中直接返回该 id,不新建。
- 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。
- 未知 workspaceId fail loud(不静默创建到别处)。
- 解析保证(两臂同约定):promise resolve 时返回的 id 已在 list store 且 `sessions.binding(id)` 同步可解析——`SessionRuntime.create` 在 RPC 成功后同步投影列表再 resolve,使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。
- 调用方拿 id 自行 `sessions.open`;首条提示词发送就是普通 `session.prompt`——会话本来就在,失败即普通提示词失败,draft 文本还在 machine 里,重试即再次发送。
- 全局 New Session 按钮默认取 `recentWorkspaceId`:先比较各 Workspace 内 Session 的最新 `updatedAt`,无 Session 时回退 Workspace `createdAt`,同值保持 Host 顺序;只有完全没有 Workspace 时才 `sessions.clear()` 进入无会话视图。Workspace 分组内的创建动作仍显式命中该 Workspace。
- 解析保证(两臂同约定):promise resolve 时返回的 id 已在 list store。视图 owner 随后同步 retain,因此 draft 搬运方可以在历史就绪前通过该 binding 写入文本,无需等待 notifier flush。
- 调用方拿 id 安装一份 `mainView` reference;首条提示词发送就是普通 `session.prompt`——Session 本来就在,失败即普通提示词失败,draft 文本还在 machine 里,重试即再次发送。
- 全局 New Session 按钮默认取 `recentWorkspaceId`:先比较各 Workspace 内 Session 的最新 `updatedAt`,无 Session 时回退 Workspace `createdAt`,同值保持 Host 顺序;只有完全没有 Workspace 时才释放主视图 reference,进入无 Session 视图。Workspace 分组内的创建动作仍显式命中该 Workspace。
- 运行时启动时订阅首次完整基线:若已有恢复成功的 current 会话则保持不动,否则自动 `connectWorkspace(recentWorkspaceId)` 并 open 返回的 blank 会话。该策略只结算一次;之后用户主动 clear 不会再次被自动选择覆盖,连接失败则等下一次基线投影重试。
- blank Hero 中改选 Workspace 也走 `connectWorkspace`;若目标 id 与当前 id 不同,先把当前 input machine 的非空 draft 搬到目标 scope,再 `sessions.open(nextId)`。旧 blank 实体不删除,只因不再 current 而从列表隐藏。
- blank Hero 中改选 Workspace 也走 `connectWorkspace`;若目标 id 与主视图 id 不同,`ui-workspace` 先 retain 目标,通过 preparation callback 搬运当前 input machine 的非空 draft,再发布新的主 reference。旧 blank 实体不删除,只因其 `mainView` reference 被释放而从列表隐藏。
### 逐会话供数:`sessions.provide` 标准件通道
### 逐会话供数:`uiSession.provide` 标准件通道
会话 slot 组件「自己拿会话数据」的唯一供数路径。插件以静态描述符 `sessions.provide({hooks, props, resolve})` 声明固定键表(重名 key 注册时 throw),`resolve(binding)` 在确定会话下物化值并随 scope 拆;ui-renderer `standardKit` 统一循环把 hooks 格绑成 `use<Name>` 选择器钩子(`observableHook`→uSES,防 tearing)、props 格原样透传。
Session slot 组件「自己拿 Session 数据」的唯一供数路径。插件以静态描述符 `uiSession.provide({hooks, props, resolve})` 声明固定键表(重名 key 注册时 throw),`resolve(binding)` 在确定 binding 下物化值并随其 scope 拆;ui-renderer `standardKit` 统一循环把 hooks 格绑成 `use<Name>` 选择器钩子(`observableHook`→uSES,防 tearing)、props 格原样透传。
slot scope 是闭集 `root | session-maybe | session`:
- `root` 只拿全局标准件,不接收会话身份或供数。
- `session-maybe` 以**收养(adoption)身份语义**跟随 current 会话(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个**会话到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格会话 entry 完全一致——切到不同会话重挂,跌回无会话也重挂为崭新的空态化身(之后再次收养)。因此组件本地的逐会话状态**由构造保证**随切换清零;需要活过切换的状态必须住会话绑定的源(machine、store、hooks)。无会话时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅运行时的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无会话时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。
- `session-maybe` 以**收养(adoption)身份语义**继承最近 `SessionProvider` 的 binding:空态出生的化身在该 Provider 第一次收到 binding 时保持 React 实例,此后 Provider 切换 generation 或回到空态时重挂。Provider 切换 generation 时,组件本地的逐 Session 状态会清零。切换过程中,只有持久化 Store 值能活过 generation 退休;只有另一份 reference 保活该 generation 时,binding 自有 source 才能保留。无 binding 时,`sessionId`、`useSession`/`useInput` 的结果与 `inputActions` 均可缺省。Provider roster 变化会重新物化已挂载 binding,但不改变其 identity;逐 entry 的收养记账住在 renderer 的 `SessionMaybeEntry`。
- `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换会话会重建该 entry 及其会话 store。
`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无会话 → blank 会话的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 Session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。binding 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。
@@ -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-08-20-client-session-conversation-ownership.md
2026-08-20-client-session-conversation-ownership.md: 89f27f745c13e070b7f9794d98747bc73bda3b5a
2026-08-20-client-session-conversation-ownership.zh.md: 79be1b898a1fb5a75e5438d55d739d84775fe6d9
2026-08-20-client-session-conversation-ownership.md: 0d2f19cff1d4625d678a25c87bcc55fc3f2bfad4
2026-08-20-client-session-conversation-ownership.zh.md: fdb2420efff88e0a5a33dba79bebf0d072280453
@@ -38,7 +38,7 @@ Client Session and Workspace objects belong to `api/session-controller/client` a
The React adapters for Session and Workspace belong to `client/ui-session` and `client/ui-workspace`. The Store engine belongs to `client/store`; the Slot registry, scope materialization, and observable-to-hook binding belong to `client/ui-renderer`.
The system has no aggregate `client/runtime` package and no replacement central facade. [Session history and event transport](2026-08-18-session-history-and-event-transport.md) defines Session history, Remote streams, pagination cursors, and reconnect continuity; this note starts from the Client objects and sources published by Controllers.
The system has no aggregate `client/runtime` package and no replacement central facade. [Session history and event transport](2026-08-18-session-history-and-event-transport.md) defines history continuity. [Client Session references](2026-09-15-client-session-references.md) owns reference acquisition, exact-generation lifetimes, main-area ownership, and unified UI status; this note owns the layering and source-registration rules.
## Layering principles
@@ -55,9 +55,10 @@ Each standard hook belongs to the `ui-*` package closest to its data semantics.
| Hook | Owner | Source |
| --- | --- | --- |
| `useSessions` | `client/ui-session` | Session Controller global list |
| `useSession` | `client/ui-session` | Current Session snapshot |
| `useProjection` | `client/ui-session` | Current Session keyed projection |
| `useSessionPendingInteraction` | `client/ui-session` | Aggregated pending domains |
| `useSession` | `client/ui-session` | Bound Session snapshot |
| `useProjection` | `client/ui-session` | Bound Session keyed projection |
| `useSessionStatus` | `client/ui-session` | Running, effective pending request, and unread completion |
| `useSessionRetainInfo` | `client/ui-session` | Read-only Controller reference-source counts |
| `useWorkspaces` | `client/ui-workspace` | Workspace Controller list |
| `useConversation` | `client/ui-conversation` | Conversation binding snapshot |
| `useChat` | `client/ui-chat` | `chat` target source |
@@ -77,10 +78,10 @@ Adding a target does not add a branch to the renderer or Session Controller. The
| Package | Owns | Explicitly does not own |
| --- | --- | --- |
| `api/session-controller/client` | Session objects, list, selection, commands, projections, queue, event windows, and Agent Contexts | Conversation targets, React, Slots, Workspace |
| `api/session-controller/client` | Session objects, catalog, references, source counts, commands, projections, event windows, and Agent Contexts | Navigation, completion reminders, Conversation targets, React, Slots, Workspace |
| `api/workspace-controller/client` | Workspace objects, ordering, archive state, commands, and snapshots | React, Session navigation policy, directory UI |
| `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results |
| `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data |
| `client/ui-session` | Explicit Session scope, standard sources, `SessionProvider`, and unified UI status | Session transport, reference ownership, Conversation assembly, Approval/Question results |
| `client/ui-workspace` | Workspace hook, browser UI, main-area reference, and cross-Controller navigation policy | Workspace transport, copies of Session data |
| `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots |
| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, and locale | Session lifecycle, generic View navigation, Trajectory, historical-image cache |
| `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots |
@@ -117,9 +118,9 @@ Session data reaches the UI through this path:
useChat useTrajectory
```
Workspace data enters the Workspace Controller from `ctx.remote.workspace`, then `ui-workspace` exposes it as `useWorkspaces`. For cross-domain navigation, `ui-workspace` temporarily reads the Session Controller and issues a selection or command.
Workspace data enters the Workspace Controller from `ctx.remote.workspace`, then `ui-workspace` exposes it as `useWorkspaces`. `ui-workspace` reads explicit targets for cross-domain navigation and owns the main-area reference without making it a default business Context.
Approval and Question arrive from the Host waterfall through `ctx.remote.$on` at their respective UI owners. Each owner publishes a Pending object; `ui-session.pendingInteractions` then supplies that same object to Session navigation state and Conversation composer selection.
Approval and Question arrive from the Host waterfall through `ctx.remote.$on` at their respective UI owners. Each owner publishes a Pending object; `ui-session.sessionStatus` supplies the same effective object to Workspace indicators and Conversation composer selection.
## Session Controller Client
@@ -142,7 +143,7 @@ Whether a field derives from an event, control frame, or local command does not
The Session Controller exposes three distinct read faces:
1. The global Session list and current-selection source, used by navigation and `useSessions`.
1. The Session catalog and local ownership sources, used by `useSessions` and read-only reference metadata consumers.
2. A logical binding for each Session containing `sessionId`, a `SessionSnapshot` source, commands, and projection sources.
3. A Conversation-facing `SessionEventSource` used only by the Conversation assembly core.
@@ -160,9 +161,9 @@ Initial open, reconnect, gap repair, and updates whose continuity cannot be prov
### Session binding lifecycle
Each Session binding owns a Cordis Context and Fiber. The Session Controller creates and releases the binding.
Each live Session generation owns a Cordis Context and Fiber. The Controller creates its binding on acquisition and retires it on final reference release or root disposal.
Objects that depend on a Session register cleanup through `binding.ctx.effect()`. Releasing a binding cleans up Conversation bindings, UI materializations, and scoped Slot stores without a dedicated `onBindingRelease` or `onRelease` callback protocol.
Objects that depend on a Session register cleanup through `binding.ctx.effect()`. Generation retirement cleans up Conversation bindings, UI materializations, and scoped Slot stores without a dedicated `onBindingRelease` or `onRelease` callback protocol.
This cleanup does not require the Session Controller to know the roster of upper-layer consumers.
@@ -172,12 +173,12 @@ This cleanup does not require the Session Controller to know the roster of upper
`client/ui-session` is the sole Session adapter between the Session Controller and the React/Slot system. It provides `ctx.uiSession` and:
- observes the Session list, current selection, and per-Session bindings;
- observes the Session catalog, local reference metadata, and explicitly supplied bindings;
- installs the session and session-maybe scope adapters;
- supplies `SessionProvider` rendering semantics;
- supplies built-in Session snapshot, projection, and sessionId sources;
- accepts Session-scoped source contributions from other domain packages;
- aggregates pending interactions registered by business packages.
- aggregates domain-owned pending interactions with running and completion-reminder facts in `sessionStatus`.
It does not own Session transport, event folding, Conversation targets, or concrete business results.
@@ -193,12 +194,12 @@ The runtime rejects undeclared, missing, or duplicate standard props. `ui-sessio
session and session-maybe use the same adapter with different binding semantics:
- a strict session scope refuses to render without a current binding;
- a strict session scope refuses to render without an explicitly supplied binding;
- session-maybe uses a stable absent binding to preserve hook call order;
- changing the current Session rebuilds the strict Session subtree under the `sessionId` key;
- root and session-maybe entries may remain mounted across Session changes.
- changing the exact Context generation remounts a bound subtree, including same-id replacement;
- an unbound session-maybe entry adopts its first binding without remounting; root entries have no Session binding.
Each real materialized binding retains the Controller binding's Context. `ui-session` removes the cache entry and withdraws the current binding through `binding.ctx.effect()`.
Each UI materialization borrows the Controller binding's Context. `ui-session` removes that generation's cache entry and publishes absence through `binding.ctx.effect()`; it does not retain the Session.
Changing the contribution roster rematerializes existing bindings and publishes a new source set. Source identity remains stable within one binding lifetime, as required by `useSyncExternalStore` caching.
@@ -206,9 +207,9 @@ Changing the contribution roster rematerializes existing bindings and publishes
`SessionProvider` is a standard seat derived by `PropsRenderSlots` from a session-scoped child declaration, not a React Context imported directly by business components.
It accepts ordinary `ReactNode` children rather than a `(sessionId) => ReactNode` render function; callers wrap `renderSlot('details', {})` directly.
It accepts ordinary `ReactNode` children and a required `session={reference | undefined}`. The Provider borrows the caller-owned reference without acquiring or releasing it; callers wrap `renderSlot('details', {})` directly.
Session identity comes from the scope binding and standard `sessionId` prop. The Provider handles only the absent branch and subtree isolation by Session identity; components do not obtain Session data through a Provider callback.
Session identity reaches components through the explicit scope binding and standard `sessionId` prop. An absent Provider stays unbound, and neither nested Providers nor root entries fall back to a main-area Session.
### Pending interactions
@@ -220,7 +221,7 @@ Concurrent objects with the same key are rejected; replacement requests use a ne
`ui-session` selects each Session's effective object using domain precedence. Higher precedence wins; at equal precedence, the later valid object in traversal order wins.
The aggregate is published as `pendingInteractions: ObservableSnapshot<ReadonlyMap<SessionId, SessionPendingInteraction>>`; `useSessionPendingInteraction` is its React read face.
The pending aggregate is private to `ui-session`; its effective request appears unchanged as `sessionStatus.getSnapshot().get(id)?.pendingInteraction`. `useSessionStatus` is the public UI read face.
Session navigation state and composer takeover read the same effective object. They do not maintain separate status maps or takeover rosters.
@@ -242,7 +243,7 @@ These combined facts do not enter `WorkspaceSnapshot`:
`client/ui-workspace` registers the Workspace list source as the root standard source `workspaces`, from which the renderer provides `useWorkspaces`.
Initial selection, blank-Session reuse, new-session navigation, concurrent-create coalescing, and navigation after archival are UI navigation policy. That policy may read both `ctx.workspaces` and `ctx.sessions` at decision time, but it issues only Controller commands and selection actions and does not publish a combined snapshot.
Initial restoration, blank-Session reuse, new-session navigation, concurrent-create coalescing, and navigation after archival are UI policy. `ui-workspace` may read both Controllers, but it keeps the main target and reference in its own navigation owner instead of writing UI selection into a Controller snapshot.
Directory pickers, directory browsing, and `openPath` are separate directory capabilities and do not enter the Workspace Controller.
@@ -286,7 +287,7 @@ The shell phase is a pure composition of Session lifecycle and Conversation targ
### Input and composer
The composer chain belongs to `ui-conversation`; a concrete takeover belongs to its business package. `ConversationRoot` reads the current Session's effective object through `useSessionPendingInteraction` and supplies it to chain selectors as `ComposerChainProps.pendingInteraction`.
The composer chain belongs to `ui-conversation`; a concrete takeover belongs to its business package. `ConversationRoot` reads its bound Session's effective request through `useSessionStatus` and supplies it to chain selectors as `ComposerChainProps.pendingInteraction`.
A selector is a pure function of owner currency. Its non-null result reaches the selected component as `matched`. A stable composer entry and the default composer remain mounted together, while the chain selects one effective presentation.
@@ -334,7 +335,7 @@ The Gateway requires only that Remote Event arguments and results are valid JSON
### One pending projection
The Sidebar and composer consume the same `pendingInteractions` snapshot. Navigation displays approval, plan-review, or question state from the effective object's `kind`; each composer entry selects its own panel by object identity.
The Sidebar and composer consume the same effective `sessionStatus` pending request. Navigation displays approval, plan-review, or question state from its `kind`; each composer entry selects its own panel by object identity.
The same request identity drives both UI surfaces. A request that replaces another request of the same type uses a new key, so selectors and subscribers observe the identity change.
@@ -366,7 +367,7 @@ Stores hold viewing and interaction state such as drafts, View selection, Chat s
When one plugin provides both a source and a Slot entry, it registers the source first and the entry second. Reverse Cordis disposal then removes the entry before the source, so a mounted entry never briefly loses a required hook.
Releasing a Session binding cleans up UI materialization and scoped Stores through `binding.ctx.effect()`. Releasing a plugin fiber cleans up sources, listeners, and Slot entries through registration disposers.
Final Session-reference release cleans up UI materialization and scoped Stores through `binding.ctx.effect()`. Releasing a plugin fiber cleans up sources, listeners, and Slot entries through registration disposers.
Every disposer is idempotent and depends on no implicit callback outside the Cordis lifecycle.
@@ -386,7 +387,7 @@ UI components do not receive `ctx`. Cross-package collaboration uses Cordis serv
Before adding state, choose its sole owner from its consumption semantics: Host communication, commands, and entity lifecycle belong to an API Controller; data assembled from Session events but independent of a target belongs to the Conversation core; projections serving only one View belong to that target package; drafts, selections, and panel state belong to the UI package that owns the interaction.
The same fact must not be retained simultaneously in a Controller snapshot, Conversation snapshot, and Store. A cross-domain decision reads multiple sources and immediately issues a command; it does not create a joined snapshot or cache another domain's object.
The same fact must not be retained simultaneously in a Controller snapshot, Conversation snapshot, and Store. Cross-domain navigation reads sources at decision time. A UI-owned status source may compose independent running, pending-request, and completion-reminder facts, but must preserve domain ownership and object identity rather than duplicate those domains' state.
These are signs of incorrect ownership: a Controller imports React; the renderer branches on business types; a component traverses Session events; a Store holds Session or Workspace entities; changing one target requires changing the Session Controller.
@@ -421,7 +422,7 @@ A target must not use another target's snapshot as its data source. Optional col
5. When it can handle the request, create the Pending object, publish it through the publication function, await its result, and remove it in `finally`.
6. Test concurrent keys, precedence, user cancellation, transport abort, plugin disposal, and delegation without a Session.
A request does not register Slots, declare child Slots, mutate the Session snapshot, or create a separate state index. Sidebar and composer both read one effective object from `useSessionPendingInteraction`.
A request does not register Slots, declare child Slots, mutate the Session snapshot, or create a separate state index. Sidebar and composer read the same effective object from `useSessionStatus`.
### Review checks
@@ -38,7 +38,7 @@ Session 与 Workspace 的 Client 对象分别归 `api/session-controller/client`
Session 与 Workspace 的 React 适配分别归 `client/ui-session` 和 `client/ui-workspace`。Store engine 归 `client/store`,Slot registry、scope materialization 和 observable-to-hook 绑定归 `client/ui-renderer`。
系统不提供聚合式 `client/runtime` package,也不设置替代它的总控 facade。Session history、Remote stream、分页 cursor 和重连连续性由 [Session 历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md) 定义;本 Note 从 Controller 发布的 Client 对象与 source 开始。
系统没有聚合式 `client/runtime` 包,也没有替代的中央 facade。[Session 历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md)定义历史连续性。[Client 会话引用](2026-09-15-client-session-references.zh.md)拥有引用获取、精确代际生命周期、主区域所有权和统一 UI 状态;本篇拥有分层与数据源注册规则。
## 分层原则
@@ -55,9 +55,10 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把
| Hook | Owner | Source |
| --- | --- | --- |
| `useSessions` | `client/ui-session` | Session Controller 全局列表 |
| `useSession` | `client/ui-session` | 当前 Session snapshot |
| `useProjection` | `client/ui-session` | 当前 Session keyed projection |
| `useSessionPendingInteraction` | `client/ui-session` | pending domain 聚合结果 |
| `useSession` | `client/ui-session` | 已绑定会话快照 |
| `useProjection` | `client/ui-session` | 已绑定会话的键控投影 |
| `useSessionStatus` | `client/ui-session` | 运行状态、有效待处理请求和未读完成提醒 |
| `useSessionRetainInfo` | `client/ui-session` | 控制器的只读引用来源计数 |
| `useWorkspaces` | `client/ui-workspace` | Workspace Controller 列表 |
| `useConversation` | `client/ui-conversation` | Conversation binding snapshot |
| `useChat` | `client/ui-chat` | `chat` target source |
@@ -77,10 +78,10 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把
| Package | 拥有内容 | 明确不拥有 |
| --- | --- | --- |
| `api/session-controller/client` | Session 对象、列表、选择、命令、projection、queue、事件窗口和 Agent Context | Conversation target、React、Slot、Workspace |
| `api/session-controller/client` | 会话对象、目录、引用、来源计数、命令、投影、事件窗口与 Agent Context | 导航、完成提醒、Conversation target、React、Slot、Workspace |
| `api/workspace-controller/client` | Workspace 对象、顺序、归档、命令和 snapshot | React、Session 导航策略、目录 UI |
| `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 |
| `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 |
| `client/ui-session` | 显式会话作用域、标准数据源、`SessionProvider` 与统一 UI 状态 | 会话传输、引用所有权、Conversation 组装、Approval/Question 结果 |
| `client/ui-workspace` | Workspace 钩子、浏览器 UI、主区域引用与跨控制器导航策略 | Workspace 传输、会话数据副本 |
| `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot |
| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details 和 locale | Session 生命周期、通用 View 导航、Trajectory、历史图片 cache |
| `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot |
@@ -117,9 +118,9 @@ Session 数据按以下路径进入 UI:
useChat useTrajectory
```
Workspace 数据从 `ctx.remote.workspace` 进入 Workspace Controller,再由 `ui-workspace` 暴露为 `useWorkspaces`;需要跨域导航时,`ui-workspace` 临时读取 Session Controller 并发出选择或命令。
Workspace 数据由 `ctx.remote.workspace` 进入 Workspace 控制器,再由 `ui-workspace` 通过 `useWorkspaces` 提供。`ui-workspace` 为跨领域导航读取显式目标并持有主区域引用,不把它变为默认业务 Context。
Approval 与 Question 从 Host waterfall 经 `ctx.remote.$on` 到达各自 UI owner。Owner 发布 Pending 对象,`ui-session.pendingInteractions` 再把同一对象送往 Session 导航状态和 Conversation composer selection。
Approval 与 Question 通过 `ctx.remote.$on` 从 Host waterfall 到达各自的 UI owner。各 owner 发布 Pending 对象;`ui-session.sessionStatus` 向 Workspace 标识和 Conversation composer 选择提供同一个有效对象。
## Session Controller Client
@@ -142,7 +143,7 @@ Approval 与 Question 从 Host waterfall 经 `ctx.remote.$on` 到达各自 UI ow
Session Controller 对外提供三个互不替代的读取面:
1. 全局 Session list 与 current selection source,供导航和 `useSessions` 使用。
1. 会话目录与本地所有权数据源,由 `useSessions` 和只读引用元数据消费方使用。
2. 每个 Session 的逻辑 binding,包含 `sessionId`、`SessionSnapshot` source、commands 与 projection sources。
3. Conversation-facing `SessionEventSource`,只供 Conversation assemble core 使用。
@@ -160,9 +161,9 @@ Session Controller 对外提供三个互不替代的读取面:
### Session binding 生命周期
每个 Session binding 持有自己的 Cordis Context 与 Fiber。Session Controller 创建 binding,也负责释放它。
每个活跃会话 generation 持有 Cordis Context 与 Fiber。控制器在获取引用时创建绑定,并在最后一份引用释放或根销毁时结束该绑定。
依赖 Session 的对象把清理注册到 `binding.ctx.effect()`。Binding 释放会触发 Conversation binding、UI materialization 和 scoped Slot store 的清理,不存在额外的 `onBindingRelease` 或 `onRelease` 回调协议。
依赖会话的对象通过 `binding.ctx.effect()` 注册清理。generation 结束会清理 Conversation 绑定、UI 物化结果和作用域 Slot 存储,无需单独的 `onBindingRelease` 或 `onRelease` 回调协议。
这种清理方式不要求 Session Controller 了解上层消费者名册。
@@ -172,12 +173,12 @@ Session Controller 对外提供三个互不替代的读取面:
`client/ui-session` 是 Session Controller 与 React/Slot 系统之间唯一的 Session adapter。它提供 `ctx.uiSession`,并负责:
- 观察 Session list、current selection 和 per-Session binding;
- 观察会话目录、本地引用元数据和显式提供的绑定;
- 安装 session 与 session-maybe scope adapter;
- 提供 `SessionProvider` 的呈现语义;
- 内建 session snapshot、projection 和 sessionId source;
- 接收其他领域 package 的 Session-scoped source contribution;
- 聚合业务 package 注册的 pending interaction。
- 在 `sessionStatus` 中组合领域持有的待处理交互、运行事实和完成提醒。
它不拥有 Session transport、event folding、Conversation target 或具体业务结果。
@@ -193,12 +194,12 @@ Session Controller 对外提供三个互不替代的读取面:
session 与 session-maybe 使用同一个 adapter,但绑定语义不同:
- strict session scope 在没有 current binding 时拒绝渲染;
- 严格会话作用域在没有显式提供的绑定时拒绝渲染;
- session-maybe 使用稳定 absent binding,保持 hook 调用顺序;
- current Session 切换以 `sessionId` 为 key 重建严格 Session subtree;
- root 与 session-maybe entry 可以跨 Session 切换常驻。
- 精确 Context generation 改变时重新挂载已绑定子树,同一 id 的替代 generation 也如此;
- 未绑定的 session-maybe 条目无需重新挂载即可接纳首个绑定;root 条目没有会话绑定。
每个真实 materialized binding 保留 Controller binding 的 Context。`ui-session` 通过 `binding.ctx.effect()` 删除缓存项并撤销 current binding。
每个 UI 物化结果借用控制器绑定的 Context。`ui-session` 通过 `binding.ctx.effect()` 移除该 generation 的缓存项并发布空值,不会 retain 会话。
Contribution roster 变化会重建已 materialize 的 binding 并发布新的 source 集合。同一 binding 生命周期内,source identity 保持稳定,以满足 `useSyncExternalStore` 的缓存要求。
@@ -206,9 +207,9 @@ Contribution roster 变化会重建已 materialize 的 binding 并发布新的 s
`SessionProvider` 是 `PropsRenderSlots` 根据 session-scoped child 声明派生的标准席,不是业务 component 直接 import 的 React Context。
它接收普通 `ReactNode` children,不接收 `(sessionId) => ReactNode` render function;调用方直接用它包裹 `renderSlot('details', {})`。
它接收普通 `ReactNode` children 和必填的 `session={reference | undefined}`。Provider 借用调用方持有的引用,不获取或释放它;调用方直接包住 `renderSlot('details', {})`。
Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provider 只负责 absent branch 与按 Session identity 隔离 subtree,组件不得借助 Provider 回调取得 Session 数据。
会话身份通过显式作用域绑定和标准 `sessionId` prop 到达组件。空 Provider 保持未绑定,嵌套 Provider 和 root 条目都不会回退到主区域会话。
### Pending interaction
@@ -220,7 +221,7 @@ Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provid
`ui-session` 使用各 domain 的 precedence 选出每个 Session 当前生效的对象。较高 precedence 胜出,相同 precedence 下后遍历到的有效对象胜出。
聚合结果发布为 `pendingInteractions: ObservableSnapshot<ReadonlyMap<SessionId, SessionPendingInteraction>>`,`useSessionPendingInteraction` 是其 React 读取面。
待处理聚合是 `ui-session` 的私有实现;其有效请求原样出现在 `sessionStatus.getSnapshot().get(id)?.pendingInteraction` 中。`useSessionStatus` 是公开 UI 读取接口。
Session 导航状态和 composer takeover 必须读取同一个 effective object,不得分别维护 status map 或 takeover roster。
@@ -242,7 +243,7 @@ Session 导航状态和 composer takeover 必须读取同一个 effective object
`client/ui-workspace` 把 Workspace list source 注册为 root 标准 source `workspaces`,renderer 由此提供 `useWorkspaces`。
初始选择、blank Session 复用、新建导航、并发创建合并和归档后导航属于 UI navigation policy。该 policy 可以在决定时同时读取 `ctx.workspaces` 与 `ctx.sessions`,但只调用 Controller command 和 selection action,不发布联合 snapshot。
启动恢复、空白会话复用、新会话导航、并发创建合并与归档后的导航属于 UI 策略。`ui-workspace` 可以读取两个控制器,但把主目标和引用保存在自己的导航 owner 中,不向控制器快照写入 UI 选择。
目录 picker、目录浏览和 `openPath` 属于独立目录能力,不进入 Workspace Controller。
@@ -286,7 +287,7 @@ Shell phase 由 Session lifecycle 与 Conversation target activity 纯合成。S
### Input 与 composer
Composer chain 属于 `ui-conversation`,具体 takeover 属于业务 package。`ConversationRoot` 从 `useSessionPendingInteraction` 读取当前 Session 的 effective object,并作为 `ComposerChainProps.pendingInteraction` 交给 chain selector。
composer chain 属于 `ui-conversation`,具体接管属于业务包。`ConversationRoot` 通过 `useSessionStatus` 读取已绑定会话的有效请求,并作为 `ComposerChainProps.pendingInteraction` 提供给 chain selector。
Selector 是 owner currency 的纯函数,非 null 结果作为 `matched` 传给获选 component。Stable composer entry 与默认 composer 可以同时常驻,chain 只选择一个有效呈现。
@@ -334,7 +335,7 @@ Gateway 只要求 Remote Event 参数和结果是合法 JSON 传输值,不复
### 单一 pending 投影
Sidebar 与 composer 使用相同 `pendingInteractions` snapshot。导航根据 effective object 的 `kind` 显示审批、计划审阅或问题状态,composer entry 根据对象实例选择自己的面板。
Sidebar 与 composer 消费 `sessionStatus` 中同一个有效待处理请求。导航根据其 `kind` 显示审批、计划审阅或问题状态;每个 composer 条目按对象身份选择自己的面板。
同一请求 identity 同时驱动两处 UI。新请求替换同类型旧请求时使用新 key,因此 selector 与订阅者都观察到身份变化。
@@ -366,7 +367,7 @@ Store 只承载 draft、View selection、Chat selection、inspection request 和
一个 plugin 同时提供 source 与 Slot entry 时,先注册 source,再注册 entry。Cordis 反向 disposal 先移除 entry,再移除 source,仍挂载的 entry 因而不会短暂失去必需 hook。
Session binding 释放通过 `binding.ctx.effect()` 清理 UI materialization 与 scoped store。Plugin fiber 释放通过 registration disposer 清理 source、listener 和 Slot entry。
最后一份会话引用释放后,通过 `binding.ctx.effect()` 清理 UI 物化结果和作用域存储。插件 fiber 释放通过注册 disposer 清理数据源、监听器和 Slot 条目。
所有 disposer 都可重复调用,不依赖 Cordis 生命周期以外的隐式回调。
@@ -386,7 +387,7 @@ UI component 不接收 `ctx`。跨 package 协作使用 Cordis service、standar
新增状态前先按消费语义确定唯一 owner:Host 通信、命令和实体生命周期归 API Controller;由 Session events 形成且与 target 无关的数据归 Conversation core;只服务一种 View 的投影归对应 target package;草稿、选择和面板状态归拥有该交互的 UI package。
同一事实不得同时保存在 Controller snapshot、Conversation snapshot 和 Store。需要跨域决策时读取多个 source 并立即发出 command,不创建联合 snapshot,也不缓存另一领域的对象副本。
同一个事实不能同时保存在控制器快照、Conversation 快照和存储中。跨领域导航在决策时读取数据源。UI 持有的状态数据源可以组合独立的运行、待处理请求和完成提醒事实,但必须保留领域归属与对象身份,不能复制这些领域的状态。
以下信号表示 owner 选择错误:Controller 开始 import React;renderer 出现业务类型分支;组件遍历 Session events;Store 保存 Session 或 Workspace 实体;一个 target 的变化要求修改 Session Controller。
@@ -421,7 +422,7 @@ Target 不得读取另一个 target 的 snapshot 作为自己的数据源。可
5. 可处理时创建 Pending 对象,使用 publication function 发布,等待结果,并在 `finally` 中移除。
6. 测试并发 key、precedence、用户取消、transport abort、plugin disposal 和无 Session delegation。
单次请求不得注册 Slot、声明 child Slot、修改 Session snapshot 或另建状态索引。Sidebar 与 composer 都从 `useSessionPendingInteraction` 读取同一个 effective object。
请求不注册 Slot、不声明子 Slot、不修改会话快照,也不创建独立状态索引。Sidebar 与 composer 从 `useSessionStatus` 读取同一个有效对象。
### Review 检查点
@@ -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-09-08-global-main-panels.md
2026-09-08-global-main-panels.md: 6aa7df2dcdd28b94cc0084a0044764efb2e749e2
2026-09-08-global-main-panels.zh.md: bbb10b2ef5784748a165ad54c907fac2a63c9b17
2026-09-08-global-main-panels.md: 63530a6b498bd8099cf627d82c888846d3d96767
2026-09-08-global-main-panels.zh.md: 776787c3de3b79ca7948dcf949ac0393f87e7808
@@ -10,13 +10,13 @@ Plugins need application-wide views that do not belong to a Session. A Session-s
## Decision
The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding.
The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to Conversation. `ui-session` derives the root Session binding from `uiWorkspace`'s `mainView` ownership marker; `main.conversation` and its associated right Sidebar inherit that Provider binding. Other main entries receive no implicit Session binding. [Client Session references](2026-09-15-client-session-references.md) owns acquisition and source metadata; this note owns global panel selection.
The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registered no panel entry when this landed, so an empty list has no DOM or spacing; the web bundle's plugin manager now registers the first one ([plugin management moves to the Web sidebar](2026-09-09-plugin-management-in-the-web-sidebar.md)). Selection validates the live main entry and rejects a missing key without replacing the current panel.
One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame.
`uiWorkspace.openSession(id)` selects the Session before returning the main area to the Conversation, including when the same Session is selected again. `openWorkspace` and `forkSession` use the layout's `beginNavigation()` abort signal and their own service lifetime to commit only the latest navigation. The Workspace preparation callback moves drafts synchronously only while the request remains current. Supersession prevents a late UI commit, not Session creation. Panel navigation neither cancels the retained Session nor writes a Session event.
`uiWorkspace.openSession(target)` acquires the explicit target before replacing its main reference and returning the main area to Conversation. `openWorkspace` and `forkSession` keep the layout's existing `beginNavigation()` signal and service lifetime. `openWorkspace` runs its existing synchronous preparation after acquisition and before replacing the main reference. Supersession prevents a late UI commit, not Session creation. Direct Session opening adds no global navigation cancellation. Panel navigation neither releases the retained main Session nor writes a Session event.
DOM focus is not navigation selection. Search and directory-picker controls can receive focus while the global panel and its selected sidebar row remain visible; opening a Session changes the main selection.
@@ -10,13 +10,13 @@ Status: implemented
## 决策
布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。
布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation。`ui-session` 根据 `uiWorkspace` 的 `mainView` 所有权标记得出根 Session binding;`main.conversation` 及其关联右 Sidebar 继承该 Provider binding。其他主面板条目不获得隐式会话绑定。[Client 会话引用](2026-09-15-client-session-references.zh.md)拥有引用获取与来源元数据;本篇拥有全局面板选择。
侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。本决定落地时默认组合不注册面板条目,因此空列表没有 DOM 或间距;现在 web bundle 的插件管理器注册了第一个条目([插件管理移到 Web 侧栏](2026-09-09-plugin-management-in-the-web-sidebar.zh.md))。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。
`uiWorkspace.openSession(id)` 先选中会话,再将中央区域切回 Conversation,包括再次选中同一个会话的情况。`openWorkspace` 和 `forkSession` 使用布局的 `beginNavigation()` abort signal 与自身 service 生命周期,只提交最新导航。工作区准备回调仅在请求仍有效时同步搬移草稿。请求过期会阻止晚到的 UI 提交,但不阻止会话创建。面板导航既不取消保留的会话,也不写入会话事件。
`uiWorkspace.openSession(target)` 先获取显式目标,再替换主引用并让中央区域返回 Conversation。`openWorkspace` 和 `forkSession` 保留布局既有的 `beginNavigation()` 信号与服务生命周期。`openWorkspace` 在获取完成后、替换主引用前执行既有的同步准备动作。请求被替代会阻止迟到的 UI 提交,不阻止会话创建。直接打开会话不增加全局导航取消。面板导航既不释放所持主会话,也不写入会话事件。
DOM 焦点不是导航选中态。搜索和目录选择控件可以获得焦点,同时保留全局面板及其侧栏行的选中态;打开会话才改变中央区域的选中态。
@@ -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-09-09-consumer-owned-startup-strictness.md
2026-09-09-consumer-owned-startup-strictness.md: e009e66ead25ef0a5e6001d33663e32bc04d19d2
2026-09-09-consumer-owned-startup-strictness.zh.md: 58c364056f5b0dc41e018cd5488be983662401d4
2026-09-09-consumer-owned-startup-strictness.md: 2f16078c7051b4038c1d48120b500f0fcd465aef
2026-09-09-consumer-owned-startup-strictness.zh.md: c0fe61e5f708f79f6a5b903f3157ee358c3086bc
@@ -28,11 +28,13 @@ This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transpor
## Consequences
Stable required entry ids are part of application assembly. Renaming one requires updating the list and its tests. Optional plugin failures remain visible in Loader state and stderr without tearing down active siblings. Required failures use the same detailed import, activation, or pending-service diagnostic before app-boot disposes the root.
Stable required entry ids are part of application assembly. Renaming one requires updating the list and its tests. Optional plugin failures remain visible in Loader state and stderr without tearing down active siblings. Required failures combine every inactive entry into one diagnostic, separating failed plugins from pending services and marking required entries. `StartupError` retains the original failures as its cause after app-boot disposes the root. The CLI prints its message once and exits with code 1, avoiding duplicate wrapper stacks while preserving plugin stacks, nested causes, and aggregate members. The CLI saves original errors, inactive-entry metadata, and startup warning/error records in a unique report directly under `$DSH_HOME/logs/`. This preserves import errors and error properties that the concise terminal output omits. Failed writes fall back to the full report on stderr and keep exit code 1. Unrelated exceptions remain unhandled.
The compact terminal report keeps the failing plugins visible; a separate file retains raw diagnostics without the default logger buffer's record limit. Raw error values remain intact, so a sharing warning accompanies the report rather than silently redacting fields. An independent exporter lifetime covers asynchronous application disposal. The CLI awaits stderr completion before explicitly exiting, because failed plugins can leave stdin or other handles open.
## Testing
App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate.
App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. Unit expectations pin diagnostic grouping, preservation of original error objects and import logs, exporter cleanup, complete diagnostic values, private file creation, concurrent report names, and failed-write fallback. The built Web-profile acceptance asserts a single port-conflict stack without Node wrapper output, verifies the saved diagnostic file and its stderr fallback, serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate.
The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together.
@@ -28,11 +28,13 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles
## 后果
稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 使用相同的详细 import、activation 或 pending-service 诊断,然后由 app-boot 拆卸 root。
稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 将所有 inactive entry 合并到一份诊断中,区分失败插件与等待服务的插件,并标记 required entry。App-boot 拆卸 root 后,`StartupError` 仍以 cause 保留原始失败。CLI 仅输出其消息一次,并以退出码 1 结束,避免重复的包装堆栈,同时保留插件堆栈、嵌套原因和聚合错误成员。CLI 将原始错误、未激活条目的元数据及启动警告、错误记录保存到直接位于 `$DSH_HOME/logs/` 下的唯一报告中,保留简洁终端输出省略的导入错误和错误属性。写入失败时,完整报告回退到 stderr,退出码仍为 1。其他异常继续作为未处理异常抛出。
简洁的终端报告突出失败插件;单独文件保存原始诊断,不受默认 logger 缓冲区记录数限制。原始错误值保持完整,因此报告附带分享提醒,不会静默脱敏字段。独立的 exporter 生命周期覆盖应用的异步资源释放。CLI 等待 stderr 写入完成后明确退出,因为失败插件可能留下 stdin 或其他打开的句柄。
## 测试
App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。
App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。单元预期输出固定诊断分组、原始错误对象与导入日志的保留、exporter 清理、完整诊断值、私有文件创建、并发报告命名以及写入失败回退行为。构建后的 Web-profile acceptance 断言端口冲突堆栈只输出一次且不包含 Node 包装输出,验证已保存的诊断文件及其 stderr 回退,并会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。
[Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。
@@ -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-09-15-bounded-office-conversion.md
2026-09-15-bounded-office-conversion.md: 79ee82c7a84f6a0495ccfca59cc1e215ceef95b6
2026-09-15-bounded-office-conversion.zh.md: 5cf38d0e4459fba0ef2b5a185de6178ede09cb3b
2026-09-15-bounded-office-conversion.md: 78a2fc46d97500575262d2263bf5549037872264
2026-09-15-bounded-office-conversion.zh.md: 88cb15c8cbf2200a9365b9a42cb5802c1bfb65d8
@@ -30,10 +30,10 @@ Foreground preview and explicit QA requests precede background work. Disabling b
**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs.
Host workspace-file reads return raw bytes within the reserved capacity and delegate complete bounded reading to `fs.readBytes`. Base64 encoding belongs to Remote responses, so Host conversion does not allocate an encoded source string or a decoded copy.
## Consequences
The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; Remote base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish.
Office preview checks source authorization and versions through Workspace Files, then reads raw input with `fs.readBytes` within its conversion reservation. Office input limits govern that read. The Host conversion path avoids base64 source allocation; PDF responses encode only the converted output.
Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers.
@@ -30,10 +30,10 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。
Host 工作区文件读取在预留容量内返回原始字节,并将完整有界读取交给 `fs.readBytes`。base64 编码由 Remote 响应负责,因此 Host 转换无需分配编码后的源字符串或解码副本。
## 后果
缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;Remote base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。
Office 预览通过 Workspace Files 检查源文件授权与版本,再使用 `fs.readBytes` 在转换预留容量内读取原始输入。该读取受 Office 输入上限约束。Host 转换路径不分配 base64 源数据;PDF 响应只编码转换后的输出。
受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。
@@ -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-09-15-client-session-references.md
2026-09-15-client-session-references.md: 2d727809b237ca919296dac82a3b201ba2dd8fbf
2026-09-15-client-session-references.zh.md: 34fa6526a57f43fd7636c68c7ed72bb982a129f2
@@ -0,0 +1,226 @@
# Agent Note: Client Session references, reference sources, and UI status
Status: implemented
English | [中文](2026-09-15-client-session-references.zh.md)
## Problem
The Session catalog, live Client objects, views, and asynchronous operations have different lifetimes. Catalog membership does not establish ongoing use. A borrowed binding cannot protect asynchronous work or distinguish successive Client generations with the same Session id. A global current Session makes independently bound components act on another view's Session.
A reference count identifies ongoing use but not its consumers. Main-area highlighting, Sidebar ownership, and background operations need consumer-source information. Pending interactions and completion reminders also need one UI read interface so Workspace and Conversation do not independently combine the same status.
Client history access and Host Agent execution have independent lifetimes. History opening can fail; local Context acquisition need not read history. Explicit ownership must preserve navigation, loading, error handling, and recovery behavior without adding unrelated UI policy.
## Decision
### Scope and ownership
Client Session objects, Agent-scoped Client Contexts, references, consumer-source metadata, UI status, and explicit Provider composition use the ownership rules below. Host Session and Agent lifetimes, SlotFactory, activity dashboards, durable Session formats, and both SDKs' Host protocols remain independent.
| Owner | Responsibility |
| --- | --- |
| Client Session Controller | Catalog, live generations, references, source counts, bindings, history windows, and existing Session control state |
| `ui-session` | Explicit Session Provider integration and the unified UI status source |
| View or operation | Its own reference, target, source identifier, and release point |
| Workspace UI | Main-area target and reference, navigation, persisted target, and creation flow |
| UI composition boundary | Supplies an owner-provided reference to one explicit `SessionProvider` |
| Conversation and Sidebar subtrees | Consume only their Provider's Session, never a main-area reference or global selection |
| Client Gateway | Invocation-lifetime Context ownership while handling a Host event |
The [Client layering design](../../implemented/architecture/2026-08-20-client-session-conversation-ownership.md) defines one-way data, adapter, renderer, and presentation dependencies. Reference-source bookkeeping does not give the Controller a dependency on UI packages.
This decision partially supersedes the list-selected scope lifecycle in the [Web Client Session scope and provide-channel decision](2026-07-25-web-client-session-scope-and-provide-channel.md); that note retains the blank-Session and adoption rationale under explicit Provider ownership.
### Addresses, bindings, and references
`SessionTarget` is a known `SessionId` or a durable direct-parent `SubagentAddress`. It identifies what to acquire; it owns nothing. The Controller resolves an explicit address without requiring a preloaded parent catalog, while the Host validates its parent, child, and mode when history opens. Child discovery and an already known child address remain distinct from retaining the child. Navigation uses the same target representation rather than adding a separate navigation address.
`SessionBinding` is the shared Client generation: `sessionId`, Session face, event source, and scoped Context. Multiple references to the same live generation share this binding. A new generation with the same id has a different binding and Context.
`SessionReference` owns one use of one exact generation. It exposes read-only `sessionId` and `binding`, a `ready` Promise for the shared initial history opening, plus idempotent `release()` and `Symbol.dispose`. `ready` resolves to the exact binding when the corresponding `Session.open()` attempt resolves, including its stateful Remote-failure result. Reading `binding` after release or generation disposal fails. Retaining, releasing, or inspecting a reference does not create durable Sessions or start, stop, or retain a Host Agent.
`SessionBinding` is a borrowed value and owns no lifetime. Only unreleased `SessionReference` objects contribute to source and total reference counts. Synchronous code may borrow a binding within its owner's reference lifetime; work that outlives that owner must acquire its own reference.
| API | Result and caller obligation |
| --- | --- |
| `sessions.retain(target, options)` | `SessionReference`; returns immediately, and the caller owns the reference until release |
| `sessions.using<T>(target, options, operation)` | `Promise<T>`; awaits `reference.ready`, awaits `operation(reference)`, releases its reference, and returns the operation's result |
| `sessions.retainInfo(id)` | Stable read-only observable of local reference counts; no acquisition, scope creation, or history I/O |
| `sessions.scope(id)` / `sessions.binding(id)` | Borrow an existing live generation or return `undefined`; neither opens nor extends its lifetime |
| `sessions.sessionOf(ctx)` | Returns the matching live Session face or `undefined`; an ended Context cannot resolve to a same-id replacement |
| `sessions.create(...)` / `sessions.fork(...)` | Existing Host operations returning a Session identity; retaining and displaying it remain explicit |
`SessionRetainOptions` contains required `source: SessionReferenceSource` and optional `signal: AbortSignal`. Both acquisition methods accept `target: SessionTarget` and these options. Source keys are consumer-defined and declaration-merge extensible; there is no runtime source-registration protocol or default main-view source. A source is a usage label, not another Session address or a permission to act on it.
### Acquisition, failure, and release
Acquisition synchronously resolves the target, creates or retains the local Session, Context, fiber, and binding, records one reference and its source contribution, starts the generation's shared initial history opening, and returns the reference. Concurrent acquisitions share that opening but receive independent references and readiness waits. `reference.ready` resolves to the still-live exact binding when the shared `Session.open()` attempt resolves.
Unknown Session-id addressing fails before a reference is returned; an explicit subagent address is validated by the Host during opening. A thrown opening failure, caller cancellation, reference release, or generation retirement rejects that reference's `ready`; one cancelled waiter does not cancel another owner's shared opening. A Remote failure represented by `openState: 'error'` follows `Session.open()` and resolves readiness, leaving the error renderable through the binding. The caller still owns a returned reference until release, while `sessions.using` releases its reference when readiness or its operation fails.
`using reference = sessions.retain(target, options)` releases on exit from the enclosing scope; code that requires settlement of the initial history attempt awaits `reference.ready`. `sessions.using` is the callback helper: it waits for that settlement before invoking an operation, waits for its returned value or Promise, and then releases. Returned values must not rely on the helper's released reference remaining usable; a longer-lived consumer acquires its own reference. The helper propagates rejected readiness and operation failures without a fallback result, retry, or error presentation. Model-selection generation checks and error-state updates remain in ModelSelection.
A fulfilled `ready` Promise means the initial `Session.open()` attempt settled; it does not assert `openState: 'open'` or promise an uninterrupted connection. Stateful opening failures and later stream failures remain observable through existing Session state. Callers retain their existing handling and presentation of those errors.
Release removes only that reference and its source contribution. Final release withdraws the generation's admission and id mappings before disposing the Session and fiber. A later retain can create a fresh generation immediately; cleanup of the old generation cannot remove the replacement. `release()` initiates local cleanup synchronously, while root disposal awaits outstanding asynchronous teardown.
The owning Client root invalidates all references during shutdown. It refuses new acquisitions, withdraws live mappings, and joins scoped cleanup and Session stream teardown. References cannot keep a disposed Client root alive. Catalog removal alone does not dispose a referenced generation.
### Reference sources and Session list records
The Controller owns source counts alongside each live generation's references. For every source, the count equals that generation's unreleased references bearing the source. A Session can have several sources, and a source can hold several references. Sources have no special lifecycle behavior in the allocator.
Each available Session list row exposes a read-only `retainedBy` record from source key to positive reference count. An unretained row has an empty record; zero-count keys are absent. Acquisition failure and release update that projection, including removal of the final key. The source counts remain local facts when Host metadata is refreshed; Host responses cannot overwrite them.
The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` synthesizes a local fallback row for every live generation so Provider and ownership consumers can resolve it; `ids` remains the Host-list membership and ordering. A fallback row is not Host metadata. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host.
For example, `retainedBy = { mainView: 1, gateway: 2 }` means that the main view and two Host invocations own three references. Releasing the main-view reference removes only `mainView`; the Gateway invocations continue to own the generation. Source names in this example are consumer keys, not a closed Controller enum.
`current` means main-view occupancy: a row has that marker exactly when its `mainView` source count is positive. It is one use of the general source record, not a separate `sessions.current` value or Session-selection service. Other consumers can derive their own markers from their source keys. The reference system does not impose a globally unique consumer or select one Session from multiple sources.
The main-area owner manages its own target and reference transitions. The list does not infer selection from how many Providers happen to be mounted. Source records report actual ownership, including temporary overlap during acquisition; UI navigation remains responsible for its target, without assigning selection authority to the allocator.
Window-level consumers may inspect source markers. Session business operations still use their supplied scope or explicit reference; they cannot find `mainView` in the catalog to recover a missing operation target. A background reference is ownership evidence, not evidence that a user viewed the Session.
`SessionRetainInfo` contains `referenceCount` and the read-only `retainedBy` record. `sessions.retainInfo(id)` observes that local ownership independently of catalog membership and remains stable across same-id generation replacement. An identity without a live generation has zero references and an empty source record; reading this value does not assert that the identity exists on the Host.
`ui-session` exposes `useSessionRetainInfo(sessionId, selector)` for an explicit identity and `useSessionRetainInfo(selector)` for the Session bound by the surrounding Provider. An unbound scope supplies absence to the latter form, never the main area's Session. The renderer constructs both forms from the same bare retain-info source. Consumers test the `mainView` source count to recognize the former current-Session role; other source keys remain equally queryable. Reading or subscribing does not retain the Session.
### Unified UI Session status
`ui-session` owns a React-free `sessionStatus` source and exposes it through the standard `useSessionStatus` hook. The snapshot is indexed by Session identity and supplies one UI status record per known Session. It combines these independent facts rather than reducing them to one mutually exclusive phase:
| Field | Value | Meaning and owner |
| --- | --- | --- |
| `running` | `boolean` or `undefined` | Latest known Session running fact; absence of a baseline is not confirmed idle |
| `pendingInteraction` | `SessionPendingInteraction` or `undefined` | Effective domain-owned request, or absence when no request is pending |
| `completionUnread` | `boolean` | UI reminder for an observed stop that has not been acknowledged |
Source counts remain authoritative in `SessionListState.byId[id].retainedBy`; UI status reads them for acknowledgement policy without owning another reference registry. Titles, Workspace associations, history, queues, and projection data remain with their existing owners.
Pending domains retain `SessionPendingInteractionMap`, request identities, precedence, publication disposers, and teardown delegation. The unified status includes the same effective request object; it does not copy requests or create a second pending registry. Workspace status indicators and Conversation composer selection read `useSessionStatus` instead of separate `useSessionPendingInteraction` and `useCompletedSessionIds` hooks.
Completion tracking subscribes to the existing `api-session/status` events so a running-to-idle transition is not lost in batched catalog snapshots. Catalog snapshots establish initial and reconnect baselines. A pending empty catalog is not evidence that Sessions disappeared. The update rules are:
- An initial idle baseline does not create a completion reminder.
- Observing running clears an earlier reminder and records the running baseline.
- A known running-to-idle transition sets `completionUnread` only when the Session lacks main-view ownership.
- Acquiring main-view ownership clears the reminder; retaining from an unrelated source does not.
- Releasing main-view ownership does not manufacture a reminder for an earlier stop.
- Removing a Session clears its completion reminder and running baseline; pending-request teardown stays with the request's domain.
The reminder denotes an observed stop, not successful task completion or completion of a particular queued message. Main-view ownership preserves selection-based acknowledgement even while a global panel temporarily hides the Conversation. The design has no `ui-session/view-presence` event, mounted-Provider index, or implicit acknowledgement from Provider mount.
The Session Controller publishes running and reference-source facts but owns no completion-reminder set, `consumeCompletion` method, or pending-interaction presentation. Reference acquisition does not execute completion-reminder business logic.
### Explicit Providers and consumer lifetimes
The single `SessionProvider` can inherit an outer binding or override its subtree with explicit `session={reference | undefined}`. It neither acquires nor releases ownership. The root Provider resolves its main binding from the `mainView` ownership marker without maintaining another current value; sibling and nested Providers affect only their own subtrees. Explicit absence stays absent instead of falling back to the main area.
The Provider injects the selected binding without keying its whole body. Strict `session` entries remount when the binding Context changes. A blank `session-maybe` entry adopts its first binding without remounting; after adoption, another binding Context or a return to absence starts a new component incarnation.
`uiWorkspace` owns the main-area reference with source `mainView`, and `ui-session` derives the root Provider from that reference's ownership marker on the Session record. Conversation, right Sidebar, preset, command, input, and model components beneath a Provider consume only its standard bound data. They do not read the main-area reference or a global selection, and they do not reinterpret an ID as a binding from another Provider.
Each Provider occurrence establishes an independent rendering scope from its supplied `SessionReference`. Two references may identify different Sessions or share one `SessionBinding`; different bindings isolate business and view data, while Providers for the same binding share Session, Conversation, input, and other Session-level data but retain separate component-local state. The main area and Sidebar can mount two Conversations concurrently without either Provider replacement or teardown redirecting the other subtree.
`ui-session` reuses one stable business observable per active `SessionBinding`. Generation caches for Conversation assembly, input shells, command popups, input-trigger controllers, and model directories use binding identity instead of Session IDs. Caches that must enumerate live values use `WeakMapWithValues<SessionBinding, Value>`, whose weak key table and strong value set provide identity lookup and value iteration. The container performs no cleanup; subscriptions, controllers, URLs, and other resources still release deterministically through `binding.ctx.effect()`. Provider-occurrence view state ends with that rendering scope, while final generation cleanup belongs to the binding Context.
Descriptor changes assemble replacement sources before publication without recreating Session generations. A Provider validates its reference while reading it; a released reference, a reference from another Controller, or a reference that no longer matches an active binding cannot establish a scope.
| Consumer | Acquisition and release |
| --- | --- |
| Main Conversation | `uiWorkspace` acquires the navigation target; `ui-session` establishes the root Provider from the `mainView` marker, and the Conversation subtree consumes only its Provider binding |
| Associated right Sidebar | `RightbarRoot` inherits the root Provider without reading the main-area reference |
| Independent Session view | Its owner retains the target and establishes a Provider from its own reference alongside the main area |
| Host event handler in the Client | Holds a local Context reference through handler and reply settlement |
Conversation references belong to their view owners, not Chat, Trajectory, or an individual action. Chat, Trajectory, commands, input, uploads, image reads, and model selection borrow the same binding during the Provider lifetime rather than acquiring a reference per action. Switching or closing that Conversation can end its in-flight local work. Sidebar-tab layout and resource ownership remain separate; only a Sidebar view that hosts a Conversation needs its own Session reference. Empty layouts and guide placeholders retain nothing.
Scoped business objects capture their binding before awaiting work. They do not reinterpret an old Context or directory as a new generation with the same id. ModelSelection borrows its Provider binding while retaining its own selection-generation and error rules. Editor-detach callbacks may overlap scope teardown; optional trigger and popup resolution returns absence for that retired Context rather than resolving another generation.
### Main-area navigation and presentation
`uiWorkspace.openSession`, `openWorkspace`, `forkSession`, and `startSession` remain the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership.
The existing `uiWorkspace` implementation directly owns the main-area reference and target. Its navigation methods update that owner rather than calling a receiver registered by Conversation. `ui-session` derives the root Provider binding from the source marker; the main Conversation and associated right Sidebar only inherit the Provider and neither depend on `uiWorkspace` nor see the main reference. An independent Sidebar Conversation establishes a nested Provider from its own reference and overrides only that subtree's binding. The main reference is not a global standard prop, subtree Hook, or default value for ID-based lookup.
The main view privately persists its target identity and subagent address under `dsh.sessions.current`, never a reference. Startup restoration, initial Workspace selection, and clearing an archived main target remain UI responsibilities. Archiving or removing catalog metadata does not revoke independent references held by other consumers.
| UI behavior | Final rule |
| --- | --- |
| Session-list highlight and blank-row treatment | Derive main-area occupancy from `retainedBy.mainView`, not the number of mounted Session Providers |
| New Session Workspace | Explicit Workspace first, then the main Session's Workspace under the existing lookup rule, then the existing recent-Workspace policy |
| Onboarding | Evaluate absence or blankness of the main-area Session, not all historical Sessions |
| Browser document title | Conversation shows Session title plus product title; a global panel shows product title |
| Chat/Trajectory restoration | Restore the view for the explicitly selected main target; independently bound views keep their own state |
| Cordis inventory panel | One list without current/other grouping; no public runner getter for main-area selection |
Source metadata does not change when DOM focus moves or when a global panel hides a retained view. The [global main-panel design](../../implemented/architecture/2026-09-08-global-main-panels.md) owns panel selection and layout; Session reference ownership does not replace it.
Conversation retains its `hero`, `settling`, and `active` composition and existing history-loading and `openError` handling. Acquisition adds no outer loading/error phase presentation, extra composer-hiding condition, Retry button, or replacement Sidebar recovery panel. Existing error handlers continue to handle their errors; call sites without error presentation gain none. Promise rejection and correct reference release do not imply an additional UI handler.
Workspace connection and fork preserve their existing navigation guards and panel-switch invalidation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects.
### Presets and creation flows
Preset directories and deployment defaults may be shared. A bound Session's preset is read or changed through its Provider binding; preset controllers are cached by `SessionBinding` rather than managed by one root current-Session follower. The hero preset seat uses the `session-maybe` Provider: it shows the creation-flow choice without a Session and operates on the exact bound blank Session after one arrives. Header labels read the same Provider-bound Session projection.
A preset chosen before Session creation remains in the main Conversation's `session-maybe` preset surface. After Workspace creation or reuse establishes the main Provider over a blank Session, that surface applies the choice to its Provider-bound Session. When Settings changes the default preset or picker setting, the preset service selects the blank Session whose established Provider binding carries `mainView` ownership and updates that Session. Non-blank main Sessions, independent Sidebar Providers, and other background references remain unchanged; preset subtrees do not read the main reference or use a global current follower to find their target.
### Host-event Context ownership and Typert
A validated Host waterfall identity can arrive before catalog discovery. The Client Context resolver must remain synchronous. It acquires a local generation reference with the Gateway's source and returns `TypertOwnedValue<Context>` without opening history or refreshing child catalogs. A handler that needs history separately acquires a public reference and awaits its `ready` Promise, or uses `sessions.using`.
Gateway owns the local reference until both handler use and reply settlement end. Context acquisition failures retain the existing report-and-delegate behavior, while handler failures produce rejected replies. Cancellation reaches the handler through its existing signal and suppresses late replies without releasing a Context still in use. Plugin shutdown joins the active connection generation's outstanding handlers; Connection starts no replacement generation until that source settles.
`TypertOwnedValue` carries a value and its disposer through the generic Gateway. It has no additional reference count and does not teach Gateway about Session-specific ownership. Independently bundled Client modules share the owned-value marker. Client outgoing `identity(ctx)` remains synchronous; Host Context resolution and Host lifecycle are unchanged.
## Alternatives considered
**Catalog membership as ownership.** Discovery data cannot establish ongoing view or operation use, and some Context identities arrive before catalog membership.
**Implicit main Session plus an explicit alternative.** Two target-resolution rules make reusable components depend on where they render. Explicit Providers supply the target, while generic source records serve window-level observation.
**Provider subtrees reading `mainSession`.** A component would then have both a Provider target and a window-level target, so an independent Sidebar Conversation could be redirected by a main-area change. The main reference participates only in Provider assembly; subtrees consume their Provider.
**Session ID as a generation-cache key.** Final release allows a replacement generation with the same ID to appear before old cleanup ends. An ID key can reuse the old object or let old cleanup remove the replacement. Business caches use weak binding identity, and the binding Context still owns resource cleanup.
**Asynchronous `retain` that resolves only after history opens.** It delays Provider installation and main-view navigation until history arrives, so the existing loading state cannot render immediately. A synchronous reference separates ownership from its explicit `ready` result.
**History I/O in synchronous Context resolution.** Host-event dispatch needs a scoped lifetime, not necessarily a history window; coupling them delays or blocks handlers before catalog discovery.
**A dedicated current flag.** One consumer-specific flag cannot describe Sidebar and background ownership. A main-view marker is derivable from the general per-source counts.
**Completion state in Session Controller, or separate pending and completion hooks.** Completion acknowledgement is UI policy. One UI status source composes independent facts while retaining domain-owned pending objects and Controller-owned running facts.
**Provider presence as acknowledgement or release.** Mounting is neither an owner's lifetime nor selection-based acknowledgement. It cannot decide which background or temporarily hidden uses remain alive or count as viewed.
**Bare ids in Providers or a global binding revision.** An id does not express generation ownership, and global refresh invalidates unrelated Session consumers.
**A reference for every action.** The Provider already defines the Conversation's usage lifetime. Reacquiring for every click fragments view ownership into fine-grained sources. Only work that must outlive the Provider acquires another reference.
**Navigation registration, new cancellation policies, and acquisition-specific recovery UI.** Reference ownership requires explicit targets, release, and failure propagation, not additional navigation or recovery behavior.
**Client references retaining Host Agents.** History access and Host execution are independent uses; a long-lived Client view cannot define Host business-operation lifetime.
## Verification
- Acquisition tests cover shared initial opening, ordinary and unexpected open failures, independent waiter cancellation, exact-generation replacement, and root teardown reaching quiescence.
- Source tests cover multiple sources, several references from one source, failed acquisition rollback, idempotent release, catalog refresh/removal, and late release of an ended generation without changing its replacement.
- Retain-info hook tests cover explicit identities, Provider-bound defaults, unbound and nested scopes, source updates across generations, and reads that create neither references nor history requests.
- Scope and Gateway tests cover synchronous Context acquisition before catalog discovery without history I/O, reported acquisition delegation, rejected handler replies, ownership through cancellation and settlement, and shared owned-value markers across bundles.
- UI status tests cover pending precedence and teardown, event transitions lost by snapshot batching, initial and reconnect baselines, main-source acknowledgement, unrelated-source retention, and no Provider-presence event.
- View tests cover explicit Providers for two different Sessions and for repeated uses of one Session, scoped presets, descriptor updates, and Sidebar occurrence/adoption lifetimes without new recovery controls.
- Assembled browser scenarios preserve main-area highlighting, blank rows, onboarding, Workspace defaults, titles, and the defined navigation/error behavior. Existing recorded model turns remain the behavioral input when only Client ownership changes.
- The public `retain` and `using` consumers, generated API catalogs, package contracts, and type checks agree on options and ownership. The helper keeps references through callback settlement and propagates both acquisition and operation failures.
## Consequences
Consumers must keep references until their real completion point; an unreleased reference still leaks within a live Client root. Retaining after final release creates a new generation and can reopen history. Source counts describe ownership, not visibility, task success, or authority; using the main marker as a business fallback would recreate implicit current-Session coupling.
Final release discards non-persisted binding-owned state, including loaded history pages, Chat scroll anchors, preview wrapping, Files expansion, composer attachments, and undo history. Only persisted Session-keyed Store values or a generation kept alive by another reference survive a view switch; the runtime does not clear those persisted values.
Every public `retain`, including the temporary references used by Session rename and fork-title assignment, starts the generation's shared initial history opening. These metadata operations await `reference.ready` before using the Session and therefore pay that history I/O for a cold generation.
The catalog, UI status, and view target have separate owners and can publish independently. Their consumers must not infer a lifecycle transition solely from notification order. The main view remains an ordinary reference owner, while its navigation and presentation rules stay in UI rather than the reference allocator.
@@ -0,0 +1,226 @@
# Agent Note: Client Session 引用、引用来源与 UI 状态
Status: implemented
[English](2026-09-15-client-session-references.md) | 中文
## 问题
Session 目录、活跃 Client 对象、视图与异步操作具有不同生命周期。目录成员关系不能证明仍在使用。借用的 binding 无法保护异步工作,也无法区分同一 Session ID 的不同 Client 代。全局 current Session 会使独立绑定的组件操作另一个视图的 Session。
引用计数能表明仍在使用,但不能识别使用方。主区域高亮、Sidebar 所有权与后台操作需要使用方来源信息。待处理交互与完成提醒也需要统一的 UI 读取接口,避免 Workspace 和 Conversation 各自组合相同状态。
Client 历史访问与 Host Agent(智能体)执行具有独立生命周期。历史打开可能失败;获取本地 Context 不一定需要读取历史。显式所有权必须保持导航、加载、错误处理与恢复行为,不添加无关 UI 策略。
## 决策
### 范围与所有权
Client Session 对象、Agent 作用域的 Client Context、引用、使用方来源元数据、UI 状态与显式 Provider 组合遵循下述所有权规则。Host Session 和 Agent 生命周期、SlotFactory、activity 列表视图、持久 Session 格式及两个 SDK 的 Host 协议保持独立。
| 所有者 | 职责 |
| --- | --- |
| Client Session Controller | 目录、活跃代、引用、来源计数、binding、历史窗口与既有 Session 控制状态 |
| `ui-session` | 显式 Session Provider 集成与统一 UI 状态来源 |
| 视图或操作 | 自己的引用、目标、来源标识与释放时机 |
| Workspace UI | 主区域目标与引用、导航、持久目标及创建流程 |
| UI 组合边界 | 将所有者提供的引用交给一个明确的 `SessionProvider` |
| Conversation 与 Sidebar 子树 | 只消费所在 Provider 的 Session,不读取主区域引用或全局选择 |
| Client Gateway | 处理 Host 事件时调用期的 Context 所有权 |
[Client 分层设计](../../implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md)定义数据、适配器、渲染器与展示层的单向依赖。引用来源统计不会使 Controller 依赖 UI 包。
本决策部分取代 [Web Client Session scope 与 provide channel 决策](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)中由 list 选择驱动的 scope 生命周期;后者保留显式 Provider 所有权下的 blank Session 与收养语义理由。
### 地址、binding 与引用
`SessionTarget` 是已知 `SessionId` 或持久的直接父子 `SubagentAddress`。它标识要获取的目标,不持有任何对象。Controller 解析显式地址时不要求预先加载 parent catalog,Host 则在打开历史时校验其 parent、child 与 mode。子会话发现与已知子会话地址均不等同于持有该子会话。导航使用同一种目标表示,不增加另一套导航地址。
`SessionBinding` 是共享的 Client 代,包含 `sessionId`、Session 接口、事件来源与作用域 Context。同一活跃代的多个引用共享这个 binding。同 ID 的新一代具有不同的 binding 和 Context。
`SessionReference` 持有某个确切代的一次使用。它公开只读 `sessionId` 和 `binding`、表示共享首次历史打开的 `ready` Promise,以及幂等 `release()` 与 `Symbol.dispose`。对应 `Session.open()` 尝试解析时,`ready` 解析为该确切 binding,包括以状态表示的 Remote failure 结果。引用释放或其代清理后,读取 `binding` 会失败。获取、释放或查看引用均不创建持久 Session,也不启动、停止或持有 Host Agent。
`SessionBinding` 是借用值,不构成持有。只有未释放的 `SessionReference` 计入来源与总引用数。同步代码可以在拥有者引用的生命周期内借用 binding;需要越过该生命周期的工作必须拥有自己的引用。
| API | 返回结果与调用方义务 |
| --- | --- |
| `sessions.retain(target, options)` | `SessionReference`;立即返回,调用方持有该引用直到释放 |
| `sessions.using<T>(target, options, operation)` | `Promise<T>`;依次等待 `reference.ready` 与 `operation(reference)`,释放自己的引用,并返回操作结果 |
| `sessions.retainInfo(id)` | 本地引用计数的稳定只读 observable,不获取引用、不创建作用域,也不执行历史 I/O |
| `sessions.scope(id)` / `sessions.binding(id)` | 借用已存在的活跃代或返回 `undefined`,不打开它,也不延长其生命周期 |
| `sessions.sessionOf(ctx)` | 返回匹配的活跃 Session 接口或 `undefined`;已结束的 Context 不能解析为同 ID 替代代 |
| `sessions.create(...)` / `sessions.fork(...)` | 既有 Host 操作,返回 Session 身份;持有与展示仍需显式执行 |
`SessionRetainOptions` 包含必填 `source: SessionReferenceSource` 与可选 `signal: AbortSignal`。两个获取方法均接受 `target: SessionTarget` 和这些 options。来源键由使用方定义,通过声明合并扩展;不提供运行时来源注册协议,也不默认使用主视图来源。来源是使用标签,不是另一种 Session 地址,也不授予操作权限。
### 获取、失败与释放
获取操作同步解析目标,创建或持有本地 Session、Context、fiber 和 binding,记录一份引用及其来源计数,启动该代共享的首次历史打开,然后返回引用。并发获取共享这次打开,但分别获得独立引用与就绪等待。共享的 `Session.open()` 尝试解析时,`reference.ready` 解析为仍然有效的确切 binding。
未知 Session id 的寻址在返回引用前失败;显式 subagent 地址由 Host 在打开期间校验。抛出的打开失败、调用方取消、引用释放或该代结束会拒绝该引用的 `ready`;取消一个等待方不会取消其他所有者共享的打开操作。以 `openState: 'error'` 表示的 Remote failure 跟随 `Session.open()` 语义并解析就绪,错误仍可通过 binding 渲染。调用方持有已返回的引用直到释放,而 `sessions.using` 会在就绪或操作失败时释放自己的引用。
`using reference = sessions.retain(target, options)` 在所在作用域退出时释放;需要等待首次历史尝试结算的代码等待 `reference.ready`。`sessions.using` 是面向回调调用方的辅助方法:它先等待该次结算,再调用操作并等待操作返回的普通值或 Promise,最后释放。返回值不能依赖辅助方法已经释放的引用仍可使用;需要更长生命周期的使用方自行获取引用。辅助方法传播被拒绝的就绪与操作失败,不提供兜底结果、重试或错误展示。模型选择的代际检查与错误状态更新保留在 ModelSelection。
`ready` Promise 成功表示首次 `Session.open()` 尝试已经结算;它不保证 `openState: 'open'`,也不保证连接永不中断。以状态表示的打开失败与之后的流失败继续通过既有 Session 状态公开。调用方保留对这些错误的既有处理与展示。
释放只移除该引用及其来源计数。最后释放在清理 Session 与 fiber 前撤回该代的准入和 ID 映射。后续 retain 可以立即创建新一代;旧代清理不能移除替代代。`release()` 同步发起本地清理,根清理等待尚未结束的异步释放工作。
所属 Client 根在关闭时使全部引用失效。它拒绝新的获取,撤回活跃映射,并汇合作用域清理与 Session 流释放。引用不能使已清理的 Client 根继续存活。仅移除目录记录不会清理仍被引用持有的代。
### 引用来源与 Session 列表记录
Controller 将来源计数与每个活跃代的引用共同管理。每个来源的计数等于该代尚未释放且带有该来源的引用数。同一 Session 可以有多个来源,同一来源可以持有多份引用。分配器不赋予任何来源特殊生命周期行为。
每条已有 Session 列表记录公开只读 `retainedBy`,由来源键映射到正数引用计数。未被持有的记录使用空对象;零计数键不存在。获取失败与释放更新该投影,包括删除最后一个来源键。Host 元数据刷新时,来源计数仍是本地事实,Host 响应不能覆盖它。
即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 为每个活跃代合成本地兜底记录,以便 Provider 与所有权使用方解析它;`ids` 仍表示 Host 列表成员关系与顺序。兜底记录不是 Host 元数据。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。
例如,`retainedBy = { mainView: 1, gateway: 2 }` 表示主视图与两个 Host 调用持有三份引用。释放主视图引用只移除 `mainView`;Gateway 调用继续持有该代。示例中的来源名是使用方键,不是 Controller 内封闭的枚举。
`current` 表示主视图占用:记录的 `mainView` 来源计数为正数时,就具有这个标记。这只是通用来源记录的一种用途,不是独立的 `sessions.current` 值或 Session 选择服务。其他使用方可以从自己的来源键派生标记。引用系统不要求全局唯一使用方,也不从多个来源中选择一个 Session。
主区域所有者管理自己的目标与引用切换。列表不根据恰好挂载了多少 Provider 推断选择。来源记录反映实际所有权,包括获取期间的短暂重叠;UI 导航仍负责自己的目标,不把选择权交给分配器。
窗口级使用方可以查看来源标记。Session 业务操作仍使用传入的作用域或显式引用,不得从目录中查找 `mainView` 来补齐缺失的操作目标。后台引用只能证明所有权,不能证明用户查看过 Session。
`SessionRetainInfo` 包含 `referenceCount` 与只读 `retainedBy` 记录。`sessions.retainInfo(id)` 独立于目录成员关系观察本地所有权,并在同 ID 换代时保持稳定。没有活跃代的身份具有零引用和空来源记录;读取该值不代表该身份在 Host 上存在。
`ui-session` 通过 `useSessionRetainInfo(sessionId, selector)` 读取明确身份,通过 `useSessionRetainInfo(selector)` 读取外围 Provider 绑定的 Session。未绑定作用域向后一种形式提供缺失值,不回退到主区域 Session。两种形式均由渲染器从相同的裸 retain-info 来源构造。使用方检查 `mainView` 来源计数以识别原 current-Session 角色,其他来源键也可同等查询。读取或订阅不会持有 Session。
### 统一的 UI Session 状态
`ui-session` 拥有不依赖 React 的 `sessionStatus` 来源,通过标准 `useSessionStatus` 钩子公开。快照按 Session 身份索引,为每个已知 Session 提供一条 UI 状态记录。它组合下列独立事实,不将它们压缩成互斥的单一阶段:
| 字段 | 取值 | 含义与所有者 |
| --- | --- | --- |
| `running` | `boolean` 或 `undefined` | 最新已知的 Session 运行事实;缺少基线不表示已确认 idle |
| `pendingInteraction` | `SessionPendingInteraction` 或 `undefined` | 领域拥有的有效请求;没有待处理请求时缺失 |
| `completionUnread` | `boolean` | 已观察到停止、但尚未确认的 UI 提醒 |
来源计数以 `SessionListState.byId[id].retainedBy` 为准;UI 状态读取它以执行确认策略,不维护另一套引用注册表。标题、Workspace 关联、历史、队列和投影数据保留在各自的既有所有者中。
待处理领域保留 `SessionPendingInteractionMap`、请求身份、优先级、发布清理函数与卸载委托。统一状态包含同一个有效请求对象,不复制请求,也不创建第二套待处理注册表。Workspace 状态指示器与 Conversation composer 选择读取 `useSessionStatus`,不再分别读取 `useSessionPendingInteraction` 和 `useCompletedSessionIds` 钩子。
完成跟踪订阅已有 `api-session/status` 事件,避免 running 到 idle 的变化在合批目录快照中丢失。目录快照建立初始与重连基线。pending 状态下的空目录不能证明 Session 已消失。更新规则如下:
- 初始 idle 基线不产生完成提醒。
- 观察到 running 时清除旧提醒,并记录运行基线。
- 从已知 running 变为 idle 时,仅在 Session 没有主视图持有的情况下设置 `completionUnread`。
- 获得主视图持有时清除提醒;无关来源的 retain 不清除提醒。
- 释放主视图持有不会为更早的一次停止补造提醒。
- 移除 Session 时清除其完成提醒与运行基线;待处理请求的清理仍属于请求领域。
提醒表示观察到的一次停止,不代表任务成功,也不代表某条排队消息完成。即使全局面板暂时隐藏 Conversation,主视图所有权仍保持基于选择的确认语义。本设计不提供 `ui-session/view-presence` 事件、已挂载 Provider 索引或 Provider 挂载时的隐式确认。
Session Controller 发布运行与引用来源事实,但不拥有完成提醒集合、`consumeCompletion` 方法或待处理交互展示。引用获取不执行完成提醒业务逻辑。
### 显式 Provider、并行 Conversation 与缓存身份
唯一的 `SessionProvider` 可以继承外层 binding,也可以用显式 `session={reference | undefined}` 覆盖本子树。它不获取或释放所有权。根 Provider 从 `mainView` 所有权标记解析主 binding,不维护另一份 current;并列或嵌套 Provider 只影响各自子树。显式缺失保持缺失,不回退到主区域。
Provider 注入选定 binding,但不为整个 body 设置 key。严格 `session` entry 在 binding Context 改变时重新挂载。空白 `session-maybe` entry 接受首个 binding 时不重新挂载;接受后,切换到另一个 binding Context 或回到缺失状态会创建新的组件 incarnation。
`uiWorkspace` 持有来源为 `mainView` 的主区域引用,`ui-session` 从该引用在 Session 记录中的所有权标记建立根 Provider。Provider 下的 Conversation、右 Sidebar、preset、命令、输入与模型组件只能读取 Provider 绑定的标准数据。它们不得读取主区域引用或全局选择,也不得按 Session ID 重新寻找一个可能属于其他 Provider 的 binding。
每个 Provider occurrence 以传入的 `SessionReference` 建立独立渲染作用域。两个引用可以指向不同 Session,也可以共享同一 `SessionBinding`;不同 binding 的业务与观看数据完全分离,同一 binding 的 Provider 共享 Session、Conversation、输入等 Session 级数据,但保留各自的组件局部状态。主区域与 Sidebar 可以同时挂载两个 Conversation,任一 Provider 的替换或卸载不改变另一棵子树的目标。
`ui-session` 为每个活跃 `SessionBinding` 复用稳定的业务 observable。Conversation assembly、input shell、command popup、input-trigger controller 与 model directory 等代际缓存以 binding 为弱键,不再以 Session ID 为键;需要枚举活跃值的缓存使用 `util-values` 的 `WeakMapWithValues<SessionBinding, Value>`,由弱键表和强值集合共同维护身份查询与值遍历。该容器不执行清理;订阅、控制器、URL 和其他资源仍通过 `binding.ctx.effect()` 确定性释放。Provider occurrence 的观看状态随该 Provider 的渲染作用域释放,最终 generation 清理由 binding Context 负责。
Descriptor 变化先组装替代来源再发布,不重建 Session 代。Provider 在读取 reference 时校验它仍有效;已经释放、来自其他 Controller 或不再对应活跃 binding 的 reference 不能创建作用域。
| 使用方 | 获取与释放 |
| --- | --- |
| 主 Conversation | `uiWorkspace` 获取导航目标;`ui-session` 从 `mainView` 标记建立根 Provider,Conversation 子树只消费 Provider 绑定 |
| 关联右 Sidebar | `RightbarRoot` 继承根 Provider,不读取主区域引用 |
| 独立 Session 视图 | 自己的所有者持有目标,并以自己的引用建立 Provider;与主区域同时运行 |
| Client 中的 Host 事件 handler | 持有本地 Context 引用直到 handler 与回复结算 |
Conversation 的引用属于其视图所有者,不属于 Chat、Trajectory 或某次点击。Chat、Trajectory、命令、输入、上传、图片读取与模型选择在 Provider 生命周期内借用同一 binding;它们不按行动重复获取引用。切换或关闭该 Conversation 可以结束仍在途的本地工作。Sidebar tab 的布局与资源所有权保持独立;只有承载 Conversation 的 Sidebar 视图所有者需要自己的 Session 引用。空布局与 guide 占位页不持有引用。
作用域业务对象从 Provider 捕获自己的 binding,不把旧 Context 或目录重新解释成同 ID 的新一代。ModelSelection 借用 Provider binding,并保留自己的选择代际与错误规则。编辑器脱离回调可能与作用域清理重叠;可选 trigger 和 popup 对已结束的 Context 返回空值,不解析其他 generation。
### 主区域导航与展示
`uiWorkspace.openSession`、`openWorkspace`、`forkSession` 和 `startSession` 仍是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。
现有 `uiWorkspace` 实现直接持有来源为 `mainView` 的主区域引用与目标。导航方法直接更新该所有者,不调用 Conversation 注册的接收者。`ui-session` 根据来源标记把该引用对应的 binding 注入根 Provider;主 Conversation 和关联右栏只继承 Provider,既不依赖 `uiWorkspace`,也看不到主引用。独立 Sidebar Conversation 以自己的 reference 建立嵌套 Provider,并覆盖本子树的 binding。主引用不是全局标准 prop、子树 Hook 或按 ID 查询的默认值。
主视图在 `dsh.sessions.current` 下私下持久化目标身份与子会话地址,不保存引用。启动恢复、初始 Workspace 选择与归档主目标后的清空仍属于 UI。归档或移除目录元数据不撤销其他使用方持有的独立引用。
| UI 行为 | 最终规则 |
| --- | --- |
| Session 列表高亮与空白记录处理 | 从 `retainedBy.mainView` 派生主区域占用,不按已挂载 Session Provider 的数量判断 |
| New Session 的 Workspace | 优先使用显式 Workspace,其次按既有查找规则使用主 Session 的 Workspace,最后使用既有最近 Workspace 策略 |
| Onboarding | 判断主区域 Session 是否缺失或为空白,不判断所有历史 Session |
| 浏览器文档标题 | Conversation 显示 Session 标题与产品标题;全局面板显示产品标题 |
| Chat/Trajectory 恢复 | 为显式选中的主目标恢复视图;独立绑定的视图保留自己的状态 |
| Cordis inventory 面板 | 使用不区分 current/other 的单一列表;runner 不提供主区域选择的公开 getter |
DOM 焦点移动或全局面板隐藏仍被持有的视图时,来源元数据不变。[全局主面板设计](../../implemented/architecture/2026-09-08-global-main-panels.zh.md)拥有面板选择与布局;Session 引用所有权不替代它。
Conversation 保留 `hero`、`settling`、`active` 组合与既有历史加载和 `openError` 处理。获取引用不增加外层 loading/error 阶段展示、额外隐藏 composer 的条件、Retry 按钮或替换 Sidebar 内容的恢复面板。已有错误处理方继续处理自己的错误;没有错误展示的调用点不增加展示。Promise 拒绝与正确释放引用不意味着额外增加 UI 处理方。
Workspace 连接和 fork 保持既有导航检查与面板切换失效规则。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。
### 预设与创建流程
预设目录与部署默认值可以共享。已绑定 Session 的预设通过 Provider 的 binding 读取或修改;预设控制器按 `SessionBinding` 缓存,不由根级 current-Session 跟随器管理。Hero 的 preset seat 使用 `session-maybe` Provider:没有 Session 时显示创建流程选择,绑定空白 Session 后操作该确切 Session。标题标签读取同一 Provider 绑定 Session 的投影。
Session 创建前选择的 preset 保留在主 Conversation 的 `session-maybe` preset surface 中。Workspace 创建或复用空白 Session 并建立主 Provider 后,该 surface 将选择应用到 Provider 绑定的 Session。设置页修改默认 preset 或 picker 设置时,preset 服务从 Provider 已建立的 binding 缓存中选择带 `mainView` 所有权标记的空白 Session,并更新该 Session。非空白主 Session、Sidebar 的独立 Provider 与其他后台引用均不受该设置动作影响;preset 子树不读取主引用,也不通过全局 current follower 寻找目标。
### Host 事件 Context 所有权与 Typert
经校验的 Host waterfall(瀑布式事件)身份可以先于目录发现到达。Client Context 解析器必须保持同步。它带上 Gateway 的来源获取本地代引用,返回 `TypertOwnedValue<Context>`,不打开历史,也不刷新子目录。需要历史的 handler 另行获取公开引用并等待其 `ready` Promise,或使用 `sessions.using`。
Gateway 持有本地引用,直到 handler 使用与回复结算均结束。Context 获取失败保留既有的记录错误并委托语义,handler 失败产生拒绝回复。取消通过已有 signal 到达 handler,并抑制晚回复,但不会在 Context 仍被使用时提前释放。插件关闭汇合当前连接代的在途 handler;Connection 只在该来源结算后启动替代代。
`TypertOwnedValue` 跨通用 Gateway 传递值及其清理操作。它没有额外引用计数,也不要求 Gateway 理解 Session 专用所有权。独立 Client bundle 共享 owned-value 标记。Client 发请求的 `identity(ctx)` 保持同步;Host Context 解析与 Host 生命周期不变。
## 考虑过的替代方案
**以目录成员关系作为所有权。** 发现数据无法证明视图或操作仍在使用,而且部分 Context 身份先于目录成员关系到达。
**隐式主 Session 加显式替代路径。** 两套目标解析规则会使可复用组件依赖其渲染位置。显式 Provider 提供目标,通用来源记录服务于窗口级观察。
**Provider 子树继续读取 `mainSession`。** 同一组件会同时拥有 Provider 与窗口级目标,Sidebar 的独立 Conversation 也会被主区域变化重定向。主引用只参与 Provider 装配,子树只读取 Provider。
**以 Session ID 为代际缓存键。** 最终释放允许同 ID 新代在旧清理结束前出现,ID 键会复用旧对象或让旧清理删除新对象。业务缓存使用弱引用的 binding 身份,资源清理由 binding Context 负责。
**只在历史打开后解析的异步 `retain`。** 它会把 Provider 安装与主视图导航推迟到历史到达之后,导致既有加载状态无法立即渲染。同步引用把所有权与显式的 `ready` 结果分开。
**在同步 Context 解析中执行历史 I/O。** Host 事件派发需要作用域生命周期,不一定需要历史窗口;耦合两者会在目录发现之前延迟或阻止 handler。
**专用 current 标志。** 一种使用方专用标志无法描述 Sidebar 与后台所有权。主视图标记可以从通用的按来源计数中派生。
**Session Controller 中的完成状态,或独立的待处理与完成提醒钩子。** 完成确认属于 UI 策略。统一 UI 状态来源组合独立事实,同时保留领域拥有的待处理对象与 Controller 拥有的运行事实。
**以 Provider 呈现决定确认或释放。** 挂载既不等于所有者生命周期,也不等于基于选择的确认。它无法决定哪些后台或暂时隐藏的使用仍应存活,或应当算作已查看。
**Provider 中的裸 ID 或全局 binding revision。** ID 不表达代际所有权,全局刷新会使无关 Session 使用方失效。
**每个行动各自获取引用。** Provider 已经定义 Conversation 的使用生命周期;为每次点击重复持有会把视图所有权拆成大量细粒度来源。只有明确需要越过 Provider 生命周期的工作才另行持有引用。
**导航注册、新的取消策略与获取专用恢复 UI。** 引用所有权要求显式目标、释放与失败传播,不要求额外导航或恢复行为。
**Client 引用持有 Host Agent。** 历史访问与 Host 执行是独立使用需求,长时间打开的 Client 视图不能定义 Host 业务操作生命周期。
## 验证
- 获取测试覆盖共享首次打开、普通与意外打开失败、独立等待方取消、确切代替换,以及根清理达到完全停稳。
- 来源测试覆盖多个来源、同来源多份引用、获取失败回滚、幂等释放、目录刷新或移除,以及旧代的晚释放不改变替代代。
- Retain-info 钩子测试覆盖明确身份、Provider 绑定默认值、未绑定与嵌套作用域、跨代来源更新,以及读取不创建引用或请求历史。
- 作用域与 Gateway 测试覆盖目录发现前不执行历史 I/O 的同步 Context 获取、获取失败的记录与委托、handler 的拒绝回复、取消与结算期间的所有权,以及跨 bundle 共享的 owned-value 标记。
- UI 状态测试覆盖待处理优先级与清理、会被快照合批丢失的事件变化、初始与重连基线、主来源确认、无关来源持有,以及不存在 Provider 呈现事件。
- 视图验证覆盖两个不同 Session 和同一 Session 多次使用的并行显式 Provider、互不重定向的 Conversation、独立 Slot store、作用域 preset、descriptor 更新,以及没有新恢复控件的 Sidebar 生命周期。
- 实际组合浏览器场景保持主区域高亮、空白记录、onboarding、Workspace 默认值、标题与约定的导航或错误行为。只有 Client 所有权改变时,已有模型轮次录制仍作为行为输入。
- 公开 `retain`、`using`、Provider、生成 API 目录、包约定与类型检查对 options 和所有权保持一致。Provider 子树不读取主区域引用,独立所有者各自释放自己的引用。
## 后果
使用方必须把引用保留到真实结束点;在活跃 Client 根内,未释放引用仍会泄漏。最后释放后再次 retain 会创建新一代,并可能重新打开历史。来源计数描述所有权,不描述可见性、任务成功或操作权限;以主标记作为业务兜底会重新引入隐式 current-Session 耦合。
最后一份 reference 释放后,未持久化的 binding 自有状态会被丢弃,包括已加载的历史页、Chat 滚动锚点、预览换行、Files 展开状态、composer 附件和 undo 历史。只有持久化的 Session-keyed Store 值或由另一份 reference 保活的 generation 能跨视图切换保留;runtime 不会清除这些持久值。
每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括 Session 重命名和 fork 标题设置所用的临时引用。这些元数据操作在使用 Session 前等待 `reference.ready`,因此冷 generation 会承担这次历史 I/O。
目录、UI 状态与视图目标由不同所有者管理,可以独立发布。使用方不能仅从通知顺序推断生命周期变化。主视图仍是普通引用所有者,其导航与展示规则留在 UI,不进入引用分配器。
@@ -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-09-15-desktop-native-fatal-recovery.md
2026-09-15-desktop-native-fatal-recovery.md: b2a0dfff1a630d1eedaa898e4a4c8144ff7ae776
2026-09-15-desktop-native-fatal-recovery.zh.md: 0fee729e3be791d10966050d21482c5132b14fe6
2026-09-15-desktop-native-fatal-recovery.md: 63ec81991f3e396075fe4ed09a8ddaa87c865862
2026-09-15-desktop-native-fatal-recovery.zh.md: 05dbad80064706d384eeac6537da073a8f365e7e
@@ -12,7 +12,7 @@ A recovery document depends on the renderer and preload whose failure can preven
Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; the Host restarts after failed package writes, and a failed Host restart enters native recovery; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Disabling writes activation metadata under the existing profile transaction lock after Host shutdown, without requiring runtime initialization or deleting installed files. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile transaction lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
@@ -22,6 +22,8 @@ This supersedes recovery-page and reset behavior in the [immediate-window decisi
A Web modal depends on client initialization, while a second recovery document adds renderer resources and preload recovery paths. Native dialogs remain usable when those components fail. Automatically resetting configuration or restarting on every report can delete user configuration or create restart loops; explicit actions preserve user control.
**Disable bundles while retaining the active profile patch.** A malformed patch or a patch that inserts a broken plugin can still prevent startup. Renaming preserves the user’s exact bytes for manual repair while removing that layer from startup; unique backup names preserve earlier recovery attempts.
## Consequences
Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.
Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. A malformed home-level patch still blocks startup after profile recovery and requires manual repair. Backups accumulate in the profile directory without automatic pruning; users remove them when no longer needed. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.
@@ -12,7 +12,7 @@ Status: implemented
Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。禁用操作等待 Host 关闭后,在已有 profile 事务锁内写入启用元数据,不要求运行时初始化,也不删除安装文件。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 事务锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
@@ -22,6 +22,8 @@ Web 文档保留在原位。宿主回调负责启动失败展示,共享启动
Web 模态框依赖客户端初始化,而第二份恢复文档会增加渲染器资源和 preload 恢复路径。原生对话框在这些组件失败时仍然可用。自动重置配置或每次报告都重启可能删除用户配置或形成重启循环;显式操作保留用户控制权。
**禁用 bundle,但保留生效的 profile patch。** 损坏的 patch 或插入故障插件的 patch 仍可阻止启动。重命名保留用户原始内容供手动修复,同时将该层移出启动过程;唯一备份名保留此前恢复操作的备份。
## Consequences
恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。
恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。损坏的 home 级 patch 在 profile 恢复后仍会阻止启动,需要手动修复。备份在 profile 目录中累积,不会自动清理;用户在不再需要时删除。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。
@@ -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-09-16-plugin-configuration-on-the-plugins-page.md
2026-09-16-plugin-configuration-on-the-plugins-page.md: fb330bee44373d20c7aeea8cee521bf8def025d4
2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 65c1f5a8bf4654b395ab4bfcf8015ae7c23a6acc
@@ -0,0 +1,39 @@
# Agent Note: Plugin configuration on the Plugins page
Status: implemented
English | [中文](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md)
## Problem
A plugin's settings lived in Settings, on the Plugins section's configuration tab: four collapsible cards, one per host-plane namespace, beside a read-only inventory tab. The sidebar's Plugins page listed and switched bundles but could not open a plugin's settings, so two surfaces split one object between them, and a bundle installed from outside the repository had no place for a form of its own at all.
## Decision
**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `<package name>#<row id>`) gives that row a configure control that opens a page headed by the row id. Every entry is rendered in two views the page passes as owner props: `summary` for the one-liner under the title, `page` for the form. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin.
**Attribution is declared by the registrant, in the slot name and key.** No manifest field, no registration metadata, and no owner information from the settings service: an official page registers without a bundle, a bundle's page with its package name, a row's page with the bundle and the row id its patch declares. A community bundle that ships no browser half has no configuration page; the page renders no generic form from a settings schema.
**Only a save writes.** The form's discard control and its unsaved marker are gone: leaving a page drops the staged edits, which the form does on unmount. A save still writes through the client settings scope with the revision fence.
**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired.
**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review leaves `OPTIONAL_BUNDLES` and the CLI's dependencies: it is a published experimental package the install guide names as its example.
## Consequences
- A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own.
- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it still owns while its pages live on the Plugins page.
- Settings lists the inventory only; the settings goldens that carried the Plugins nav entry and the configuration tab were re-recorded.
## Alternatives considered
**Registration metadata on the slot entry.** A slot-declared `meta` share (description, bundle, row) would let one `plugins.item` slot carry every case, at the cost of a new concept in `ui-slots`; the three slots say the same with the API that exists.
**A generic form from the settings schema.** `describe()` already ships each namespace's schemastery schema, so a community namespace without a browser half could get a default form. Deferred: the official pages are curated, a generic form would expose internal namespaces without an allowlist, and attributing a namespace to a bundle needs an owner in the descriptor.
**Keeping the cards in Settings and linking to them from the page.** Cheapest, but the review asked for the form on the plugin's own page.
## Testing
`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the new Official group.
@@ -0,0 +1,39 @@
# Agent Note:插件页上的插件配置
状态:已实现
[English](2026-09-16-plugin-configuration-on-the-plugins-page.md) | 中文
## 问题
插件的设置原本放在设置里、插件分区的配置标签页上:四张可折叠卡片,每个宿主平面命名空间一张,旁边是只读的清单标签页。侧栏的插件页能列出并启停组合包,却打不开插件的设置,于是同一个对象被两个界面各持一半;仓库之外安装的组合包更是完全没有地方放自己的表单。
## 决策
**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开以行 id 为标题的页面。每个条目都按页面以 owner props 传入的两种视图渲染:`summary` 是标题下的一句话简介,`page` 是表单。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。
**归属由注册方在 slot 名与键里声明。** 不加 manifest 字段,不加注册元数据,也不从 settings 服务取 owner 信息:官方页面不带组合包注册,组合包的页面带自己的包名注册,行的页面带组合包与其 patch 声明的行 id 注册。没有浏览器半侧的社区组合包就没有配置页;页面不会从 settings schema 渲染通用表单。
**只有保存才写入。** 表单的放弃控件与未保存标记都去掉了:离开页面即丢弃暂存修改,表单在卸载时执行。保存仍经由客户端 settings scope,带 revision 栅栏。
**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。
**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,不再有官方标签;配置页排在其后。Auto review 离开 `OPTIONAL_BUNDLES` 与 CLI 的依赖:它是一个已发布的实验包,安装引导拿它作示例。
## 后果
- 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。
- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,尽管它的页面住在插件页上。
- 设置只列出清单;带有插件导航项与配置标签页的设置类 golden 已重录。
## 考虑过的替代方案
**slot 条目上的注册元数据。** 由 slot 声明的 `meta` 份额(描述、组合包、行)能让单个 `plugins.item` slot 覆盖所有情况,代价是 `ui-slots` 多一个概念;三个 slot 用现有 API 表达了同样的信息。
**从 settings schema 生成通用表单。** `describe()` 已经把每个命名空间的 schemastery schema 发给浏览器,没有浏览器半侧的社区命名空间本可获得一份默认表单。延期:官方页面是精选的,通用表单会在没有白名单的情况下暴露内部命名空间,而把命名空间归属到组合包还需要 descriptor 带 owner。
**把卡片留在设置里、从插件页链接过去。** 最省事,但 review 要求表单在插件自己的页面上。
## 测试
`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录新的官方分组。
@@ -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-09-16-desktop-window-menus.md
2026-09-16-desktop-window-menus.md: 85ac42aa287b1cd9e595ade7cb44b965dea787dd
2026-09-16-desktop-window-menus.zh.md: 1376b90058ceafa7e378b4fd543d261064f5d8aa
@@ -0,0 +1,35 @@
# Agent Note: Desktop standard macOS window menus
Status: implemented
English | [中文](2026-09-16-desktop-window-menus.zh.md)
## Problem
The Desktop shell replaces Electron's default application menu with a custom template that listed only the application and Edit menus. Electron builds only the roles a template declares, so macOS lost the File and Window menus and the application hide commands the default template supplies, including Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). None of those shortcuts did anything in the Desktop application while every comparable macOS application responds to them (issue #4374).
## Decision
On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus.
Electron's role labels are English string literals inside Electron (`lib/browser/api/menu-item-roles.ts`, `filemenu`, `windowmenu`, `close`, `minimize`, `hide`) with no locale lookup, and Electron re-applies them to the native menu items before the menu is shown, so a non-English Desktop shows them in English; the existing Edit menu behaves the same way. No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role.
## Alternatives considered
**Bind ⌘W to minimizing or hiding the window.** macOS reserves Minimize for ⌘M and Hide for ⌘H, and comparable applications close the front window with ⌘W. Binding another command to the reported shortcut would contradict the platform convention the change follows.
**Declare only the Window menu.** That menu supplies Minimize and Zoom but no Close, leaving ⌘W unbound.
**Add a bare `{ role: 'close' }` item to the application submenu.** It avoids a File menu containing a single command, but places a window command among the app-wide Plugins, Updates, Hide, and Quit items where no macOS application puts it.
**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application, turning a window shortcut into an unrequested quit path.
**Recreate the main window on Dock activation regardless of other windows.** The plugin window can outlive the main window, so gating `activate` on `mainWindow` instead of `BrowserWindow.getAllWindows()` would let a Dock click always restore the main window. That changes window lifecycle beyond restoring the suppressed platform commands, so the existing `activate` condition stays.
## Consequences
macOS regains the window and application commands the custom menu suppressed, at the cost of four menu roles. The labels stay English on a non-English Desktop.
## Testing
A `apps/desktop/tests/main-startup.spec.ts` case pins the declared menu roles per platform, including the macOS hide commands. Role-based menu items execute natively, so a programmatic `click()` and the vitest Electron mock cannot exercise the shortcuts; a real Electron 44 run of the same template showed the standard File, Window, and application items present only after this change, with the same English labels while `app.getLocale()`, `getSystemLocale()`, and `getPreferredSystemLanguages()` all reported `zh-CN`.
@@ -0,0 +1,35 @@
# Agent Note: Desktop standard macOS window menus
Status: implemented
[English](2026-09-16-desktop-window-menus.md) | 中文
## 问题
Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该模板此前只列出应用菜单和 Edit 菜单。Electron 只构建模板中声明的 role,因此 macOS 失去了默认模板提供的 File、Window 菜单和应用隐藏命令,包括 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。这些快捷键在 Desktop 应用中都没有任何效果,而同类的 macOS 应用都会响应它们(#4374)。
## 决策
macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。
这些 role 的标签是 Electron 内部的英文常量(`lib/browser/api/menu-item-roles.ts` 的 `filemenu`、`windowmenu`、`close`、`minimize`、`hide`),没有语言查询,而且 Electron 在菜单显示前会把这些标签重新写到本机菜单项上,因此在非英文的 Desktop 上它们仍是英文;现有的 Edit 菜单同样如此。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。
## 考虑过的替代方案
**把 ⌘W 绑定为最小化或隐藏窗口。** macOS 把最小化留给 ⌘M、把隐藏留给 ⌘H,同类应用都用 ⌘W 关闭最前窗口。给这个快捷键绑定其他命令会违背本改动所遵循的平台惯例。
**只声明 Window 菜单。** 该菜单提供 Minimize 和 Zoom,但不提供关闭,⌘W 仍然没有绑定。
**在应用子菜单里直接加一个 `{ role: 'close' }` 项。** 这样可以避免只有一个命令的 File 菜单,但会把窗口命令放到 Plugins、Updates、Hide、Quit 这些应用级命令中间,没有 macOS 应用这样排布。
**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用,把窗口快捷键变成用户没有要求的退出路径。
**让 Dock 激活时无论其他窗口是否存在都重建主窗口。** 插件窗口可以比主窗口存活更久,因此把 `activate` 的判断从 `BrowserWindow.getAllWindows()` 改为 `mainWindow` 能让点击 Dock 总可以恢复主窗口。但这超出了恢复被压掉的平台命令,属于窗口生命周期的行为变更,因此保留现有的 `activate` 判断。
## 影响
macOS 恢复了自定义菜单压掉的窗口和应用命令,代价是四个菜单 role。在非英文的 Desktop 上这些标签仍是英文。
## 测试
`apps/desktop/tests/main-startup.spec.ts` 中的一个用例固定了各平台声明的菜单 role,包括 macOS 的隐藏命令。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 vitest 的 Electron mock 都无法触达这些快捷键;用真实 Electron 44 运行同一模板显示,只有在本改动之后才出现标准的 File、Window 和应用菜单项,并且在 `app.getLocale()`、`getSystemLocale()` 和 `getPreferredSystemLanguages()` 都报告 `zh-CN` 时标签保持不变。
@@ -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-09-16-session-writer-held-feedback.md
2026-09-16-session-writer-held-feedback.md: 922987996c4abd32857cd0f54cec4394566cf97f
2026-09-16-session-writer-held-feedback.zh.md: 6a1aa7993a8f36c50cb5fed0180d6523af44c177
@@ -0,0 +1,25 @@
# Agent Note: Session writer contention feedback
Status: implemented
English | [中文](2026-09-16-session-writer-held-feedback.zh.md)
## Problem
A Session can retain its write handle while its Agent is idle. Another Host cannot resume that Session, but a generic internal-error toast gives the user no recovery guidance. The holder can also belong to the same process, so contention alone does not identify another running application.
## Decision
Session Controller reports `session/writer-held` with `{ sessionId }` when resume fails with `SessionAlreadyOwnedError` and no reusable Agent exists. Clients discriminate by code, following the [Remote failure vocabulary](../architecture/2026-08-28-ctx-remote-failure-vocabulary.md). The controller recognizes the persistence error by its Error name, matching Session Query's optional-dependency handling; loading the controller requires no persistence implementation or error-class identity.
Send and model-selection failures show localized guidance that another DSH instance may hold the Session and suggest quitting other instances before retrying. Model selection returns the original Remote result to both UI entries; error classification does not depend on a later read of shared directory state. The [write-lease decision](../feature/2026-08-31-cross-process-session-write-lease.md) continues to own locking and release semantics; this feedback neither takes ownership nor retries writes automatically.
## Alternatives considered
**A free-text reason under `session/agent-busy`.** A second string discriminator loses the code-to-details type relationship and mixes write contention with prompt admission failures.
**A separate directory flag or a required persistence peer.** A flag duplicates the operation's failure and can become stale after a catalog refresh. Making persistence mandatory solely to identify its error removes support for deployments without persistence.
## Consequences
The wire gains one typed failure code without changing stored Session data. Recovery guidance cannot identify which process owns the handle. Host tests cover the error name and ordinary failures with and without persistence; Client tests cover both locales and operation-local model failures. The keyless `queue-actions` Web scenario pins the writer-held toast and preserved draft through the shipped composition. `verify-optional-dependency-imports` guards module loading against optional value imports.
@@ -0,0 +1,25 @@
# Agent Note: 会话写句柄占用反馈
Status: implemented
[English](2026-09-16-session-writer-held-feedback.md) | 中文
## Problem
Agent 空闲时,Session 仍可能持有写句柄。另一个 Host 无法恢复该会话,但通用内部错误 toast 没有提供恢复指引。持有者也可能属于同一个进程,因此仅凭占用不能确定是另一个正在运行的应用。
## Decision
当恢复因 `SessionAlreadyOwnedError` 失败且不存在可复用 Agent 时,Session Controller 返回 `session/writer-held`,携带 `{ sessionId }`。Client 遵循 [Remote 失败词汇](../architecture/2026-08-28-ctx-remote-failure-vocabulary.zh.md),按 code 判别。Controller 按 Error 名称识别持久化错误,与 Session Query 的可选依赖处理一致;加载 Controller 不需要持久化实现或错误类身份相同。
发送和模型选择失败显示本地化指引,说明可能有其他 DSH 实例占用 Session,并建议退出其他实例后重试。模型选择将原始 Remote 结果返回给两个 UI 入口;错误分类不依赖稍后读取共享目录状态。[写租约决策](../feature/2026-08-31-cross-process-session-write-lease.zh.md) 继续拥有锁定和释放语义;此反馈既不抢占写权限,也不自动重试写入。
## Alternatives considered
**在 `session/agent-busy` 下使用自由文本 reason。** 第二个字符串判别值失去 code 与 details 的类型关系,并将写占用与提示词接纳失败混在一起。
**单独的目录标志或必需的持久化 peer。** 标志重复记录操作失败,且可能在目录刷新后过时。仅为识别错误而强制依赖持久化,会移除对无持久化部署的支持。
## Consequences
Wire 新增一个有类型的失败码,不改变已存储的 Session 数据。恢复指引无法确定哪个进程持有句柄。Host 测试覆盖错误名称,以及有、无持久化时的普通失败;Client 测试覆盖两种语言和随模型操作返回的失败。无密钥的 `queue-actions` Web 场景通过随附组合固定写占用 toast 和保留草稿的输出。`verify-optional-dependency-imports` 防止可选依赖的值导入破坏模块加载。
@@ -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-continuable-subagent-conversations.md
2026-07-28-continuable-subagent-conversations.md: af4382a764a59a2d7c02f3cd5feffb32022441f8
2026-07-28-continuable-subagent-conversations.zh.md: 886e31fa3d88b78f875d51058bb2ec8c525837fe
2026-07-28-continuable-subagent-conversations.md: a30966c4d614fda837bc2bac95f6b6e411de9791
2026-07-28-continuable-subagent-conversations.zh.md: 59a02a211e0ca861f819a7524e4d69ad09acdbbc
@@ -143,7 +143,7 @@ Session and descriptor persistence survive restart. Activation state, Agent inbo
This version covers continuable in-process children and leaves one-shot delegation unchanged. Remote providers require a separate Activation handle with equivalent authenticated control and child-first quiescence contracts before they can support the same behavior.
It adds no host-user continuation, subagent steering operation, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public residency query, new live-Activation or descendant limit, or runtime cache; the later [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md) added the one public stop operation on top of this lifecycle. Existing delegation-depth policy remains unchanged. Optional child-to-parent reporting is a later consumer of this lifecycle rather than part of the base continuable capability.
It adds no host-user continuation, subagent steering operation, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public residency query, or runtime cache; the later [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md) added the one public stop operation on top of this lifecycle. Existing delegation-depth policy remains unchanged. Optional child-to-parent reporting is a later consumer of this lifecycle rather than part of the base continuable capability.
## Alternatives considered
@@ -203,7 +203,7 @@ The implementation pins these behaviors:
Removing Jobs gives up generic background-work inspection, result collection, and exact Task cancellation. If those product features become requirements, they need a request ticket or inbox capability that does not reintroduce a second execution queue.
Retaining an Activation while descendants run consumes Agent resources proportional to the unfinished ownership graph. The existing delegation-depth policy still bounds nesting, but this version adds no live-Activation or total-descendant limit; settled historical Sessions retain no `AgentHandle`.
Retaining an Activation while descendants run consumes Agent resources proportional to the unfinished ownership graph. The existing delegation-depth policy still bounds nesting, and [shared Activation capacity](2026-09-15-continuable-activation-capacity.md) bounds live continuable descendants; settled historical Sessions retain no `AgentHandle`.
The process-local inbox and ownership graph do not coordinate two harness processes. Deployments allowing concurrent access to one persistence store still require a durable lease and mailbox protocol.
@@ -143,7 +143,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
本版本覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳约定,才能支持同样的行为。
它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存;后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.zh.md)在此生命周期之上补充了唯一的公开停止操作。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。
它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开驻留查询、以及运行时缓存;后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.zh.md)在此生命周期之上补充了唯一的公开停止操作。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。
## 曾考虑的替代方案
@@ -203,7 +203,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
移除 Task 会放弃通用后台工作检查、结果收集和精确 Task 取消。如果这些产品功能成为需求,就需要不会重新引入第二条执行队列的请求 ticket 或 inbox 能力。
在后代运行期间保留激活,会按尚未完成所有权图的规模消耗 Agent 资源。现有委派深度策略仍会限制嵌套层级,但本版本不新增在线激活数量或后代总数限制;已结算的历史会话不保留 `AgentHandle`。
在后代运行期间保留激活,会按尚未完成所有权图的规模消耗 Agent 资源。现有委派深度策略仍会限制嵌套层级,而[共享 Activation 容量](2026-09-15-continuable-activation-capacity.zh.md)限制存活的可续接后代数;已结算的历史会话不保留 `AgentHandle`。
进程内 inbox 和所有权图无法协调两个 harness 进程。允许多个进程并发访问同一持久化存储的部署,仍需要持久化 lease 和邮箱协议。
@@ -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-09-04-web-clickable-link-styles.md
2026-09-04-web-clickable-link-styles.md: 6fa61945143853a2812468638dafd23ebcd456dc
2026-09-04-web-clickable-link-styles.zh.md: 0b15a6c72df9151ffb79f7fb41d582e191e79be6
2026-09-04-web-clickable-link-styles.md: fc383387cede1517981358fd31a08f47c2d76c02
2026-09-04-web-clickable-link-styles.zh.md: 7336a740e2ba93fe67a504745dcf770cab3c803d
@@ -13,7 +13,7 @@ Clickable artifact links in the chat transcript wore four different costumes: ma
One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links:
- Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus.
- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
- Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show.
- Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style).
- In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border.
@@ -23,7 +23,7 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl
## Alternatives considered
- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead.
- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site favicons remain possible later behind the same `url` category.
- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site marks live behind the same `url` category ([known-site link marks](2026-09-16-known-site-link-marks.md)).
- **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line.
- **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows.
@@ -13,7 +13,7 @@ Status: implemented
会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言:
- 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。
- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
- 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。
- 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。
- 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。
@@ -23,7 +23,7 @@ Status: implemented
## 备选方案
- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。
- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。
- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份;按站点标记位于同一 `url` 类别之下([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md))。
- **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。
- **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。
@@ -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-09-15-continuable-activation-capacity.md
2026-09-15-continuable-activation-capacity.md: bf7ca6d78d7a39a9c5d42dbc000de98706863cb5
2026-09-15-continuable-activation-capacity.zh.md: 404c2c0f4eef9576670b51cd253b53ad0d4ef0d5
@@ -0,0 +1,35 @@
# Agent Note: Shared continuable Activation capacity
Status: implemented
English | [中文](2026-09-15-continuable-activation-capacity.zh.md)
## Problem
Depth limits bound nesting but permit wide concurrent delegation. Background Job limits do not cover continuable children, and a lifetime creation quota prevents useful later work after earlier children finish.
## Decision
The subagent service configures `maxActiveSubagents`, defaulting to 8. Each live non-continuable parent owns one process-local pool, shared by reference through uninterrupted continuable parent links. The pool owner itself is excluded. One-shot runs and external-provider work do not enter this pool. A one-shot intermediate parent starts a separate pool for its continuable children; cross-one-shot capacity inheritance is deferred. Delegation depth remains independently configured.
The Activation registry reserves a unique slot before fresh or cold-resume reconstruction yields. The materialization owns rollback until the Activation owns the slot; unpublished rollback and failed materialization may both release the same token safely. Handle disposal precedes release, which precedes parent settlement notification. Sending to an existing Activation reuses its slot.
Pool lookup, admission and release take amortized constant time. A weak root map does not retain dead root Agents; each pool holds only occupied tokens. No Session catalog scan, tree traversal, durable counter, or public capacity-query API is added.
The Host registers the `subagent` settings section over its composition. Each reservation reads the current capacity, so lowering it never evicts resident children or rebuilds their registry. Delegation tools resolve an omitted depth from the same section at each attempt; explicit numeric and provider-managed tool policies retain priority. Keeping counts in the pool and policy in settings avoids a second live counter or a settings-triggered teardown.
## Alternatives considered
A cumulative child count is a separate cost policy and does not release capacity after useful work finishes. Counting model requests would omit waiting parents, queued inbox work, reconstruction and cleanup, all of which retain resources. Scanning resident and pending maps adds work proportional to unrelated live trees. Separate counters require synchronization with slot ownership.
Waiting for capacity can deadlock when every slot belongs to a parent waiting for a descendant. Full pools reject with an actionable diagnostic instead of retaining queued activation requests.
## Consequences
Idle Activations with pending inbox work or owned descendants still occupy slots. Cold resume can fail at capacity even though the historical child exists; browser prompts report this as `subagent/delivery-unavailable`. Slots do not impose a token or cumulative spending budget, and they do not coordinate multiple harness processes.
The [continuable lifecycle decision](2026-07-28-continuable-subagent-conversations.md) retains ownership of settlement and child-first teardown. This capacity policy extends that lifecycle without changing durable Session data.
## Verification
Focused continuation tests exercise the default, invalid configuration, shared multi-level capacity, root isolation, pending creation, failure release, cold resume, resident messages and disposal. A keyless SDK-profile snapshot records successful background creation followed by an over-capacity tool diagnostic while the first child remains live.
@@ -0,0 +1,35 @@
# Agent Note: 共享的可续接 Activation 容量
Status: implemented
[English](2026-09-15-continuable-activation-capacity.md) | 中文
## Problem
深度限制约束嵌套层数,但仍允许大量并发委派。后台 Job 限制不覆盖可续接子代理,而生命周期累计创建配额会在早期子代理完成后阻止有用的后续工作。
## Decision
Subagent 服务通过 `maxActiveSubagents` 配置容量,默认值为 8。每个存活的非可续接父代理拥有一个进程内池,通过连续的可续接父子关系按引用共享。池的所有者自身不计入。一次性运行和外部提供方工作不进入此池。一次性中间父代理为其可续接子代理建立独立的池;跨一次性代理的容量池继承暂不实现。委派深度仍独立配置。
Activation registry 在新建或冷恢复重建首次让出执行前预占唯一名额。在 Activation 接管名额前,由 materialization 负责回滚;未发布回滚和失败的 materialization 可以安全地释放同一个 token。handle 释放先于名额归还,名额归还先于父代理完成通知。向已有 Activation 发送消息复用其名额。
池查找、接纳和释放的摊还时间复杂度均为常数。根代理的弱引用映射不会保留已结束的根 Agent;每个池只持有已占用的 token。不增加 Session 目录扫描、树遍历、持久计数器或公开的容量查询 API。
Host 在组合配置之上注册 `subagent` 设置分节。每次预占读取当前容量,因此调低上限不会驱逐驻留子代理,也不会重建注册表。委派工具在每次尝试时从同一分节解析省略的深度;显式数值和 provider-managed 工具策略保留优先级。池持有计数、设置持有策略,避免增加第二份在线计数或因设置变更而触发拆除。
## Alternatives considered
累计子代理计数是另一种成本策略,不会在有用工作完成后释放容量。只统计模型请求会遗漏等待后代的父代理、收件箱排队内容、重建和清理,这些阶段都占用资源。扫描驻留和待创建映射会增加与无关存活任务树数量成正比的工作。独立计数器需要与名额所有权同步。
当所有名额都属于等待后代的父代理时,等待容量可能造成死锁。池满时返回可操作诊断,不保留排队的激活请求。
## Consequences
有待处理收件箱内容或所拥有后代的空闲 Activation 仍占名额。容量耗尽时,即使历史子代理存在,冷恢复也可能失败;浏览器消息将其报告为 `subagent/delivery-unavailable`。名额不构成 token 或累计费用预算,也不协调多个 Harness 进程。
[可续接生命周期决策](2026-07-28-continuable-subagent-conversations.zh.md) 仍负责完成判定和先子后父的清理。本容量策略扩展该生命周期,不修改持久 Session 数据。
## Verification
聚焦的续接测试覆盖默认值、非法配置、多层共享容量、根代理隔离、待完成创建、失败归还、冷恢复、驻留消息和释放。免密 SDK profile 快照记录后台创建成功,以及首个子代理仍存活时再次创建所产生的超限工具诊断。
@@ -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-09-16-known-site-link-marks.md
2026-09-16-known-site-link-marks.md: 9d029af06f9ca0f3fa8290112c7023ec1b9e0b84
2026-09-16-known-site-link-marks.zh.md: 2c7630d122d1576ba41cb653cf0b86e9c5a2ddb4
@@ -0,0 +1,38 @@
# Agent Note: Known-site link marks
Status: implemented
English | [中文](2026-09-16-known-site-link-marks.zh.md)
## Problem
Every transcribed anchor led with the same globe, so a transcript full of GitHub, npm, and documentation links gave no signal about where a link goes until the reader parsed its label. The [clickable-link vocabulary](2026-09-04-web-clickable-link-styles.md) reserved that leading seat for one category glyph and left per-site marks as a possible later extension of the `url` category.
## Decision
`LinkIcon` takes an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site.
`SiteGlyph.tsx` in ui-primitives owns the mapping: forty host suffixes resolve to thirty-four marks. The developer sites a transcript usually cites: GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News (`news.ycombinator.com`), YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, CSDN. The mainstream sites a general audience links: search (Google, Baidu, DuckDuckGo), video and audio (TikTok, Netflix, Spotify), social and messaging (Facebook, Instagram, Reddit, Telegram with its `t.me` short links, WhatsApp with `wa.me`, WeChat via `weixin.qq.com`, QQ, Weibo), shopping (Taobao, AliExpress, eBay), reference and community (Quora, V2EX), and Apple. A host matches a suffix when it equals it or is a subdomain of it, and the longest matching suffix wins, so `gist.github.com` and `en.wikipedia.org` need no entry while `weixin.qq.com` keeps WeChat rather than the QQ mark its `qq.com` suffix would also select. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe.
The artwork is the [Simple Icons](https://simpleicons.org) set (CC0-1.0) consumed as a devDependency of ui-primitives, so adding a site is one host-map entry and the artwork version comes from the lockfile rather than a copied path. Each mark renders as that set's single path on its 24-unit viewBox, inset to a 28-unit box so it keeps the ~8% margin the 20-unit `ic_ds_*` glyphs draw inside their own box, and takes the link's `currentColor` rather than the brand fill the set records. Every mark is `aria-hidden`; the anchor's own text remains the accessible name.
Taking `currentColor` is deliberate for a functional link glyph rather than brand presentation: YouTube, X, Instagram, and other brands publish guidelines against recoloring their marks, and the first alternative below weighs the fixed-brand-fill option. CC0-1.0 covers the path data; every mark remains its owner's trademark, and the generated third-party notices cover the package rather than this artwork.
Two consumers pass their destination: the markdown renderer's `renderSafeLink`, which covers authored anchors, reference links, and URL-promoted inline code, and the web card's source and fetch links. Both already hold the sanitized destination.
## Alternatives considered
- **Fetching each site's favicon** from the site itself or a favicon service. This covers arbitrary sites, but rendering a transcript would issue network requests to every linked host, which discloses reading activity and turns a text render into a network operation; it also fails offline and behind a strict `img-src` policy. Rejected: a fixed local vocabulary keeps rendering deterministic and private, and the globe remains the honest fallback.
- **Filling the mark with the site's brand color.** Rejected: the link glyphs are `currentColor`-only so they follow the link alias, dark mode, and hover; a fixed fill would be the first exception and would fight the link's own hover color.
- **Copying the path data into this module.** Rejected: a third-party artwork set that changes upstream is exactly what a pinned dependency tracks; fourteen embedded copies would leave no update path.
- **Adding site values to `LinkIconKind`.** Rejected: the kind is the category the consumer states, while the site is derived from the destination; folding both into one union would make every consumer spell out a site it does not know.
- **A monogram tile for every unmapped host.** Rejected as noise: a generated letter capsule claims a site identity without carrying one, and 14px leaves no room for readable initials.
## Consequences
- Adding a site needs a host-map entry in `SiteGlyph.tsx` and, to be checked, a URL in the `link-icon` spec: that spec lists a representative URL per mapped site, requires thirty-four distinct marks across them, and pins the GitHub, npm, YouTube, Wikipedia, X, Telegram, and WhatsApp aliases. A dropped or duplicated entry fails the count, while a site added to the map alone stays uncovered by the spec.
- The vocabulary stays bounded by category rather than by demand: each site costs one map entry, one upstream mark, and one spec URL, and the thirty-four marks add roughly 23 KB of path data to the source, which the shell build reduces to a few KB in the entry chunk after tree-shaking, minification, and compression.
- `simple-icons` enters the browser build graph as a development dependency of ui-primitives; the shell build tree-shakes the imported marks instead of the complete set.
- Unknown hosts, non-http schemes, unparseable destinations, and file categories keep the existing globe or category glyph; the `mailto` link in a transcript still shows the globe.
- The vocabulary is deliberately finite. Sites such as `example.com` never get a mark from this mechanism; recognizing them would require the network fetching the alternatives reject.
- Coverage: the LinkIcon spec covers the aliases, the fallback branches, and the sizing seat; the markdown spec pins one known and one unknown anchor; the web-card spec pins a source and a fetch link against the same marks.
@@ -0,0 +1,38 @@
# Agent Note: 已知站点链接标记
Status: implemented
[English](2026-09-16-known-site-link-marks.md) | 中文
## Problem
转写内容中的每个锚点都以同一个地球图形开头,因此满是 GitHub、npm 与文档链接的对话在读者解析标签之前,无法从图形看出链接去向。[可点击链接词汇](2026-09-04-web-clickable-link-styles.zh.md) 把该前置位置留给一个分类图形,并把按站点区分的标记留作 `url` 类别日后可能的扩展。
## Decision
`LinkIcon` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。
ui-primitives 的 `SiteGlyph.tsx` 持有该映射:四十个主机后缀解析为三十四个标记。转写内容常引用的开发者站点:GitHub(`github.com`、`github.io`、`raw.githubusercontent.com`)、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News(`news.ycombinator.com`)、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。普通用户常访问的主流站点:搜索(Google、百度、DuckDuckGo)、视频与音频(TikTok、Netflix、Spotify)、社交与通讯(Facebook、Instagram、Reddit、Telegram 及 `t.me` 短链、WhatsApp 及 `wa.me`、`weixin.qq.com` 上的微信、QQ、微博)、购物(淘宝、速卖通、eBay)、参考资料与社区(Quora、V2EX)以及 Apple。主机等于某后缀或为其子域即匹配,且最长匹配后缀优先,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记,而 `weixin.qq.com` 得到微信标记而不是其 `qq.com` 后缀同样会选中的 QQ 标记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。
图形取自 [Simple Icons](https://simpleicons.org) 图标集(CC0-1.0),以 ui-primitives 的 devDependency 形式引入,因此新增站点只是主机映射中的一条记录,图形版本由 lockfile 固定,而不是复制路径数据。每个标记以该图标集的单条 path 绘制在其 24 单位 viewBox 上,并内缩到 28 单位盒中,以保留 20 单位 `ic_ds_*` 图形在自身盒内约 8% 的留白;填充使用链接的 `currentColor`,而不是图标集记录的品牌填充色。所有标记均为 `aria-hidden`,锚点自身文本仍是可访问名称。
对功能性链接图形采用 `currentColor` 是刻意的取舍,而非品牌呈现:YouTube、X、Instagram 等品牌公布的规范禁止为其标记改色,下方第一个备选方案比较了固定品牌填充的做法。CC0-1.0 覆盖路径数据;每个标记仍属其所有者的商标,生成的第三方声明覆盖该 npm 包本身,而不是这份图形。
两处消费者传入自己的目的地:markdown 渲染器的 `renderSafeLink`(覆盖作者书写的锚点、引用式链接与提升为链接的行内代码)以及 web 卡片的来源与抓取链接。两者本已持有经安全校验的目的地。
## Alternatives considered
- **抓取各站点的 favicon**,无论取自站点本身还是 favicon 服务。这能覆盖任意站点,但渲染一份转写内容会向每个被链接的主机发起网络请求,泄露阅读行为并把文本渲染变成网络操作;它在离线与严格 `img-src` 策略下同样失败。拒绝:固定的本地词汇表让渲染保持确定与私密,地球仍是诚实的回退。
- **以站点品牌色填充标记。** 拒绝:链接图形只使用 `currentColor`,以跟随链接 alias、暗色模式与 hover;固定填充会成为首个例外,并与链接自身的 hover 颜色冲突。
- **把路径数据复制进本模块。** 拒绝:上游会持续更新的第三方图形集正是需要固定版本依赖来跟踪的内容;十四个内嵌副本没有更新路径。
- **把站点值加进 `LinkIconKind`。** 拒绝:kind 是消费者陈述的分类,而站点由目的地推导;把两者折进同一联合类型会迫使每个消费者写出自己并不知晓的站点。
- **为每个未映射主机生成首字母方块。** 以噪音为由拒绝:自动生成的字母胶囊宣称了一种站点身份却没有承载它,14px 也没有留下可读字母的空间。
## Consequences
- 新增站点需要在 `SiteGlyph.tsx` 的主机映射中加一条记录,并在 `link-icon` spec 中补一个 URL 才会被检查:该 spec 为每个已映射站点列出一个代表 URL、要求它们共产生三十四个互不相同的标记,并固定 GitHub、npm、YouTube、Wikipedia、X、Telegram 与 WhatsApp 的别名。记录丢失或重复会让数量断言失败,而只改主机映射、不补 spec URL 的新站点仍未被 spec 覆盖。
- 词汇表按类别而非按需求增长:每个站点需要一条主机映射、一个上游标记与一个 spec URL;这三十四个标记在源码中增加约 23 KB 路径数据,经 shell 构建的 tree-shake、压缩与 gzip 后进入入口 chunk 的只有几 KB。
- `simple-icons` 作为 ui-primitives 的开发依赖进入浏览器构建图;shell 构建会 tree-shake 所导入的标记,而不是整套图标。
- 未收录主机、非 http scheme、无法解析的目的地与文件类别都保留原有的地球或分类图形;转写内容中的 `mailto` 链接仍显示地球。
- 该词汇表刻意有限。像 `example.com` 这样的站点不会由此机制获得标记;要识别它们就需要被上述备选方案拒绝的网络抓取。
- 覆盖:LinkIcon spec 覆盖别名、各回退分支与尺寸座位;markdown spec 固定一个已知锚点与一个未知锚点;web 卡片 spec 用同一批标记固定一个来源链接与一个抓取链接。
@@ -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-09-15-shipped-optional-bundles.md
2026-09-15-shipped-optional-bundles.md: 6de3eb7d39b86a2e5dd6db756d2bf6561aeb8ed4
2026-09-15-shipped-optional-bundles.zh.md: 05c23c7c0cdd6a146b938370fec64377652e31d4
2026-09-15-shipped-optional-bundles.md: 40ef69d3880f8d1c925c1e3c423e59a628f22e09
2026-09-15-shipped-optional-bundles.zh.md: adf2b979b9d23b7510c077f3633e2d030732a7b4
@@ -10,11 +10,11 @@ The Web plugin page manages only the bundles a person installed into the profile
## Decision
The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page lists optional bundles in a built-in group with an official tag beside the profile's own installed bundles.
The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page opens its Official group with the optional bundles, tagged beta where the feature is one, ahead of the profile's own installed bundles.
Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed.
Agent Teams and Auto review ship this way first, as `@deepseek-ai/dsh-experimental-agent-team-profile`, `@deepseek-ai/dsh-experimental-agent-team-web-profile`, and `@deepseek-ai/dsh-experimental-auto-review`.
Agent Teams ships this way, as `@deepseek-ai/dsh-experimental-agent-team-profile` and `@deepseek-ai/dsh-experimental-agent-team-web-profile`. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle.
## Alternatives considered
@@ -10,11 +10,11 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样
## 决策
启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页把可选组合包放在带官方标签的内置分组里,与 profile 自己安装的组合包并列。
启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页以可选组合包开启「官方」分组,属于 beta 功能的带 beta 标签,排在 profile 自己已安装的组合包之前。
默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。
Agent Teams 与 Auto review 首先以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile`、`@deepseek-ai/dsh-experimental-agent-team-web-profile` 与 `@deepseek-ai/dsh-experimental-auto-review`。
Agent Teams 以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile` 与 `@deepseek-ai/dsh-experimental-agent-team-web-profile`。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。
## 考虑过的替代方案
@@ -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-09-06-frontend-performance-budgets.md
2026-09-06-frontend-performance-budgets.md: 2ccc992add9a1f7fc7824a3e0ed604a9582def56
2026-09-06-frontend-performance-budgets.zh.md: bfe31fd4c8c61b3b89336a8d9d8234d1e76ba040
2026-09-06-frontend-performance-budgets.md: ea91edf8d39d838ab58204f8516d9d1e9d50109f
2026-09-06-frontend-performance-budgets.zh.md: 84c115ef09f55babf96eb1ef8d2f472a139dd55d
@@ -68,6 +68,8 @@ Run 34036109842, job 101494445658 records open samples of 875.306861/1083.683529
A controlled mouse-refocus delay waits for the real DONE marker without pausing replay: the mouse path rejects a trusted input after DONE, while Enter submission and keyboard-only draft input pass all three samples under the same control. The delay is diagnostic-only. A clean three-sample run on arm64 Node 24.19.0 / Chromium 149.0.7827.55 reports first-reply/input/complete-wall medians of 288.823/418.868/2567.328 ms, with actual overlap and post-DONE rejection in every sample. This proves removal of the mouse-action scheduling dependency, not the cause of a particular hosted stall; all workload constants and budgets remain fixed.
The current Trajectory acceptance limit is 650 ms, represented by a 520 ms target with the shared 1.25× headroom. Run 35095609422 records medians of 666.651723 ms and 630.843184 ms on its original attempt and retry. This explicit 4% relaxation of the former 625 ms limit accepts the retry median but still rejects the original median; it is a budget decision, not a measured product speedup. Recorded retry samples exercise the accepted range, and the same verdict assertion rejects 651 ms. Other endpoint budgets and workload parameters remain unchanged.
## Alternatives considered
**Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim.
@@ -68,6 +68,8 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式
受控的鼠标重新聚焦延迟等待真实 DONE 标记,不暂停重放:鼠标路径拒绝 DONE 之后的真实输入,而 Enter 提交与纯键盘草稿输入在相同对照下通过全部三个样本。该延迟仅用于诊断。在 arm64 Node 24.19.0 / Chromium 149.0.7827.55 上,不含延迟的三个样本报告首段回复/输入/完整壁钟中位数 288.823/418.868/2567.328 ms,每个样本均满足实际重叠并拒绝 DONE 之后的输入。这证明移除了鼠标操作调度依赖,并不证明某次托管停顿的原因;全部工作负载常量和预算保持固定。
当前 Trajectory 验收上限为 650 ms,由 520 ms 目标与共享的 1.25× 余量表示。运行 35095609422 的首次执行与重试分别记录中位数 666.651723 ms 和 630.843184 ms。相较于原先的 625 ms 上限,这次明确的 4% 放宽接受重试中位数,但仍拒绝首次中位数;这是预算决策,不代表测得产品提速。记录的重试样本覆盖新增的接受区间,同一个判定断言拒绝 651 ms。其他终点预算与工作负载参数保持不变。
## 考虑过的替代方案
**用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。
+1
View File
@@ -111,6 +111,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`semver`](https://github.com/npm/node-semver) | ISC |
| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 |
| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
| [`tsx`](https://github.com/privatenumber/tsx) | MIT |
| [`turndown`](https://github.com/mixmark-io/turndown) | MIT |
-1
View File
@@ -49,7 +49,6 @@
"@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
"@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^",
"@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^",
"@deepseek-ai/dsh-experimental-auto-review": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-round-driver": "workspace:^",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
README.md: dd8256fbe81d8a0e3d6993875f577cbc6f8b027e
README.zh.md: a7d9417227feb59cfca9bb04167e5258a6840d69
README.md: 6429b2872ec393304a7d3a76afd8194cb74d7698
README.zh.md: c9d9bee8ad84e3fb0c3f4d82b295c743f5a5ad99
+9
View File
@@ -50,6 +50,15 @@ dsh --profile web --patch ./extra.yml --dump-config
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files but does not prepare the runtime module fallback under `$DSH_HOME/profiles/node_modules`. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
<a id="startup-diagnostics"></a>
## Startup diagnostics
A required plugin activation failure prints failed plugins and their original stacks, followed by pending plugins and missing services. Required plugins appear first in the pending list. The final `Full diagnostics:` line names a unique `startup-<timestamp>-<uuid>.log` file directly under `$DSH_HOME/logs/` (default `~/.dsh/logs/`). The CLI completes report and stderr writes before explicitly exiting with code 1, even if plugin handles remain open; it does not overwrite earlier reports or delete them automatically.
The report includes DSH and Node versions, platform, profile, root configuration path, every inactive plugin's module and state, original errors, and startup warning/error arguments, including import errors that have no Fiber. Node inspection preserves nested causes, aggregate members, circular references, non-enumerable properties, and Symbol properties with depth, string, and array limits disabled. Custom inspectors are disabled and accessors are described without evaluating them. The collector includes asynchronous failure cleanup and stops when boot settles. It does not collect environment variables or configuration contents independently of logged errors. Raw plugin errors can contain configuration or credential values; the report header warns readers to review it before sharing. Values are not redacted.
New directories and files request modes `0700` and `0600` on POSIX. The CLI prints a file path only after writing succeeds. If the logs directory or file cannot be written, stderr includes the write error and full report, and the exit code remains 1. Optional-only activation warnings retain their normal output without creating a report.
## Plugin management
`dsh plugin --profile <name> <args...>` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `<args...>` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack.
+9
View File
@@ -52,6 +52,15 @@ dsh --profile web --patch ./extra.yml --dump-config
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件,但不会准备 `$DSH_HOME/profiles/node_modules` 下的运行时模块 fallback。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。
<a id="startup-diagnostics"></a>
## 启动诊断
必需插件激活失败时,CLI 先输出失败插件及其原始堆栈,再列出等待中的插件和缺失服务。等待列表中的必需插件排在前面。末尾的 `Full diagnostics:` 行指向直接位于 `$DSH_HOME/logs/`(默认 `~/.dsh/logs/`)下的唯一 `startup-<timestamp>-<uuid>.log` 文件。CLI 完成报告和 stderr 写入后会明确以退出码 1 结束,即使插件仍有打开的句柄;不会覆盖以前的报告,也不会自动删除它们。
报告包含 DSH 和 Node 版本、平台、profile、根配置路径、每个未激活插件的模块与状态、原始错误,以及启动期间的警告和错误参数,包括尚无 Fiber 的导入错误。Node 检查输出保留嵌套原因、聚合成员、循环引用、不可枚举属性和 Symbol 属性,并关闭深度、字符串及数组长度限制。自定义检查函数被禁用,访问器只描述而不求值。收集器包含失败后的异步清理日志,并在启动结算后停止。它不会独立于已记录错误额外收集环境变量或配置内容。插件原始错误可能包含配置或凭据值;报告开头会提醒读者在分享前检查内容。报告不脱敏。
在 POSIX 上,新目录和文件分别请求 `0700` 和 `0600` 权限。CLI 仅在写入成功后输出文件路径。如果日志目录或文件无法写入,stderr 会包含写入错误及完整报告,退出码仍为 1。只有可选插件激活异常时,维持正常警告输出,不创建报告。
## 插件管理
`dsh plugin --profile <name> <args...>` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `<args...>` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。
+18 -9
View File
@@ -8,8 +8,10 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot'
import { loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import { parseDshArgs } from './args.ts'
import { reportStartupFailure } from './startup-diagnostics.ts'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
// one directory under apps/cli, so the checked-in manifest resolves with the
@@ -26,18 +28,25 @@ function readVersion(): string {
* @returns a promise that settles when the selected command mode finishes.
*/
export async function runCli(): Promise<void> {
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
const version = readVersion()
const invocation = parseDshArgs(process.argv.slice(2), version)
switch (invocation.mode) {
case 'profile': {
const { runProfile } = await import('./profile-boot.ts')
await runProfile({
environment: loadLayeredEnv('dsh'),
profile: invocation.profile,
fromDefaultProfile: invocation.fromDefaultProfile,
patchFiles: invocation.patches,
args: invocation.args,
})
try {
await runProfile({
environment: loadLayeredEnv('dsh'),
profile: invocation.profile,
fromDefaultProfile: invocation.fromDefaultProfile,
patchFiles: invocation.patches,
args: invocation.args,
})
} catch (error) {
if (!(error instanceof StartupError)) throw error
await reportStartupFailure(error, { home: resolveDshHome(), version, profile: invocation.profile })
process.exit(1)
}
break
}
case 'plugin': {
+68
View File
@@ -0,0 +1,68 @@
/** Save original startup diagnostics while keeping the terminal report concise. */
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { inspect } from 'node:util'
import type { StartupError } from '@deepseek-ai/dsh-app-boot'
/** Launcher-owned context; no environment values or plugin configurations are collected. */
interface StartupDiagnosticContext {
home: string
version: string
profile: string
}
/** Wait for stderr to finish the write before the failed process exits. */
function writeStderr(text: string): Promise<void> {
return new Promise((resolve, reject) => {
process.stderr.write(text, (error) => {
if (error) reject(error)
else resolve()
})
})
}
/**
* Print the startup summary and save a private, uniquely named report under DSH_HOME/logs.
* Failed writes print the complete report to stderr instead of claiming a saved path.
* @param error - startup audit failure retaining plugin metadata and original errors.
* @param context - resolved Harness home, application version, and selected profile.
* @param write - terminal output sink; awaited before returning, defaults to stderr.
* @returns after saving or printing the report and completing terminal writes.
*/
export async function reportStartupFailure(
error: StartupError,
context: StartupDiagnosticContext,
write: (text: string) => void | Promise<void> = writeStderr,
): Promise<void> {
const now = new Date().toISOString()
const report = 'WARNING: Raw diagnostics may contain configuration or credential values from plugin errors. Review before sharing.\n\n' + inspect({
timestamp: now,
dshVersion: context.version,
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
profile: context.profile,
error,
}, {
depth: null,
maxArrayLength: null,
maxStringLength: null,
showHidden: true,
customInspect: false,
getters: false,
colors: false,
}) + '\n'
await write(`${error.message}\n`)
const logDir = join(context.home, 'logs')
const logPath = join(logDir, `startup-${now.replaceAll(':', '-')}-${randomUUID()}.log`)
try {
await mkdir(logDir, { recursive: true, mode: 0o700 })
await writeFile(logPath, report, { flag: 'wx', mode: 0o600 })
} catch (writeError) {
await write(`\ndsh: warning: could not write startup diagnostics: ${String(writeError)}\nFull diagnostics:\n${report}`)
return
}
await write(`\nFull diagnostics: ${logPath}\n`)
}
@@ -1,5 +1,5 @@
import { createServer } from 'node:http'
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -334,6 +334,47 @@ describe('Python SDK dsh profile keyless smoke', () => {
}
}, 40_000)
it.each([false, true])('exits after startup failure with stdin open (logs blocked: %s)', async (blocked) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-startup-exit-'))
const home = join(root, '.dsh')
const patch = join(root, 'failure.yml')
await mkdir(home)
if (blocked) await writeFile(join(home, 'logs'), 'blocked')
await writeFile(patch, '- id: agent-loop\n config:\n maxParallelToolCalls: 0\n')
const child = execa(process.execPath, [
'--import', 'tsx/esm', binScript, '--profile', 'sdk', '--patch', patch,
], {
cwd: repoRoot,
env: { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-call' },
stdin: 'pipe',
stripFinalNewline: false,
timeout: 25_000,
killSignal: 'SIGKILL',
reject: false,
})
try {
const result = await child
expect(result.timedOut, result.stderr).toBe(false)
expect(result.signal, result.stderr).toBeUndefined()
expect(result.exitCode, result.stderr).toBe(1)
expect(result.stderr).toContain('startup failed:')
expect(result.stderr).toContain('maxParallelToolCalls')
if (blocked) {
expect(result.stderr).toContain('Full diagnostics:\nWARNING: Raw diagnostics')
expect(result.stderr.trimEnd()).toMatch(/\}$/u)
} else {
const files = await readdir(join(home, 'logs'))
expect(files).toHaveLength(1)
expect(result.stderr).toContain(`Full diagnostics: ${join(home, 'logs', files[0]!)}\n`)
}
} finally {
child.stdin.end()
child.kill('SIGKILL')
await child
await rm(root, { recursive: true, force: true })
}
}, 30_000)
it('rejects an invalid max-token success env value', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-'))
try {
@@ -358,9 +399,8 @@ describe('Python SDK dsh profile keyless smoke', () => {
expect(exitCode, stderr).toBe(1)
expect(stdout).toBe('')
expect(stderr).toContain('plugin tree failed to load')
expect(stderr).toContain('required startup failure')
expect(stderr).toContain('sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server): SyntaxError')
expect(stderr).toContain('startup failed:')
expect(stderr).toContain('sdk-jsonrpc-server (required)\n Package: @deepseek-ai/dsh-sdk-jsonrpc-server\n SyntaxError')
expect(stderr).toContain('sometimes')
} finally {
await rm(root, { recursive: true, force: true })
@@ -3,7 +3,7 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import type { Readable } from 'node:stream'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
@@ -210,7 +210,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
: 'inject: [webProbeMissingRequiredService]'
const diagnostic = failure === 'disabled expression'
? 'disabled expression failed: SyntaxError'
: 'pending (waiting for service: webProbeMissingRequiredService)'
: 'webProbeMissingRequiredService'
writeFileSync(fixture.patch, `${readFileSync(fixture.patch, 'utf8')}- id: ${id}\n ${patch}\n`)
try {
const result = await execa(process.execPath, [
@@ -238,18 +238,20 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
expect(result.signal).toBeUndefined()
expect(result.exitCode).toBe(1)
expect(result.stdout).not.toContain('dsh web: http://')
expect(result.stderr).toContain('required startup failure')
expect(result.stderr).toContain(`${id} (@deepseek-ai/dsh-client-${id}): ${diagnostic}`)
expect(result.stderr).toContain('startup failed:')
expect(result.stderr).toContain(`${id} (required)`)
expect(result.stderr).toContain(diagnostic)
expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n')
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
it('fails the full Web profile when its required HTTP server cannot bind', async () => {
it.each([false, true])('fails the full Web profile when its required HTTP server cannot bind (logs blocked: %s)', async (logsBlocked) => {
const root = mkdtempSync(join(tmpdir(), 'dsh-web-required-bind-'))
const home = join(root, 'home')
mkdirSync(home)
if (logsBlocked) writeFileSync(join(home, 'logs'), 'blocked')
const blocker = createServer()
await new Promise<void>((resolve, reject) => {
const fail = (error: Error): void => { reject(error) }
@@ -289,8 +291,36 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
expect(result.signal).toBeUndefined()
expect(result.exitCode).toBe(1)
expect(result.stdout).not.toContain('dsh web: http://')
expect(result.stderr).toContain('required startup failure')
expect(result.stderr).toContain('EADDRINUSE')
expect(result.stderr).toContain('startup failed:')
expect(result.stderr).toContain('dsh: startup failed: 2 required plugins did not activate')
expect(result.stderr).toContain('Failed plugins (1):')
expect(result.stderr).toContain(' webserver (required)\n Package: @deepseek-ai/dsh-host-webserver')
expect(result.stderr).toContain('Plugins waiting for services (')
expect(result.stderr).toMatch(/connection \(required\) +webRuntime/u)
expect(result.stderr).toContain('at Server.setupListenHandle')
const summary = result.stderr.split(/\n\n(?:Full diagnostics:|dsh: warning:)/u)[0]!
expect(summary.match(/EADDRINUSE/gu)).toHaveLength(1)
expect(summary).not.toMatch(/dsh: warning:|\[cause\]|at boot \(|at runCli \(|Node\.js v/u)
let report: string
if (logsBlocked) {
expect(result.stderr).toContain('dsh: warning: could not write startup diagnostics:')
expect(result.stderr).not.toMatch(/Full diagnostics: [^\r\n]/u)
report = result.stderr.split('Full diagnostics:\n')[1]!
expect(readFileSync(join(home, 'logs'), 'utf8')).toBe('blocked')
} else {
const path = /Full diagnostics: ([^\r\n]+)/u.exec(result.stderr)?.[1]
expect(path).toBeDefined()
expect(dirname(path!)).toBe(join(home, 'logs'))
report = readFileSync(path!, 'utf8')
}
expect(report).toContain("profile: 'web'")
expect(report).toContain('nodeVersion:')
expect(report).toContain('dshVersion:')
expect(report).toContain('configurationPath:')
expect(report).toContain("code: 'EADDRINUSE'")
expect(report).toContain(`port: ${String(address.port)}`)
expect(report).toContain("module: '@deepseek-ai/dsh-client-connection'")
expect(report).toContain('at auditStartupEntries')
} finally {
await new Promise<void>((resolve, reject) => {
blocker.close((error) => { if (error === undefined) resolve(); else reject(error) })
@@ -178,7 +178,7 @@ describe.skipIf(!built)('Web process failure matrix', () => {
const result = await app.child
exit(result, 1)
expect(result.stdout).not.toContain('dsh web: http://')
expect(result.stderr).toContain('required startup failure')
expect(result.stderr).toContain('startup failed:')
expect(app.events()).toBe('witness apply 1\nwitness dispose 1\n')
} else {
await app.serves()
@@ -213,7 +213,7 @@ describe.skipIf(!built)('Web process failure matrix', () => {
} else writeFileSync(f.patch, f.render(id, undefined, 2))
await app.wait(() => app.events().includes(`target apply ${failure === 'dependency' ? 1 : 2}\n`))
await app.serves()
expect(app.stderr()).not.toContain('required startup failure')
expect(app.stderr()).not.toContain('startup failed:')
} finally {
const result = await app.close()
exit(result, 0)
@@ -288,7 +288,7 @@ describe.skipIf(!built)('Web process failure matrix', () => {
const app = start(f)
try {
await app.serves()
expect(app.stderr()).not.toContain('required startup failure')
expect(app.stderr()).not.toContain('startup failed:')
expect(app.stderr()).not.toContain('failed to import')
} finally { exit(await app.close(), 0) }
})
@@ -323,7 +323,7 @@ describe.skipIf(!built)('Web process failure matrix', () => {
await app.serves()
writeFileSync(f.patch, f.render('matrix-optional') + `- id: webserver\n config:\n host: 127.0.0.1\n port: ${address.port}\n`)
await app.wait(() => app.logs().includes('EADDRINUSE') && app.events().includes('witness apply 1\n'))
expect(app.stderr()).not.toContain('required startup failure')
expect(app.stderr()).not.toContain('startup failed:')
expect(app.events()).not.toContain('witness dispose 1\n')
expect(existsSync(f.serverUrl)).toBe(false)
writeFileSync(f.patch, f.render('matrix-optional', undefined, 2))
@@ -413,7 +413,7 @@ export function apply(ctx, config) {
writeFileSync(f.patch, pending)
await app.wait(() => app.state(id) === FiberState.PENDING && app.events().includes('witness apply 2\n'))
expect(app.events()).not.toContain('witness dispose 2\n')
expect(app.stderr()).not.toContain('required startup failure')
expect(app.stderr()).not.toContain('startup failed:')
writeFileSync(f.patch, pending + `- insert: ${JSON.stringify([{
id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 3), provider: 'matrixMissingWebDependency' },
}])}\n`)
+140
View File
@@ -0,0 +1,140 @@
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { inspect } from 'node:util'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { StartupError } from '@deepseek-ai/dsh-app-boot'
import { reportStartupFailure } from '../src/startup-diagnostics.ts'
async function home(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-startup-diagnostics-'))
onTestFinished(() => rm(dir, { recursive: true, force: true }))
return dir
}
function startupError(reason: unknown): StartupError {
const error = new StartupError('dsh: startup failed: 1 required plugin did not activate', [
{ id: 'webserver', module: './webserver.mjs', required: true, fiberState: 3, outcome: { kind: 'failed', error: reason } },
{ id: 'waiting', module: './waiting.mjs', required: false, fiberState: 0, outcome: { kind: 'pending', missing: ['webServer'] } },
])
error.startup = {
configurationPath: '/example/cordis.yml',
messages: [{ ts: 1, name: 'loader', type: 'error', args: [reason] }],
}
return error
}
describe('startup diagnostic files', () => {
it('prints the summary and saved path to stderr by default', async () => {
const dir = await home()
const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => {
if (typeof callback === 'function') callback()
return true
})
onTestFinished(() => { write.mockRestore() })
await reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' })
expect(write).toHaveBeenCalledWith(expect.stringContaining('dsh: startup failed:'), expect.any(Function))
expect(write).toHaveBeenCalledWith(expect.stringContaining(`Full diagnostics: ${join(dir, 'logs')}`), expect.any(Function))
})
it('waits for stderr completion before resolving', async () => {
const dir = await home()
const pending: Array<() => void> = []
const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => {
if (typeof callback === 'function') pending.push(() => { callback() })
return false
})
onTestFinished(() => { write.mockRestore() })
let finished = false
const report = reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' })
.then(() => { finished = true })
expect(pending).toHaveLength(1)
pending.shift()!()
await vi.waitFor(() => { expect(pending).toHaveLength(1) })
expect(finished).toBe(false)
pending.shift()!()
await report
expect(finished).toBe(true)
})
it('rejects when stderr cannot complete the write', async () => {
const dir = await home()
const write = vi.spyOn(process.stderr, 'write').mockImplementation((_text, callback?: BufferEncoding | ((error?: Error | null) => void)) => {
if (typeof callback === 'function') callback(new Error('stderr closed'))
return false
})
onTestFinished(() => { write.mockRestore() })
await expect(reportStartupFailure(startupError('failed'), { home: dir, version: '1.2.3', profile: 'web' }))
.rejects.toThrow('stderr closed')
})
it('retains original error properties, causes, aggregate members, cycles, and long values', async () => {
const dir = await home()
const chunks: string[] = []
const large = 'x'.repeat(12_000) + 'END_OF_VALUE'
const leaf = Object.assign(new Error('cannot listen'), { code: 'EADDRINUSE', payload: large })
Object.defineProperty(leaf, 'hiddenDetail', { value: 'non-enumerable detail' })
const symbol = Symbol('diagnostic-field')
Object.assign(leaf, { [symbol]: 42n, self: leaf, values: Array.from({ length: 105 }, (_, i) => `value-${i}`) })
let inspected = false
let getterRead = false
Object.defineProperty(leaf, inspect.custom, { value: () => { inspected = true; return 'hidden by custom inspector' } })
Object.defineProperty(leaf, 'lazy', { get: () => { getterRead = true; return 'evaluated getter' } })
const aggregate = new AggregateError([leaf, { transport: 'closed' }], 'activation failed', { cause: leaf })
const error = startupError(aggregate)
await reportStartupFailure(error, { home: dir, version: '1.2.3', profile: 'web' }, (text) => { chunks.push(text) })
const files = await readdir(join(dir, 'logs'))
expect(files).toHaveLength(1)
expect(files[0]).toMatch(/^startup-[\w.-]+\.log$/u)
const path = join(dir, 'logs', files[0]!)
expect(chunks.join('')).toBe(`${error.message}\n\nFull diagnostics: ${path}\n`)
const report = await readFile(path, 'utf8')
expect(report.startsWith(
'WARNING: Raw diagnostics may contain configuration or credential values from plugin errors. Review before sharing.\n\n',
)).toBe(true)
for (const text of [
'dshVersion: \'1.2.3\'', "profile: 'web'", process.version, 'configurationPath:', '/example/cordis.yml',
"module: './waiting.mjs'", 'required: false', 'fiberState: 0', "missing: [ 'webServer'", 'messages:',
'AggregateError: activation failed', '[cause]', '[errors]', 'EADDRINUSE', '[hiddenDetail]',
'non-enumerable detail', 'Symbol(diagnostic-field)', '42n', '[Circular', large, 'value-104', '[Getter]',
'transport:', 'closed', 'at startupError',
]) expect(report).toContain(text)
expect(inspected).toBe(false)
expect(getterRead).toBe(false)
if (process.platform !== 'win32') {
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect((await stat(join(dir, 'logs'))).mode & 0o777).toBe(0o700)
}
})
it('creates distinct files for concurrent failures without replacing earlier reports', async () => {
const dir = await home()
await Promise.all(['first', 'second'].map(reason => reportStartupFailure(
startupError(reason), { home: dir, version: '1.2.3', profile: 'web' }, () => {},
)))
const files = await readdir(join(dir, 'logs'))
expect(files).toHaveLength(2)
const reports = await Promise.all(files.map(file => readFile(join(dir, 'logs', file), 'utf8')))
expect(reports.filter(report => report.includes("error: 'first'"))).toHaveLength(1)
expect(reports.filter(report => report.includes("error: 'second'"))).toHaveLength(1)
})
it('prints the complete report when the logs directory cannot be created', async () => {
const dir = await home()
await writeFile(join(dir, 'logs'), 'blocked')
const chunks: string[] = []
await reportStartupFailure(startupError({ code: 'CUSTOM', value: 'original details' }), {
home: dir, version: '1.2.3', profile: 'web',
}, (text) => { chunks.push(text) })
const output = chunks.join('')
expect(output).toContain('dsh: startup failed:')
expect(output).toContain('dsh: warning: could not write startup diagnostics:')
expect(output).toContain('Full diagnostics:\n')
expect(output).toContain('original details')
expect(output).toContain('CUSTOM')
expect(output).not.toContain('Full diagnostics: ')
expect(await readFile(join(dir, 'logs'), 'utf8')).toBe('blocked')
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: 8fe85a24ff3c544bf9bf4ae6477141c38eb95a4d
README.zh.md: 456f96976bbfb899063e9b50fa24af851b4ace71
README.md: 7763152e43cd44a588883b3e346bcaf69e453a40
README.zh.md: a0758e0c5cc34c5cfd9883d54b490a9eab6f899f
+4 -2
View File
@@ -52,6 +52,8 @@ Electron chooses typed English or Chinese shell copy from its application locale
Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy.
On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Windows and Linux keep the application and Edit menus.
### Runtime and plugin activation
The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
@@ -64,11 +66,11 @@ The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version
CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable all third-party plugins and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable third-party plugins, back up profile patch, and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
Recovery waits for Host shutdown before changing plugin activation. The native recovery action disables third-party bundles by writing the profile under its transaction lock without loading runtime metadata or deleting files. Invalid profile data or write failures are reported as recovery-operation errors; Desktop does not restart as though disabling succeeded. Desktop has no profile-reset action or emergency HTML document.
Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
+4 -2
View File
@@ -52,6 +52,8 @@ Electron 根据应用语言选择类型化的英文或中文 shell 文案,并
Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。
macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Windows 和 Linux 保留应用菜单和 Edit 菜单。
### 运行时与插件激活
签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
@@ -64,11 +66,11 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用全部第三方插件并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用第三方插件、备份 profile patch 并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障。
原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作禁用第三方 bundle 时持有事务锁写入 profile,不加载运行时元数据,也不删除文件。profile 数据无效或写入失败会作为恢复操作错误报告;Desktop 不会假装禁用成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
+2 -2
View File
@@ -9,7 +9,7 @@ export const en = {
exitApplication: 'Exit',
restartApplication: 'Restart',
recoveryOperationFailed: 'The recovery operation failed',
disableThirdPartyPlugins: 'Disable all third-party plugins and restart',
disableThirdPartyPlugins: 'Disable third-party plugins, back up profile patch, and restart',
pluginsMenu: 'Desktop Plugins…',
checkUpdatesMenu: 'Check for Updates…',
updateCheckFailedTitle: 'Update Check Failed',
@@ -59,7 +59,7 @@ export const zh = {
exitApplication: '退出',
restartApplication: '重启',
recoveryOperationFailed: '恢复操作失败',
disableThirdPartyPlugins: '禁用全部第三方插件并重启',
disableThirdPartyPlugins: '禁用第三方插件、备份 profile patch 并重启',
pluginsMenu: '桌面插件…',
checkUpdatesMenu: '检查更新…',
updateCheckFailedTitle: '更新检查失败',
+14 -3
View File
@@ -37,7 +37,8 @@ const recovery = new DesktopFatalRecovery({
stop: () => { shuttingDown = true; return stopForRecovery() },
disablePlugins: async () => {
const manager = new DesktopProjectManager(resolveDesktopPaths(), runtimeResources())
await manager.disableAllPlugins()
const backupPath = await manager.disableAllPlugins()
console.info('Desktop profile recovery completed:', { profilePatchBackup: backupPath ?? null, homePatch: 'unchanged' })
},
exit: () => { app.quit() },
restart: () => { app.relaunch(); app.quit() },
@@ -427,8 +428,17 @@ async function main(): Promise<void> {
void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`)
}
// A custom application menu replaces Electron's default menu, so macOS needs
// its standard menus and application hide commands declared explicitly.
const darwin = process.platform === 'darwin'
const platformMenus: MenuItemConstructorOptions[] = darwin
? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]
: [{ role: 'editMenu' }]
const hideCommands: MenuItemConstructorOptions[] = darwin
? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }]
: []
Menu.setApplicationMenu(Menu.buildFromTemplate([{
label: process.platform === 'darwin' ? app.name : messages.application,
label: darwin ? app.name : messages.application,
submenu: [
{
label: messages.pluginsMenu,
@@ -437,9 +447,10 @@ async function main(): Promise<void> {
},
{ label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } },
{ type: 'separator' },
...hideCommands,
{ role: 'quit' },
],
}, { role: 'editMenu' }]))
}, ...platformMenus]))
const createMainWindow = (): BrowserWindow => {
const window = createWindow(appPreload, true)
+6 -8
View File
@@ -25,7 +25,7 @@ import type { DesktopPaths } from './paths.ts'
import type { DesktopRelease } from './release.ts'
import { readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts'
import {
initProfile, PROFILE_TEMPLATES, readProfileManifest, readProfilePlugins, reconcileProfilePlugins,
initProfile, PROFILE_TEMPLATES, readProfilePlugins, reconcileProfilePlugins, sanitizeProfile,
unlinkProfileModuleFallback, writeProfileBundles, type ProfileTemplate,
} from '@deepseek-ai/dsh-app-boot'
import { migrateDesktopProfileLinks } from './profile-packages.ts'
@@ -122,14 +122,12 @@ export class DesktopProjectManager {
}
/**
* Disable third-party bundles without loading application resources or deleting plugin files.
* @returns Completion of the locked profile write; the caller must stop the Host first.
* Back up the profile patch and disable third-party bundles without loading application resources.
* The caller must stop the Host first.
* @returns Backup path after the locked profile write, or undefined if the patch was absent.
*/
async disableAllPlugins(): Promise<void> {
await this.withLock(() => {
if (!existsSync(join(this.paths.profile, 'package.json'))) return
writeProfileBundles(this.paths.profile, readProfileManifest('dsh', this.paths.profile), WEB_PROFILE.bundles)
})
async disableAllPlugins(): Promise<string | undefined> {
return this.withLock(() => sanitizeProfile('dsh', this.paths.profile, WEB_PROFILE.bundles))
}
/** Read the dsh version supplied by this application's verified resources. */
@@ -6,4 +6,4 @@ Plugin initialization failed
If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.
Exit
Restart
Disable all third-party plugins and restart
Disable third-party plugins, back up profile patch, and restart
@@ -6,4 +6,4 @@ Plugin initialization failed
如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。
退出
重启
禁用全部第三方插件并重启
禁用第三方插件、备份 profile patch 并重启
+39 -4
View File
@@ -1,8 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IpcMainInvokeEvent } from 'electron'
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
import { join } from 'node:path'
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
import { DESKTOP_IPC } from '../src/ipc.ts'
import { en } from '../src/locale.ts'
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
@@ -89,7 +90,10 @@ const harness = await vi.hoisted(async () => {
failWindow(error: Error) { windowFailure = error },
popup,
socketHeaders: vi.fn(),
menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn(() => ({ popup })) },
menu: {
setApplicationMenu: vi.fn(),
buildFromTemplate: vi.fn((_items: MenuItemConstructorOptions[]) => ({ popup })),
},
dialog: {
showOpenDialog: vi.fn(),
showErrorBox: vi.fn(),
@@ -98,7 +102,10 @@ const harness = await vi.hoisted(async () => {
openExternal: vi.fn(),
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
mutateFailure: vi.fn<() => void>(),
disableAllPlugins: vi.fn(async () => { pluginsEnabled = false }),
disableAllPlugins: vi.fn(async () => {
pluginsEnabled = false
return 'desktop-test-profile/cordis.patch.yml.bak-1789555200000'
}),
get preparing() { return preparing }, get prepared() { return prepared },
get hostStarted() { return hostStarted }, get navigated() { return navigated },
get dialogShown() { return dialogShown }, get quitCompleted() { return quitCompleted },
@@ -168,6 +175,7 @@ beforeEach(() => {
harness.reset()
harness.dialog.showMessageBox.mockImplementation(() => { harness.dialogShown.resolve(); return new Promise(() => {}) })
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'info').mockImplementation(() => {})
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' })
@@ -202,6 +210,26 @@ describe('desktop main startup', () => {
expect(harness.hosts).toHaveLength(0)
})
it.each(['darwin', 'win32', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
await import('../src/main.ts')
await harness.preparing.promise
const describeItem = (item: MenuItemConstructorOptions): string | undefined =>
item.role ?? (item.type === 'separator' ? 'separator' : item.label)
const template = harness.menu.buildFromTemplate.mock.calls
.map(call => call[0])
.find(items => items.some(item => item.role === 'editMenu'))
if (template === undefined) throw new Error('application menu missing')
expect(template.map(describeItem)).toEqual(platform === 'darwin'
? ['Desktop test', 'fileMenu', 'editMenu', 'windowMenu']
: ['Application', 'editMenu'])
const application = template[0]!.submenu as MenuItemConstructorOptions[]
expect(application.map(describeItem)).toEqual(platform === 'darwin'
? [en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'hide', 'hideOthers', 'unhide', 'separator', 'quit']
: [en.pluginsMenu, en.checkUpdatesMenu, 'separator', 'quit'])
expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce()
})
it('attaches Host socket credentials only to the owned application origin and window', async () => {
await import('../src/main.ts')
await harness.preparing.promise
@@ -393,7 +421,7 @@ describe('desktop main startup', () => {
harness.prepared.reject(new Error('runtime resources missing'))
await harness.dialogShown.promise
expect(harness.dialog.showMessageBox.mock.calls[0]![0].detail).toContain('runtime resources missing')
expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable all third-party plugins and restart'])
expect(harness.dialog.showMessageBox.mock.calls[0]![0].buttons).toEqual(['Exit', 'Restart', 'Disable third-party plugins, back up profile patch, and restart'])
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
})
@@ -449,6 +477,13 @@ describe('desktop main startup', () => {
await harness.quitCompleted.promise
expect(harness.app.relaunch).toHaveBeenCalledTimes(response === 0 ? 0 : 1)
expect(harness.disableAllPlugins).toHaveBeenCalledTimes(response === 2 ? 1 : 0)
if (response === 2) {
expect(console.info).toHaveBeenCalledWith('Desktop profile recovery completed:', {
profilePatchBackup: 'desktop-test-profile/cordis.patch.yml.bak-1789555200000', homePatch: 'unchanged',
})
} else {
expect(console.info).not.toHaveBeenCalled()
}
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
expect(harness.windows[0]!.urls).toEqual(['dsh-app://app/'])
})
+11 -5
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -109,15 +109,19 @@ describe('desktop external plugin profile', () => {
await expect(manager.applyRelease()).resolves.toBeUndefined()
})
it('disables plugins before runtime initialization and preserves configuration and package files', async () => {
it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => {
const { manager } = setup()
await manager.applyRelease()
await manager.mutate({ type: 'plugin-add', spec: '[email protected]' }, hooks())
const patch = join(manager.paths.profile, 'cordis.patch.yml')
writeFileSync(patch, ': broken')
const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
await uninitialized.disableAllPlugins()
expect(readFileSync(patch, 'utf8')).toBe(': broken')
const backupPath = await uninitialized.disableAllPlugins()
expect(existsSync(patch)).toBe(false)
const backups = readdirSync(manager.paths.profile).filter(name => name.startsWith('cordis.patch.yml.bak-'))
expect(backups).toHaveLength(1)
expect(backupPath).toBe(join(manager.paths.profile, backups[0]!))
expect(readFileSync(join(manager.paths.profile, backups[0]!), 'utf8')).toBe(': broken')
expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
const manifest = JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as {
dependencies: Record<string, string>
@@ -126,11 +130,13 @@ describe('desktop external plugin profile', () => {
expect(manifest.dependencies.plugin).toBe('1.0.0')
expect(manifest.dsh.profile.bundles).not.toContain('plugin')
expect(manifest.dsh.profile.bundles).toContain('@deepseek-ai/dsh-web-app')
await manager.applyRelease()
expect(readFileSync(patch, 'utf8')).toContain('[]')
})
it('needs no runtime or package manifest when no plugins have been installed', async () => {
const { manager } = setup()
await manager.disableAllPlugins()
await expect(manager.disableAllPlugins()).resolves.toBeUndefined()
expect(existsSync(join(manager.paths.profile, 'package.json'))).toBe(false)
expect(existsSync(manager.paths.lock)).toBe(false)
})
@@ -254,7 +254,6 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
// new-session screen with the self-referential preset staged, and the
// blank session the flow produces composes from it on the host.
await dialog.waitFor({ state: 'detached', timeout: 10_000 })
await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 })
await expect.poll(async () => {
const response = await scaffold.hostFetch('/api/session/list', {
method: 'POST',
+2 -3
View File
@@ -752,7 +752,7 @@ describe('web e2e: long Chat scroll contract', () => {
})
}, 180_000)
it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
it.skipIf(MODE === 'record')('keeps composer resizing on the correct scroll owner across reopened Sessions', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-restore-composer',
seeds: [
@@ -780,7 +780,6 @@ describe('web e2e: long Chat scroll contract', () => {
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await nextPaint(world.page)
await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE)
const narrowSessionAnchor = await visibleFlowAnchor(world.page)
await openSeed(
world.page,
@@ -791,9 +790,9 @@ describe('web e2e: long Chat scroll contract', () => {
world.page,
RESTORE_FIXTURE_A,
)
await expectSameFlowTop(world.page, narrowSessionAnchor)
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
await backToBottom.waitFor({ timeout: 15_000 })
await backToBottom.evaluate((button) => {
if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
button.click()
+14 -1
View File
@@ -2,7 +2,8 @@
// in one settled keyless turn — the regression anchor for unifying link
// styles. One fixture turn produces:
// - prose: Markdown link, reference-style link, mailto link, inline-code URL,
// produced-file mention, plus inert contrasts (ambiguous basename, unwritten
// produced-file mention, a known-site link carrying that site's own leading
// mark, plus inert contrasts (ambiguous basename, unwritten
// file, command code, URL-with-flags code, javascript: destination,
// footnote superscript, remote image, fenced code block with its copy chrome)
// - artifacts: seven produced files (chips overflow into the "+N" remainder
@@ -55,6 +56,9 @@ const MAILTO_URL = 'mailto:[email protected]'
const SOURCE_URL = 'https://docs.example.test/links'
const INERT_SOURCE_URL = 'ftp://mirror.example.test/spec'
const FETCH_URL = 'https://docs.example.test/tokens'
// A mapped host, so the prose pins that the leading glyph is the site's mark
// rather than the globe the unmapped docs host keeps.
const REPO_URL = 'https://github.com/example/link-gallery'
/** One-part text content for a built message. */
function text(value: string): { type: 'text'; text: string }[] {
@@ -256,6 +260,8 @@ function galleryFixture(imageUrl: string): string {
`Docs: [style guide](${GUIDE_URL}) and \`${API_URL}\`; see [the release notes][rel], `
+ `contact [the maintainer](${MAILTO_URL}), and check the fine print[^1].`,
'',
`Upstream: [the repository](${REPO_URL}).`,
'',
`Inert contrasts: \`curl ${API_URL}\`, \`javascript:alert(1)\`, and \`pnpm run build\`.`,
'',
'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
@@ -418,6 +424,13 @@ describe('web e2e: clickable links gallery', () => {
expect.soft(await styleOf(link, 'text-decoration-line'), `${name} at rest`).toBe('none')
expect.soft(await link.locator('svg').count(), `${name} glyph`).toBe(1)
}
// A mapped host leads with its own mark; the unmapped docs host keeps the
// globe in the same seat.
const repoLink = markdown.locator(`a[href="${REPO_URL}"]`)
expect(await repoLink.count()).toBe(1)
const repoMark = await repoLink.locator('svg path').getAttribute('d')
const globeMark = await guideLink.locator('svg path').getAttribute('d')
expect(repoMark).not.toBe(globeMark)
await guideLink.hover()
expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline')
expect(await styleOf(guideLink, 'text-decoration-style')).toBe('dotted')
+1 -2
View File
@@ -15,8 +15,7 @@ async function openInventory(page: Page, url: string) {
await page.goto(url, { waitUntil: 'load' })
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '插件', exact: true }).click()
await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
await dialog.getByRole('button', { name: '内置插件', exact: true }).click()
await dialog.getByRole('searchbox', { name: '搜索插件' }).waitFor()
return dialog
}
@@ -379,9 +379,9 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
expect(await paneSnapshot(page)).toEqual(retainedB)
await open()
await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true')
await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('false')
expect(await paneSnapshot(page)).toEqual(retainedB)
await checkpoint('B restored: normal mode and Files directory state')
await checkpoint('B restored: normal mode and collapsed Files directory')
await close()
await select(original, 'LIGHTHOUSE')
await expect.poll(() => columns(page)).toEqual(normalColumns)
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -142,6 +142,11 @@
- text: ", and check the fine print"
- superscript: "1"
- text: .
- paragraph:
- text: "Upstream:"
- link "the repository":
- /url: https://github.com/example/link-gallery
- text: .
- paragraph:
- text: "Inert contrasts:"
- code: curl https://docs.example.test/api
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -0,0 +1,35 @@
- heading "插件" [level=1]
- paragraph: 添加和管理插件
- button "刷新"
- button "添加插件":
- img
- text: 添加插件
- heading "官方" [level=3]
- text: "6"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
- text: Beta 启用智能体团队协作与团队工具。
- switch "启用 智能体团队"
- listitem:
- button "查看 智能体团队 Web 界面": 智能体团队 Web 界面
- text: Beta 在浏览器中查看团队成员、任务看板和成员会话。
- switch "启用 智能体团队 Web 界面"
- listitem:
- button "查看 终端": 终端
- text: 限制 agent 运行的每一条命令。
- listitem:
- button "查看 Agent 循环": Agent 循环
- text: Agent 如何派发工具调用。
- listitem:
- button "查看 Subagent": Subagent
- text: 控制 Agent 为 Subagent 选择模型的权限。
- listitem:
- button "查看 网页搜索": 网页搜索
- text: DeepSeek 搜索提供方。
- heading "已安装" [level=3]
- text: "1"
- list:
- listitem:
- button "查看 live-client": live-client
- switch "启用 live-client"
@@ -0,0 +1,11 @@
- button "返回 live-client":
- img
- text: live-client
- heading "fixture-live-client" [level=3]
- paragraph:
- code: "@fixture/live-client"
- paragraph: 示例配置项
- form "动态插件配置":
- text: 问候语
- textbox "问候语": 你好
- button "保存"
@@ -1,45 +0,0 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "插件":
- img
- text: 插件
- button "Agent 预设":
- img
- text: Agent 预设
- button "已归档会话":
- img
- text: 已归档会话
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "插件" [level=2]
- paragraph: 配置和查看本部署已安装的插件。
- tablist "插件视图":
- tab "插件配置" [selected]
- tab "插件列表"
- tabpanel "插件配置":
- list:
- listitem:
- 'button "展开设置: 终端"':
- text: 终端 限制 agent 运行的每一条命令。
- img
- listitem:
- 'button "展开设置: Agent 循环"':
- text: Agent 循环 Agent 如何派发工具调用。
- img
- listitem:
- 'button "展开设置: Subagent"':
- text: Subagent 控制 Agent 为 Subagent 选择模型的权限。
- img
- listitem:
- 'button "展开设置: 网页搜索"':
- text: 网页搜索 DeepSeek 搜索提供方。
- img
@@ -5,7 +5,7 @@
- paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。
- text: 包名或地址
- textbox "包名或地址":
- /placeholder: 例如 dsh-better-sidebar
- /placeholder: 例如 @deepseek-ai/dsh-experimental-auto-review
- text: slow-package
- button "不知道该填什么?":
- img
@@ -4,21 +4,29 @@
- button "添加插件":
- img
- text: 添加插件
- heading "内置" [level=3]
- text: "3"
- heading "官方" [level=3]
- text: "6"
- list:
- listitem:
- button "查看 智能体团队(实验性)": 智能体团队(实验性)
- text: 官方 启用智能体团队协作与团队工具。
- switch "启用 智能体团队(实验性)"
- button "查看 智能体团队": 智能体团队
- text: Beta 启用智能体团队协作与团队工具。
- switch "启用 智能体团队"
- listitem:
- button "查看 智能体团队 Web 界面(实验性)": 智能体团队 Web 界面(实验性)
- text: 官方 在浏览器中查看团队成员、任务看板和成员会话。
- switch "启用 智能体团队 Web 界面(实验性)"
- button "查看 智能体团队 Web 界面": 智能体团队 Web 界面
- text: Beta 在浏览器中查看团队成员、任务看板和成员会话。
- switch "启用 智能体团队 Web 界面"
- listitem:
- button "查看 自动授权审查(实验性)": 自动授权审查(实验性)
- text: 官方 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- switch "启用 自动授权审查(实验性)"
- button "查看 终端": 终端
- text: 限制 agent 运行的每一条命令。
- listitem:
- button "查看 Agent 循环": Agent 循环
- text: Agent 如何派发工具调用。
- listitem:
- button "查看 Subagent": Subagent
- text: 控制 Agent 为 Subagent 选择模型的权限。
- listitem:
- button "查看 网页搜索": 网页搜索
- text: DeepSeek 搜索提供方。
- heading "已安装" [level=3]
- text: "1"
- list:
@@ -4,21 +4,29 @@
- button "添加插件":
- img
- text: 添加插件
- heading "内置" [level=3]
- text: "3"
- heading "官方" [level=3]
- text: "6"
- list:
- listitem:
- button "查看 智能体团队(实验性)": 智能体团队(实验性)
- text: 官方 启用智能体团队协作与团队工具。
- switch "启用 智能体团队(实验性)"
- button "查看 智能体团队": 智能体团队
- text: Beta 启用智能体团队协作与团队工具。
- switch "启用 智能体团队"
- listitem:
- button "查看 智能体团队 Web 界面(实验性)": 智能体团队 Web 界面(实验性)
- text: 官方 在浏览器中查看团队成员、任务看板和成员会话。
- switch "启用 智能体团队 Web 界面(实验性)"
- button "查看 智能体团队 Web 界面": 智能体团队 Web 界面
- text: Beta 在浏览器中查看团队成员、任务看板和成员会话。
- switch "启用 智能体团队 Web 界面"
- listitem:
- button "查看 自动授权审查(实验性)": 自动授权审查(实验性)
- text: 官方 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- switch "启用 自动授权审查(实验性)"
- button "查看 终端": 终端
- text: 限制 agent 运行的每一条命令。
- listitem:
- button "查看 Agent 循环": Agent 循环
- text: Agent 如何派发工具调用。
- listitem:
- button "查看 Subagent": Subagent
- text: 控制 Agent 为 Subagent 选择模型的权限。
- listitem:
- button "查看 网页搜索": 网页搜索
- text: DeepSeek 搜索提供方。
- heading "已安装" [level=3]
- text: "1"
- list:
@@ -7,9 +7,9 @@
- button "Models":
- img
- text: Models
- button "Plugins":
- button "Built-in plugins":
- img
- text: Plugins
- text: Built-in plugins
- button "Agent presets":
- img
- text: Agent presets
@@ -7,9 +7,9 @@
- button "模型":
- img
- text: 模型
- button "插件":
- button "内置插件":
- img
- text: 插件
- text: 内置插件
- button "Agent 预设":
- img
- text: Agent 预设
@@ -12,10 +12,28 @@ window.__ModuleLoader__.load({
apply(ctx) {
const counters = document.documentElement.dataset
counters.liveMounts = String(Number(counters.liveMounts ?? 0) + 1)
ctx.effect(() => ctx.locale.register('fixtureLive', { zh: { active: '动态插件已启用' }, en: { active: 'Live plugin enabled' } }))
ctx.effect(() => ctx.locale.register('fixtureLive', {
zh: { active: '动态插件已启用', configSummary: '示例配置项', configForm: '动态插件配置', configField: '问候语', configSave: '保存' },
en: { active: 'Live plugin enabled', configSummary: 'An example setting', configForm: 'Live plugin configuration', configField: 'Greeting', configSave: 'Save' },
}))
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay', id: 'fixture-live-client', locale: 'fixtureLive',
}, ({ t }) => React.createElement('div', { 'data-live-client': '' }, t('active'))))
// The configuration of this bundle's one row, as the Plugins page renders it on the row's own page.
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
name: 'plugins.row.config', key: '@fixture/live-client#fixture-live-client', locale: 'fixtureLive',
}, ({ t, view }) => view === 'summary'
? t('configSummary')
: React.createElement('form', {
'data-live-config': '',
'aria-label': t('configForm'),
onSubmit: (event) => {
event.preventDefault()
counters.liveSaves = String(Number(counters.liveSaves ?? 0) + 1)
},
},
React.createElement('label', null, t('configField'), React.createElement('input', { name: 'greeting', defaultValue: 'hello' })),
React.createElement('button', { type: 'submit' }, t('configSave')))))
ctx.effect(() => {
const ping = () => { counters.liveHits = String(Number(counters.liveHits ?? 0) + 1) }
window.addEventListener('dsh-fixture-ping', ping)
+123 -86
View File
@@ -1,12 +1,13 @@
// Web e2e scenario: the configurable tab in Plugins settings — the cards a
// deployment's exposed host-plane namespaces produce, one field edited through the real
// wire down to `$DSH_HOME/settings.yaml`, and the override badge and reset
// that layering produces. Zero model calls: everything is client state plus
// the settings document on a blank frame, so there is no fixture and a stray
// stream would fail loud on the open llm seam.
// Web e2e scenario: the configuration pages on the Plugins page — the official
// pages a deployment's exposed host-plane namespaces produce, one field edited
// through the real wire down to `$DSH_HOME/settings.yaml`, the override badge
// and reset that layering produces, and a community bundle's row configuration
// registered by its own browser half. Zero model calls: everything is client
// state plus the settings document and the profile on a blank frame, so there
// is no fixture and a stray stream would fail loud on the open llm seam.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { join } from 'node:path'
@@ -17,20 +18,26 @@ import {
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-config', import.meta.url))
const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
const OFFICIAL_EXPECTED = join(SNAPSHOT_DIR, 'official.expected.md')
const ROW_EXPECTED = join(SNAPSHOT_DIR, 'row.expected.md')
const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
const MODE = webSnapshotMode()
describe('web e2e: plugin configuration section', () => {
describe('web e2e: plugin configuration pages', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The live-client fixture is a bundle with a browser half; switched on
// below, that half registers its row's configuration into the page.
scaffold = await launchWebScaffold({
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] },
})
browser = await chromium.launch()
// Chinese browser: the section asserts the localized copy the client
// derives from it, as the rest of the settings surface does.
// Chinese browser: the pages assert the localized copy the client derives
// from it, as the rest of the settings surface does.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
@@ -43,24 +50,29 @@ describe('web e2e: plugin configuration section', () => {
})
/**
* Open the settings dialog on the Plugins section. The scenarios share one
* page so the settings document accumulates across them, so this leaves any
* dialog a previous scenario opened closed first — its mask would otherwise
* swallow the trigger click.
* Show the Plugins page's cards. The scenarios share one page so the
* settings document accumulates across them, so this closes any settings
* dialog a previous scenario left open and leaves whatever page it opened.
*/
async function openPlugins() {
async function openPlugins(): Promise<Locator> {
if (await page.getByRole('dialog', { name: '设置' }).count() > 0) {
await page.keyboard.press('Escape')
await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
}
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '插件', exact: true }).click()
await expect
.poll(() => dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current'), { timeout: 5_000 })
.toBe('true')
return dialog
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
const panel = page.locator('[data-plugin-panel]')
await panel.waitFor({ timeout: 10_000 })
while (await panel.getByRole('button', { name: /^返回/ }).count() > 0) {
await panel.getByRole('button', { name: /^返回/ }).first().click()
}
await panel.getByRole('heading', { name: '官方', exact: true }).waitFor({ timeout: 20_000 })
return panel
}
/** Open one official plugin's page from its card and wait for its form. */
async function openPage(panel: Locator, title: string): Promise<void> {
await panel.getByRole('button', { name: `查看 ${title}`, exact: true }).click()
await panel.locator('[data-plugin-config]').waitFor({ timeout: 10_000 })
}
/** The settings document as the Host has written it so far. */
@@ -68,70 +80,67 @@ describe('web e2e: plugin configuration section', () => {
return readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8').catch(() => '')
}
it('shows one card per exposed host-plane namespace', async () => {
it('lists one official page per exposed host-plane namespace after the official bundles', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards'))
const dialog = await openPlugins()
const panel = await openPlugins()
// Every card the shipped web composition exposes: the shell executor, the
// agent loop, subagent selection, and the DeepSeek search provider.
await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 })
expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1)
await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 })
expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1)
expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1)
// Collapsed: a card's fields appear only once it is expanded.
expect(await dialog.getByLabel('命令超时(毫秒)').count()).toBe(0)
// Every page the shipped web composition exposes: the shell executor, the
// agent loop, subagent selection, and the DeepSeek search provider, after
// the two official bundles the installation ships switched off.
await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 })
const official = panel.locator('[data-plugin-group="official"]')
expect(await official.locator('[data-plugin-package]').count()).toBe(2)
expect(await official.locator('[data-plugin-item]').count()).toBe(4)
for (const title of ['终端', 'Agent 循环', 'Subagent', '网页搜索']) {
expect(await official.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1)
}
// A card carries the one-liner; the fields wait for the page.
expect(await official.getByText('限制 agent 运行的每一条命令。', { exact: true }).count()).toBe(1)
expect(await panel.getByLabel('命令超时(毫秒)').count()).toBe(0)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(OFFICIAL_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('persists selected adapter routes as the subagent model allowlist', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection'))
const dialog = await openPlugins()
await dialog.getByText('Subagent', { exact: true }).click()
const toggle = dialog.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' })
const panel = await openPlugins()
await openPage(panel, 'Subagent')
const toggle = panel.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' })
await toggle.click()
const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' })
const models = panel.getByRole('group', { name: 'Agent 可选择的模型' })
await models.waitFor({ timeout: 10_000 })
const firstModel = models.getByRole('checkbox').first()
await firstModel.check()
await dialog.getByRole('button', { name: '保存', exact: true }).click()
const save = panel.getByRole('button', { name: '保存', exact: true })
await save.click()
const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent' })
await expandSubagent.waitFor({ timeout: 5_000 })
await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 })
.toBe(true)
expect(await settingsDocument()).toContain('enabled: true')
expect(await settingsDocument()).toContain('allowedModels:')
expect(await settingsDocument()).toContain('provider:')
expect(await settingsDocument()).toContain('model:')
await expandSubagent.click()
// The page stays open once the save landed; a settled form offers no save to repeat.
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true')
await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true)
expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0)
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
await toggle.click()
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await expandSubagent.waitFor({ timeout: 5_000 })
await save.click()
await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 })
.toBe(true)
expect(await settingsDocument()).toContain('allowedModels:')
expect(await settingsDocument()).toContain('provider:')
expect(await settingsDocument()).toContain('model:')
await expandSubagent.click()
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('stages an edit and writes it only when saved', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
const panel = await openPlugins()
await openPage(panel, '终端')
const timeout = dialog.getByLabel('命令超时(毫秒)')
const timeout = panel.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
// The composed default this deployment ships, before any user layer.
expect(await timeout.inputValue()).toBe('60000')
@@ -141,83 +150,111 @@ describe('web e2e: plugin configuration section', () => {
// Nothing crosses the wire until the user saves: leaving the control is
// not a decision to store the value.
expect(await settingsDocument()).not.toContain('timeoutMs')
const save = dialog.getByRole('button', { name: '保存', exact: true })
const save = panel.getByRole('button', { name: '保存', exact: true })
await expect.poll(() => save.isEnabled(), { timeout: 5_000 }).toBe(true)
await save.click()
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 })
.toBe(true)
const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' })
await expandTerminal.waitFor({ timeout: 5_000 })
await expandTerminal.click()
// Presence in the user layer is what the badge reports, and the reset is
// offered only for a field that has one.
await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1)
expect(await dialog.getByRole('button', { name: '恢复默认' }).count()).toBe(1)
await expect.poll(() => panel.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1)
expect(await panel.getByRole('button', { name: '恢复默认' }).count()).toBe(1)
// A settled form offers no save to repeat.
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('drops a staged edit on discard without touching the document', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-discard'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
const timeout = dialog.getByLabel('命令超时(毫秒)')
it('drops a staged edit when the page is left, without touching the document', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-leave'))
const panel = await openPlugins()
await openPage(panel, '终端')
const timeout = panel.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
await timeout.fill('7000')
await dialog.getByRole('button', { name: '放弃修改' }).click()
await panel.getByRole('button', { name: '返回插件列表' }).click()
await panel.getByRole('heading', { name: '官方', exact: true }).waitFor({ timeout: 10_000 })
await openPage(panel, '终端')
await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('12000')
await expect.poll(() => panel.getByLabel('命令超时(毫秒)').inputValue(), { timeout: 5_000 }).toBe('12000')
expect(await settingsDocument()).toContain('timeoutMs: 12000')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('refuses to save a draft that is not a number', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-invalid'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
const timeout = dialog.getByLabel('命令超时(毫秒)')
const panel = await openPlugins()
await openPage(panel, '终端')
const timeout = panel.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
await timeout.fill('soon')
const save = dialog.getByRole('button', { name: '保存', exact: true })
const save = panel.getByRole('button', { name: '保存', exact: true })
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
expect(await dialog.getByText('请填数字;留空表示使用默认值。').count()).toBe(1)
await dialog.getByRole('button', { name: '放弃修改' }).click()
expect(await panel.getByText('请填数字;留空表示使用默认值。').count()).toBe(1)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('clears the field back to the composed default on reset', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-reset'))
const dialog = await openPlugins()
await dialog.getByText('终端', { exact: true }).click()
const timeout = dialog.getByLabel('命令超时(毫秒)')
const panel = await openPlugins()
await openPage(panel, '终端')
const timeout = panel.getByLabel('命令超时(毫秒)')
await timeout.waitFor({ timeout: 10_000 })
expect(await timeout.inputValue()).toBe('12000')
// The reset stages the composed default; the document still carries the
// override until the save lands.
await dialog.getByRole('button', { name: '恢复默认' }).click()
await panel.getByRole('button', { name: '恢复默认' }).click()
await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('60000')
expect(await settingsDocument()).toContain('timeoutMs: 12000')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await panel.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 })
.toBe(false)
const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' })
await expandTerminal.waitFor({ timeout: 5_000 })
await expandTerminal.click()
expect(await timeout.inputValue()).toBe('60000')
expect(await dialog.getByText('已覆盖').count()).toBe(0)
expect(await panel.getByText('已覆盖').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('renders a community bundle\'s row configuration, registered by its browser half, on the row\'s page', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-row'))
const panel = await openPlugins()
// Off, the bundle's browser half is not loaded and the row has no configuration to open.
await panel.getByRole('button', { name: '查看 live-client', exact: true }).click()
const row = panel.locator('[data-plugin-row]', { hasText: 'fixture-live-client' })
await row.waitFor({ timeout: 10_000 })
expect(await panel.getByRole('button', { name: '配置 fixture-live-client' }).count()).toBe(0)
// Switched on, the Host recomposes and the browser half mounts without a
// reload; its registration puts the configure control on the row.
await panel.getByRole('switch', { name: '启用 live-client' }).click()
const configure = panel.getByRole('button', { name: '配置 fixture-live-client' })
await configure.waitFor({ timeout: 30_000 })
await configure.click()
const rowPage = panel.locator('[data-plugin-row-detail="@fixture/live-client#fixture-live-client"]')
await rowPage.waitFor({ timeout: 10_000 })
expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('fixture-live-client')
expect(await rowPage.getByText('示例配置项', { exact: true }).count()).toBe(1)
const form = rowPage.getByRole('form', { name: '动态插件配置' })
await form.getByLabel('问候语').fill('你好')
await form.getByRole('button', { name: '保存' }).click()
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.liveSaves), { timeout: 5_000 }).toBe('1')
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(ROW_EXPECTED, snapshot, MODE)
await rowPage.getByRole('button', { name: '返回 live-client' }).click()
await panel.locator('[data-plugin-detail="@fixture/live-client"]').waitFor({ timeout: 10_000 })
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['section.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['official.expected.md', 'row.expected.md'])
})
})
+14 -8
View File
@@ -71,10 +71,13 @@ describe('web e2e: plugin manager', () => {
await panel.getByText('bundle', { exact: true }).waitFor({ timeout: 20_000 })
const toggle = panel.getByRole('switch', { name: '启用 bundle' })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
// The profile's own group holds its one bundle; the installation's optional bundles form the built-in
// group, and its other bundles stay off the page.
// The profile's own group holds its one bundle; the installation's optional bundles open the Official
// group, followed by the official plugins that registered their configuration, and its other bundles
// stay off the page.
expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(1)
expect(await panel.locator('[data-plugin-group="builtin"] [data-plugin-package]').count()).toBe(3)
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(2)
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4)
expect(await panel.getByText('Beta', { exact: true }).count()).toBe(2)
// A bundle that is off still shows the rows its patch declares, without switches.
await panel.getByRole('button', { name: '查看 bundle' }).click()
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
@@ -91,7 +94,7 @@ describe('web e2e: plugin manager', () => {
it('updates built-in names and descriptions when the UI language changes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale'))
const panel = await openPluginsPanel()
await panel.getByRole('button', { name: '查看 智能体团队(实验性)' }).click()
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click()
const packageName = panel.locator('[data-plugin-name]')
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
expect(await panel.getByText('启用智能体团队协作与团队工具。').count()).toBe(1)
@@ -101,16 +104,19 @@ describe('web e2e: plugin manager', () => {
await page.getByRole('menuitem', { name: 'English' }).click()
await page.getByRole('dialog', { name: 'Settings' }).waitFor()
await page.keyboard.press('Escape')
await panel.getByRole('heading', { name: 'Experimental Agent Teams', exact: true }).waitFor()
await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor()
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
expect(await panel.getByText('Enable agent team collaboration and team tools.').count()).toBe(1)
await panel.getByRole('button', { name: 'Back to plugins' }).click()
for (const title of ['Experimental Agent Teams', 'Experimental Agent Teams Web UI', 'Experimental Auto Authorization Review']) {
for (const title of ['Agent Teams', 'Agent Teams Web UI']) {
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: `Enable ${title}`, exact: true }).count()).toBe(1)
}
expect(await panel.getByText('View team members, the task board, and teammate sessions in the browser.').count()).toBe(1)
expect(await panel.getByText('Add an Auto review permission mode that uses the model to assess authorization before each tool call.').count()).toBe(1)
// The official configuration pages follow the language too, from their own dictionary.
for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) {
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
}
} finally {
if (await page.locator('html').getAttribute('lang') === 'en') {
if (await page.getByRole('dialog', { name: 'Settings' }).count() === 0) {
@@ -122,7 +128,7 @@ describe('web e2e: plugin manager', () => {
}
await closeSettings()
}
await panel.getByRole('button', { name: '查看 智能体团队(实验性)', exact: true }).waitFor()
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).waitFor()
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+25 -1
View File
@@ -27,6 +27,7 @@ const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
const PRESERVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'preserved-expanded.expected.md')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const SENDING_EXPECTED = join(SNAPSHOT_DIR, 'sending.expected.md')
const WRITER_HELD_EXPECTED = join(SNAPSHOT_DIR, 'writer-held.expected.md')
const FAILED_EXPECTED = join(SNAPSHOT_DIR, 'failed.expected.md')
const MODE = webSnapshotMode()
@@ -232,6 +233,29 @@ describe('web e2e: queue row actions', () => {
].join('\n')
await compareOrRefreshGolden(FAILED_EXPECTED, failed, MODE)
await page.route('**/api/session/prompt', async (route) => {
const envelope = route.request().postDataJSON() as { rpcId: string }
await route.fulfill({
status: 200,
contentType: 'application/json',
json: {
type: 'server-response', rpcId: envelope.rpcId,
result: {
ok: false,
error: { code: 'session/writer-held', message: 'writer held', details: { sessionId: 'held-session' } },
},
},
})
}, { times: 1 })
await input.press('Enter')
const writerHeld = page.getByRole('alert').filter({ hasText: 'This session is already in use' })
await writerHeld.waitFor()
await expect.poll(() => input.textContent()).toBe(FAILED)
await compareOrRefreshGolden(WRITER_HELD_EXPECTED, [
await writerHeld.ariaSnapshot(),
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd),
].join('\n'), MODE)
await input.fill(TAIL)
await input.press('Enter')
await expect.poll(
@@ -373,7 +397,7 @@ describe('web e2e: queue row actions', () => {
[
'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md',
'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md',
'sending.expected.md', 'failed.expected.md',
'sending.expected.md', 'failed.expected.md', 'writer-held.expected.md',
],
)
})
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff. Show more