Merge remote-tracking branch 'origin/master' into fix/subagent-empty-terminal-message-output

# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
This commit is contained in:
Hypatia May
2026-08-10 15:43:16 +08:00
570 changed files with 25214 additions and 1167 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md
2026-07-20-error-cause-chain-diagnostics.md: 32716b5a68b3b73bded47633eca95995cdbfc586
2026-07-20-error-cause-chain-diagnostics.zh.md: 9911c32b6d68c1f5569a1fceadb65e23c6586594
2026-07-20-error-cause-chain-diagnostics.md: b80dd08d79a57738a6eef2f8638b0336ca80d4bf
2026-07-20-error-cause-chain-diagnostics.zh.md: 914e983d7ea81eef8bca8fd5aa3791f2f44d4080

View File

@@ -9,7 +9,7 @@ English | [中文](2026-07-20-error-cause-chain-diagnostics.zh.md)
A TUI run against an unreachable DeepSeek endpoint failed with the single notice `fetch failed` and no further detail. Two independent gaps produced that dead end:
1. undici's `fetch` wraps every transport failure (DNS, refused connection, TLS, proxy) in a bare `TypeError: fetch failed` whose actionable detail — `ECONNREFUSED`, `bad port`, the Happy Eyeballs AggregateError — lives on `error.cause`. Every diagnostic boundary in the harness rendered only `error.message` (or `String(error)`, which is equivalent for Errors), so the wrapper masked the diagnosis in the TUI notice, the durable `turn/end` reason, and every logger line.
2. The readline front door (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence.
2. The readline entry point (`dsh-stdio`) rendered no failure reason at all: a `turn/end` with `reason.kind === 'error'` printed nothing but the next `> ` prompt, so the same failure in `demo:repl` was pure silence.
## Decision

View File

@@ -9,7 +9,7 @@ Status: implemented
TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed` 通知,没有任何进一步细节。两个独立缺口共同造成了这个死胡同:
1. undici 的 `fetch` 把所有传输层失败(DNS、连接被拒、TLS、代理)包装成裸的 `TypeError: fetch failed`,可操作的细节——`ECONNREFUSED`、`bad port`、Happy Eyeballs 的 AggregateError——都在 `error.cause` 上。harness 里的每个诊断边界都只渲染 `error.message`(或对 Error 等价的 `String(error)`),于是包装层在 TUI 通知、持久化的 `turn/end` reason 和所有日志行里都掩盖了诊断信息。
2. readline 前门(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。
2. readline 入口(`dsh-stdio`)完全不渲染失败原因:`reason.kind === 'error'` 的 `turn/end` 只打印下一个 `> ` 提示符,同样的失败在 `demo:repl` 里就是纯粹的沉默。
## 决策

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md
2026-08-09-broken-preset-roster-rows.md: fef6a183b10f98b8ae9d2b42701380c69bc83462
2026-08-09-broken-preset-roster-rows.zh.md: 196bcf4ef16325a1d7692d2ea13d9fa683d500f4

View File

@@ -0,0 +1,33 @@
# Agent Note: Broken presets are roster rows, not gaps
Status: implemented
English | [中文](2026-08-09-broken-preset-roster-rows.zh.md)
## Problem
With files as the only composition editor, hand-edit damage had two failure shapes and both were silent until the worst moment. A preset whose `agent.cordis.yml` no longer parsed listed as a perfectly ordinary row — selectable, copyable, settable as the default — and failed only when the next session tried to mount it; set as default, every new session failed to start. A directory whose composition file was deleted outright vanished from the roster while still occupying its id on disk: `copy` refused the name with "delete the existing preset first" and `remove` answered "not found" — two contradictory errors with no way out short of hand-deleting the directory.
## Decision
Discovery owns health, and a damaged directory is a **roster row carrying a `broken` reason**, never a gap. `scanRoot` treats every directory whose name is a usable preset id as a preset slot: composition missing → broken ("still occupies the id; delete it or restore the file"), composition unreadable/unparsable/not-a-list-of-named-rows → broken with the parser's first line. The shape check parses with the loader's own `entryListSchema` (the `!!js` dialect), so health can never call broken what the loader would accept; directories whose names fail `PRESET_ID` are skipped outright, because no copy could ever collide with them. `broken` rides `AgentPreset`, the `agentPreset.list` wire entry, and the UI row. Mounting paths (`mount`/`recompose`/`standingKeyFor`) refuse a broken preset up front via `resolveMountable` with the discovery-reported reason; `resolve` still answers (delete/read/report need the row), and `copy`'s roster check now sees ghosts, which turns the "already exists" refusal actionable — the broken card to delete is on the same page.
Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure.
## Consequences
- The ghost dead end is gone end to end: the directory lists broken, its delete clears it, and the freed id is immediately claimable (covered by unit, component, and e2e tests).
- A default that later breaks still fails the session start loudly — the pickers hide broken rows, but nothing rewrites a stored default; `resolveMountable`'s early refusal is the same message every unloadable shape gets, instead of loader-dependent errors.
- Health runs on every `list()`: one read+parse per preset per roster read, accepted for the same reason unmemoized discovery was — rosters are small and freshness is the contract.
- Copying broken is refused in the UI only (disabled with reason); the host keeps `copy` shape-agnostic. A broken source yields an equally broken, equally visible copy — no capability is gained, and the host-side refusal would have needed its own error vocabulary for no journey that survives the disabled button.
## Load-bearing details
- **`PRESET_ID` moved to `types.ts`** so discovery and authoring share one containment vocabulary; authoring re-exports it unchanged.
- **The reason is one line.** js-yaml appends a multi-line code-frame snippet; the roster card is not a terminal, so `compositionProblem` keeps the first line.
- **Two mount.spec races were left untouched deliberately**: `ensureStanding` is still reachable with a preset resolved just before deletion (the private-path tests), and its stamp/unstampable semantics are unchanged — the health check happens before, in the public route.
- **Creator-mode guidance rides the same PR**: the `cordis` preset's persona now forbids editing the shipped install (corrupting `cordis` would disable the mode itself) and points authoring at `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`; its skill teaches `preset.yml` metadata, the copy-first workflow, the one-escalation sandbox reality (the preset root lies outside the session workspace), and honest verification (the agent cannot start sessions; the settings page's red marking is the user's check). Verified live: asked to edit the shipped `cordis` composition directly, the composed agent refuses citing both rules and offers the copy path; asked for a real preset, it lands it under `$DSH_HOME`, batches writes into one escalation, self-checks with the loader dialect, and hands verification to the user.
## Alternatives considered
Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment.

View File

@@ -0,0 +1,33 @@
# Agent Note:损坏的 preset 是名单行,不是空缺
Status: implemented
[English](2026-08-09-broken-preset-roster-rows.md) | 中文
## 问题
文件成为唯一的组装编辑器之后,手动编辑造成的损坏有两种形态,且都要拖到最糟的时刻才暴露。`agent.cordis.yml` 解析不了的 preset 在名单上是一张完全正常的行——可选择、可复制、可设为默认——直到下一个会话尝试挂载才失败;一旦被设为默认,所有新会话都无法启动。组装文件被整个删掉的目录则从名单上消失,却仍在磁盘上占着它的 id:`copy` 以「先删除既有 preset」拒绝这个名字,`remove` 却回答「找不到」——两条互相矛盾的错误,除了手动删目录别无出路。
## 决定
发现过程负责健康,受损目录是**携带 `broken` 原因的名单行**,绝不是空缺。`scanRoot` 把名字是可用 preset id 的每个目录都当作一个 preset 槽位:组装缺失 → broken(「仍占着该 id;删除目录或恢复文件」),组装不可读/解析失败/不是具名行列表 → broken 并携带解析器的首行。形状检查用加载器自己的 `entryListSchema`(含 `!!js` 的方言)解析,因此健康检查绝不会把加载器接受的组装叫作损坏;名字不符合 `PRESET_ID` 的目录直接跳过,因为复制永远不可能与之相撞。`broken` 依次落在 `AgentPreset`、`agentPreset.list` 的线上条目和 UI 行上。挂载路径(`mount`/`recompose`/`standingKeyFor`)经 `resolveMountable` 用发现时记下的原因在前置拒绝;`resolve` 照样应答(删除/读取/上报都需要这一行),而 `copy` 的名单检查现在看得见幽灵,让「已存在」的拒绝变得可操作——要删的损坏卡片就在同一页上。
界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。
## 后果
- 幽灵死路端到端消除:目录以损坏行列出,删除即清掉,释放的 id 立刻可用(单测、组件测试与 e2e 各自覆盖)。
- 事后才损坏的默认值仍会在会话启动处大声失败——选择器隐藏损坏行,但没有任何东西改写已存的默认;`resolveMountable` 的前置拒绝让每种不可加载形态得到同一条消息,而不是依赖加载器内部的报错。
- 健康检查随每次 `list()` 运行:每次读名单对每个 preset 一次读取加解析,接受的理由与不做缓存的发现相同——名单很小,新鲜是契约。
- 复制损坏 preset 只在 UI 层拒绝(按钮禁用并给出原因);宿主的 `copy` 保持形状无关。损坏来源产出同样损坏、同样可见的副本——没有能力增益,而宿主侧拒绝需要为一条被禁用按钮挡住的路径专门发明错误词汇。
## 关键细节
- **`PRESET_ID` 移到 `types.ts`**,让发现与创作共享同一份包含边界词汇;authoring 原样转发导出。
- **原因只留一行。** js-yaml 会附上多行代码框摘录;名单卡片不是终端,`compositionProblem` 只保留首行。
- **mount.spec 的两个竞态用例特意不动**:`ensureStanding` 仍可能拿到删除前一刻解析出的 preset(私有路径测试),其 stamp/unstampable 语义不变——健康检查发生在此之前的公开路径上。
- **创造模式的引导随同一 PR 落地**:`cordis` preset 的 persona 现在禁止编辑随附安装(损坏 `cordis` 会禁用这一模式本身),并把创作指向 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/<id>/`;其技能新教了 `preset.yml` 元信息、先复制再改的流程、一次升级的沙箱现实(preset 根目录在会话工作区之外)与诚实的验证方式(agent 无法自己启动会话;设置页的红色标记是用户的检查项)。已实测:被要求直接改随附 `cordis` 组装时,组装出的 agent 援引两条规则拒绝并给出复制路径;被要求真正创建 preset 时,它落在 `$DSH_HOME` 下、把写入合并为一次升级、用加载器方言自查、并把验证交还用户。
## 曾考虑的替代方案
隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md
2026-08-10-session-row-identity-covers-the-preset.md: 7a89dcb4e4ae292a06a1743842d2e9cf6bd96282
2026-08-10-session-row-identity-covers-the-preset.zh.md: 7ffa3423818bcc867c942651540db1975737e073

View File

@@ -0,0 +1,37 @@
# Agent Note: The session-row identity guard covers the preset
Status: implemented
English | [中文](2026-08-10-session-row-identity-covers-the-preset.zh.md)
## Problem
`SessionManager.buildListSnapshot` memoizes list rows by value: a wire refresh mints all-new summary objects, so an entry equal to the cached one is replaced by the cached instance, and every `SessionListItem` memo downstream keeps hitting. The stated contract is "reuse the cached object when every field matches"; the comparison enumerated the fields by hand and did not enumerate `agentPreset`.
A confirmed preset switch moves exactly that one field. `noteAgentPreset` upserts it and `applyMutation` merges it in — the merge deliberately does not take the mutation's `updatedAt`, so a switched row differs from its cached twin in the preset and in nothing else. The guard therefore judged the row unchanged and served the stale instance, permanently: the manager's own summaries said `minimal` while every reader of the projected snapshot went on reading `standard`.
The hero chip is one of those readers, and it compares the pick against that row before sending anything. Switching back to the preset the session was created under looked to it like "already on that preset", so it dropped the stage and sent no RPC at all — the chip label moved while the composition did not. A session could be switched away from its creation-time preset once and never back.
## Decision
The identity guard compares `agentPreset` alongside the other summary fields, which is what "every field matches" already claimed. Nothing else changes: the memoization, the merge, and the chip's no-op check all stay as they are, because each is correct once the row it reads is.
## Alternatives considered
**Have the chip re-read the host instead of the list row.** It would route around the stale row, but the row is also what the session header labels itself from, so the staleness would survive in the surface where it is most visible — and any future reader of `SessionSummary.agentPreset` would inherit the same trap.
**Drop the entry-identity memoization and rebuild rows every snapshot.** It removes the whole class of missing-field bugs, at the cost the memo exists to avoid: a wire refresh mints new objects for every row, so each refresh would re-render the entire session list.
**Compare summaries structurally rather than field by field.** A generic deep comparison cannot be added blind: the row carries `projectionValues`, whose reference identity is the deliberate signal that the projection store republished, and folding it into a value comparison would either re-render on every projection tick or mask a real one.
## Consequences
Every field a session row carries now participates in row identity, so a surface reading `SessionSummary.agentPreset` sees a switch as soon as the host confirms it — the header label included. The guard is still a hand-written enumeration, so a field added to `SessionSummary` later must be added here too; the `sessions-service` projection test names the failure mode for the next such field rather than only pinning this one.
## Testing
`sessions-service.spec.ts` feeds a blank row, notes a switch, and asserts the projected snapshot reports the new preset — it fails on the old guard because the row differs in nothing else. The `agent-preset-selection` web e2e switches down and back up, asserting the host honors the second switch and the `/` catalog returns with it; without this fix the second switch never reaches the host at all.
## Related
The same e2e covers [the catalog-invalidation fix](2026-08-10-slash-catalog-follows-preset-switch.md), which is what makes the menu follow either switch once the switch itself lands.

View File

@@ -0,0 +1,37 @@
# Agent Note:会话行的标识判定纳入 preset
Status: implemented
[English](2026-08-10-session-row-identity-covers-the-preset.md) | 中文
## Problem
`SessionManager.buildListSnapshot` 按值对列表行做记忆化:一次 wire 刷新会铸造全新的 summary 对象,因此与缓存项相等的行会被替换为缓存实例,下游每一个 `SessionListItem` memo 才能持续命中。它声明的约定是「每个字段都相同就复用缓存对象」,而那段比较是手写枚举字段的,其中没有 `agentPreset`。
一次已确认的 preset 切换恰好只移动这一个字段。`noteAgentPreset` 把它 upsert 进去,`applyMutation` 合并它——该合并有意不采用 mutation 的 `updatedAt`,因此切换后的行与它的缓存孪生只在 preset 上不同,别处一致。于是标识判定认为这一行没变,永久地提供了过期实例:manager 自己的 summaries 是 `minimal`,而所有读取投影快照的一方继续读到 `standard`。
hero 上的 chip 正是其中一个读取方,而且它在发出任何请求之前会拿这次选择和那一行比较。切回会话创建时的那个 preset,在它看来就是「已经是这个 preset 了」,于是丢弃 stage、根本不发 RPC——chip 的标签变了,组成没变。一个会话可以从创建时的 preset 切走一次,然后再也切不回来。
## Decision
标识判定把 `agentPreset` 与其余 summary 字段一起比较,这本就是「每个字段都相同」所声称的内容。其他一概不动:记忆化、合并、chip 的 no-op 检查各自都是对的——只要它们读到的那一行是对的。
## Alternatives considered
**让 chip 改为直接读宿主,而不是读列表行。** 这样能绕开过期的行,但会话头部的标签同样以这一行为准,过期状态会在最显眼的界面里留下来;而且将来任何 `SessionSummary.agentPreset` 的读取方都会继承同一个陷阱。
**去掉行标识记忆化,每次快照都重建行。** 这能整类消除「漏字段」缺陷,代价却正是这个 memo 存在的理由:一次 wire 刷新会为每一行铸造新对象,于是每次刷新都要重渲染整个会话列表。
**改成结构化比较,而不是逐字段枚举。** 通用的深比较不能盲目加:行上带有 `projectionValues`,它的引用标识本身就是「投影 store 重新发布了」这一有意为之的信号,把它折进值比较,要么每个投影 tick 都重渲染,要么把一次真实变化掩盖掉。
## Consequences
会话行携带的每个字段现在都参与行标识,因此读取 `SessionSummary.agentPreset` 的界面会在宿主确认后立刻看到切换,会话头部标签也包含在内。该判定仍是手写枚举,所以将来给 `SessionSummary` 新增字段时必须同步加进来;`sessions-service` 的投影测试为下一个这样的字段点明了失效形态,而不只是钉住这一次。
## Testing
`sessions-service.spec.ts` 喂入一行空会话、记录一次切换,并断言投影快照报告的是新 preset——在旧判定下它会失败,因为这一行别处都没变。`agent-preset-selection` web e2e 先向下切再向上切,断言宿主认可第二次切换、`/` 目录随之回来;没有这次修复,第二次切换根本到不了宿主。
## Related
同一条 e2e 也覆盖[目录失效的修复](2026-08-10-slash-catalog-follows-preset-switch.md)——正是它让菜单在切换真正落地之后跟随任一方向的切换。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md
2026-08-10-slash-catalog-follows-preset-switch.md: 85bd5b2134fd20c86fdeb13f3ce5b007449105b5
2026-08-10-slash-catalog-follows-preset-switch.zh.md: 97c8f08a7b3dfec7c17fbb00bef626e28505c500

View File

@@ -0,0 +1,43 @@
# Agent Note: The slash catalog follows a blank session's preset switch
Status: implemented
English | [中文](2026-08-10-slash-catalog-follows-preset-switch.zh.md)
## Problem
Presets moved the rows that decide what a session's `/` menu contains. The Web composition disables host-plane `skill-local`, `tool-skill`, `plan-mode`, and `command-compact`; a preset supplies them, so which commands and skills exist is a property of the session's composition rather than of the deployment.
Both browser catalogs cache per session — `CommandDirectory` in `dsh-client-ui-command`, the single-flight fetch map in `dsh-client-ui-skill` — and the composer warms both at scope birth, under whatever preset the session was created with. The hero chip then lets the user recompose the still-blank session, and neither cache had an invalidation edge for that: `commands/changed` is registry-wide and `connection/reset` needs a reconnect. `agentPresets.recompose` re-parents the agent's scope onto a standing mount that may already exist, so it registers nothing and the registry-wide signal never fires for it.
The menu therefore kept serving the composition the session no longer ran. Switching down left `compact`, `plan`, and every project skill listed; switching up left the narrower catalog — the four host-plane rows and the client's own `model` contribution — with no skills at all, which is what the bug report described. The catalog only healed when an unrelated registry change or a reconnect happened to invalidate it.
## Decision
The switch's commit point is the logged `agent-preset/selected` event. The host stream frames it as `host/session-preset-changed { sessionId, agentPreset }`, the browser runtime bridges that frame to the typed `session/preset-changed` ctx event beside the registry-invalidation bridges it already owns, and each catalog owner drops its own entry for that session: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog).
The frame is per session and carries no catalog, only the preset id — which the manager folds into the session row, because the `agentPresets.select` echo reaches only the client that issued the switch and the row is what the session header labels itself from (and what the hero chip compares the next pick against).
Deriving the frame from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come.
## Alternatives considered
**Invalidate in the client's own `agentPresets.select` callback.** Smallest change, and the preset is locked after the first turn, so the hero chip is the only place a switch can originate. Rejected because the invalidation would then live in the surface that happens to issue the RPC rather than at the commit point: a second tab on the same blank session keeps a stale menu, and any future host-side recomposition has no signal at all.
**Derive the client event from the existing `session/event` mux frame.** The logged event already reaches every subscribed client, so no new wire type would be needed. Rejected on face separation: narrowing `event.type` to `agent-preset/selected` requires the `SessionEventMap` augmentation, and the only ways to load it in the Client program are a project reference to `dsh-agent-presets` — which drags the host `ctx.sessions` merge into a program that publishes its own — or a cast that defeats the discriminant.
**Reuse `host/commands-changed`.** It is the existing catalog-invalidation frame, but it is registry-wide, carries no session, and says nothing about skills; a client would repull every session's commands and still never refresh a skill catalog.
## Consequences
The wire gains one frame and the Client one typed event, and every catalog a preset decides now has one place to subscribe: a future per-session surface derived from the composition invalidates on the same signal instead of inventing another. The cost is that the frame is a second reader of a logged fact — the host stream must keep deriving it from `agent-preset/selected`, so a future switch path that recomposes without logging would go unannounced. `ui-command` stays soft (the open menu never blanks) while `ui-skill` drops its entry outright, because a skill catalog has no partial-serve mode; a menu opened inside the refetch window shows no skills for that instant rather than the wrong ones.
## Testing
`api-proxy-agent-preset.spec.ts` asserts the committed switch frames once with the session and its new preset; `wire-events.spec.ts` asserts the frame-to-event bridge; the `ui-command` and `ui-skill` specs assert that the event repulls the recomposed session and leaves every other session's cache serving. The `agent-preset-selection` web e2e seeds a project skill and, after the hero chip applies `minimal`, asserts the `/` menu drops `compact`, `plan`, and the skill while keeping the host-plane rows — the assembled-application evidence that the panel follows the composition.
That e2e also stopped reading its staged-pick assertion off the serialized session list: the seeded session records `minimal` too, so the substring answered before the switch had landed. It now addresses the live session by id.
## Related
Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, `agent-preset-selection.e2e.ts` could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen.

View File

@@ -0,0 +1,43 @@
# Agent Note:斜杠目录跟随空会话的 preset 切换
Status: implemented
[English](2026-08-10-slash-catalog-follows-preset-switch.md) | 中文
## Problem
preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿主面的 `skill-local`、`tool-skill`、`plan-mode` 和 `command-compact`,改由 preset 提供,因此一个会话有哪些命令和技能,是它自身组成的属性,而不是部署的属性。
浏览器侧两份目录都按会话缓存——`dsh-client-ui-command` 的 `CommandDirectory`,`dsh-client-ui-skill` 的 single-flight 拉取表——并且 composer 在 scope 出生时就按会话创建时的 preset 预热了它们。随后 hero 上的 chip 允许用户重组这个仍为空的会话,而两份缓存都没有对应的失效边:`commands/changed` 是注册表级的,`connection/reset` 需要重连。`agentPresets.recompose` 只是把 agent 的 scope 重新挂接到一个可能已经存在的常驻挂载上,不产生任何注册,注册表级信号因此永远不会为它触发。
于是菜单继续提供会话已经不再运行的那套组成。向下切换后 `compact`、`plan` 和全部项目技能仍列在菜单里;向上切换后留在原地的是更窄的目录——四条宿主面行加客户端自己的 `model` 贡献——而且完全没有技能,这正是 bug 报告描述的现象。只有当某个无关的注册表变化或一次重连恰好使其失效时,目录才会自愈。
## Decision
这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。
该帧按会话粒度,不携带目录,只带 preset id——manager 会把它折进会话行,因为 `agentPresets.select` 的回执只会到达发起切换的那个客户端,而会话头部标签正是以这一行为准(hero chip 比较下一次选择时读的也是它)。
从落账事件而不是 RPC 处理器的返回值派生该帧,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。
## Alternatives considered
**在客户端自己的 `agentPresets.select` 回调里就地失效。** 改动最小,而且第一轮之后 preset 就锁定,hero 上的 chip 是切换唯一可能的发起处。否决理由是失效逻辑会落在恰好发起 RPC 的那个界面上,而不是提交点:同一个空会话在第二个标签页里仍是过期菜单,将来任何宿主侧的重组也完全没有信号。
**从既有的 `session/event` mux 帧派生客户端事件。** 落账事件本来就会送达每个已订阅的客户端,不需要新增协议类型。因面(face)分离而否决:把 `event.type` 收窄到 `agent-preset/selected` 需要 `SessionEventMap` 增补,而在 Client 程序里加载它只有两条路——引用 `dsh-agent-presets` 工程,那会把宿主的 `ctx.sessions` 合并拖进一个自己也发布同名服务的程序;或者用一次类型断言绕过判别式。
**复用 `host/commands-changed`。** 它是既有的目录失效帧,但它是注册表级的、不带会话、也与技能无关;客户端会把每个会话的命令都重拉一遍,却依然永远刷不新技能目录。
## Consequences
协议多了一个帧,Client 多了一个类型化事件,而每一份由 preset 决定的目录从此有了统一的订阅点:将来任何从组成派生的按会话界面,都在同一个信号上失效,而不必再发明一个。代价是该帧成为一项落账事实的第二个读者——宿主流必须持续从 `agent-preset/selected` 派生它,因此将来若出现一条不落账就重组的切换路径,它将无人宣告。`ui-command` 保持软失效(已打开的菜单不会变空),而 `ui-skill` 直接丢弃该项,因为技能目录没有「部分可服务」的状态;在重拉窗口内打开的菜单,那一瞬间显示的是没有技能,而不是错误的技能。
## Testing
`api-proxy-agent-preset.spec.ts` 断言已提交的切换恰好成帧一次,并带上会话与新 preset;`wire-events.spec.ts` 断言帧到事件的桥接;`ui-command` 与 `ui-skill` 的 spec 断言该事件只重拉被重组的会话,其他会话的缓存继续服务。`agent-preset-selection` web e2e 播种一个项目技能,并在 hero chip 应用 `minimal` 之后断言 `/` 菜单丢掉了 `compact`、`plan` 和该技能,同时保留宿主面的那几行——这是面板跟随组成的整装应用证据。
同一条 e2e 也不再从序列化后的会话列表里读它的 staged-pick 断言:被播种的会话同样记录着 `minimal`,子串匹配在切换落地之前就会通过。现在它按 id 寻址那个活跃会话。
## Related
第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,`agent-preset-selection.e2e.ts` 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。