diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
index 5a98fcfa4d..b17a826f3a 100644
--- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md
-2026-08-05-agent-teams.md: 073b0ac745e5bb171987ff11b4bfba0a50a8087a
-2026-08-05-agent-teams.zh.md: 0fbb477429d10fbfbcbfb5437e977f597f3ce4c2
+2026-08-05-agent-teams.md: 6f271b18fbf7498dd60098ca4f1ef610a398700a
+2026-08-05-agent-teams.zh.md: 6e9a36b07167e661382b129079b6a98d88c0aa53
diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md
index 073b0ac745..6f271b18fb 100644
--- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md
+++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md
@@ -56,9 +56,9 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may
## Web projection
-The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Current-session values use the standard `useProjection` hook; a teammate conversation reads its Lead through the shared cross-Session store.
+The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data, and changes to registered wire keys replace control baselines for connected clients.
-The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; opening the panel requests other active members' baselines without opening their conversations, and no model is shown before a durable selection or request exists.
+The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it.
Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost.
diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md
index 0fbb477429..6e9a36b071 100644
--- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md
@@ -56,9 +56,9 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以
## Web projection
-Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。当前会话的值使用标准 `useProjection` 钩子;teammate 会话通过共享的跨 Session store 读取其 Lead。
+Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据,已注册传输 key 的变化会为已连接客户端替换控制基线。
-传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;打开 panel 时请求其他 active 成员的基线,无需打开它们的会话,在持久选择或请求出现前不显示模型。
+传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。
发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。
diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts
index 3ffb5dedff..cf3fe29662 100644
--- a/apps/web/tests/agent-team-panel.e2e.ts
+++ b/apps/web/tests/agent-team-panel.e2e.ts
@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { readFileSync } from 'node:fs'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import type {} from '@deepseek-ai/dsh-experimental-agent-team'
@@ -82,10 +82,13 @@ describe('web e2e: Agent Teams panel', () => {
it('displays agent-owned task changes through a read-only board without a refresh action', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel'))
+ const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections')
+ onTestFinished(() => { projectionReads.mockRestore() })
await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click()
const action = page.getByRole('dialog', { name: 'Agent Team', exact: true })
await action.getByText('No shared tasks yet').waitFor()
await action.getByText('lead').waitFor()
+ expect(projectionReads).not.toHaveBeenCalled()
expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0)
expect(await action.getByRole('button', { name: /Refresh/u }).count()).toBe(0)
@@ -107,6 +110,7 @@ describe('web e2e: Agent Teams panel', () => {
const snapshot = await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE)
+ expect(projectionReads).not.toHaveBeenCalled()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await action.getByRole('button', { name: 'Close' }).click()
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index a6e1582653..295ce3768e 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 1d844013f1230d6dbe535c3942279683f03738ce
-event-producer-consumer.zh.md: 73438c3f19ef6850674a27bf8f490fe4cf52b420
+event-producer-consumer.md: 3b259e4c32d5de56e6f97b214b5fd50ca3af8e6f
+event-producer-consumer.zh.md: 3f2d1527919b819aec8d61a8e932876196caa673
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 1d844013f1..3b259e4c32 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -55,6 +55,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `session-projection/definitions-changed` | `emit` | [`packages/session/session-projection/src/index.ts:39`](../packages/session/session-projection/src/index.ts) | [`session-projection`](../packages/session/session-projection) (`emit`) | `session-controller` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 73438c3f19..3f2d152791 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -57,6 +57,7 @@
| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `session-projection/definitions-changed` | `emit` | [`packages/session/session-projection/src/index.ts:39`](../packages/session/session-projection/src/index.ts) | [`session-projection`](../packages/session/session-projection) (`emit`) | `session-controller` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml
index cc738c4fcb..699f3162a0 100644
--- a/docs/subsystems/session-projection.i18n.yaml
+++ b/docs/subsystems/session-projection.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: 8580b94c4d7dd36f942ff10d77e13d2dc70b7e8b
-session-projection.zh.md: b98d348a36c51aab973bc3397ddbf744860245a4
+session-projection.md: 4cc68aab0e94d155b5f6736f61fdc2394e7bf622
+session-projection.zh.md: fd6e0428456a4893bcf15e7899baec4ea01f8295
diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md
index 8580b94c4d..4cc68aab0e 100644
--- a/docs/subsystems/session-projection.md
+++ b/docs/subsystems/session-projection.md
@@ -352,5 +352,25 @@ hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly Se
Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionLogOffset](session.md)
+Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
+
+
+
+### `session-projection/*` events
+
+
+
+#### `session-projection/definitions-changed` — emit
+
+The registered client-visible keys changed; carriers must replace their baselines.
+
+```ts cordis-catalog
+/**
+ * The registered client-visible keys changed; carriers must replace their baselines.
+ * @mode emit
+ */
+'session-projection/definitions-changed'(): void
+```
+
Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md
index b98d348a36..fd6e042845 100644
--- a/docs/subsystems/session-projection.zh.md
+++ b/docs/subsystems/session-projection.zh.md
@@ -352,5 +352,25 @@ hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly Se
Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionLogOffset](session.zh.md)
+Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
+
+
+
+### `session-projection/*` events
+
+
+
+#### `session-projection/definitions-changed` — emit
+
+The registered client-visible keys changed; carriers must replace their baselines.
+
+```ts cordis-catalog
+/**
+ * The registered client-visible keys changed; carriers must replace their baselines.
+ * @mode emit
+ */
+'session-projection/definitions-changed'(): void
+```
+
Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index c283462c19..3a1d8a748c 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: fa2a251e94cfa2855790d7d71457ac4bdf2ff752
-README.zh.md: d1a73ed5843fddadfc2f401fee1a86eece4e5272
+README.md: 08c68da01ab88e500970186cf799b33f9926289b
+README.zh.md: a9f5878dc8bcf7134dd0d658ec477fe21ecbcf1e
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index fa2a251e94..08c68da01a 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -45,6 +45,8 @@ Accepted prompts and observed running keep their Client display conversion acros
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
+The control stream also sends a complete baseline when the registered client-visible projection keys change. Already-connected browsers receive late capability registration and removal without issuing a projection read.
+
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index d1a73ed584..a9f5878dc8 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -45,6 +45,8 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
+客户端可见投影 key 的注册集合变化时,控制流也发送完整基线。已连接浏览器无需发起投影读取,即可收到能力的晚注册与移除。
+
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。
diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts
index 8d27396d29..48132e1e22 100644
--- a/packages/api/session-controller/src/control.ts
+++ b/packages/api/session-controller/src/control.ts
@@ -19,6 +19,9 @@ export class SessionControlController {
/** @param ctx - Host context carrying live Agent and projection services. */
constructor(private readonly ctx: Context) {
+ ctx.on('session-projection/definitions-changed', () => {
+ if (this.streams.size > 0) this.broadcast({ type: 'baseline', value: this.baseline() })
+ })
ctx.sessionProjections.onChanged((session, key, value, seq) => {
this.broadcast({
type: 'projection',
diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts
index ad24ad94fb..60c0a19dcb 100644
--- a/packages/api/session-controller/tests/control-queue.host.spec.ts
+++ b/packages/api/session-controller/tests/control-queue.host.spec.ts
@@ -1,3 +1,4 @@
+import { z } from 'zod'
import { Context } from '@deepseek-ai/cordis'
import type { Agent, Inbox, InboxState } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
@@ -17,6 +18,15 @@ declare module '@deepseek-ai/dsh-llm' {
}
}
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionMap {
+ 'test/late-capability': number
+ }
+ interface SessionProjectionStateMap {
+ 'test/late-capability': number
+ }
+}
+
const ownedContexts = new Set()
afterEach(async () => {
await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose()))
@@ -56,6 +66,47 @@ describe('Session control Inbox projection', () => {
}
}
+ it('updates connected clients when a wire capability is registered, shared, and removed', async () => {
+ const { ctx, control, agent } = await harness()
+ const empty = ctx.sessions.create(SessionId('empty-late-capability'))
+ const abort = new AbortController()
+ const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
+ try {
+ await iterator.next()
+ const unit = {
+ key: 'test/late-capability' as const,
+ stateSchema: z.number(),
+ init: () => 7,
+ apply: (state: number) => state,
+ wire: { viewSchema: z.number(), view: (state: number) => state },
+ stateVersion: 1,
+ }
+ const removeFirst = ctx.sessionProjections.register(unit)
+ const removeSecond = ctx.sessionProjections.register(unit)
+ removeFirst()
+ await expect(iterator.next()).resolves.toMatchObject({ value: {
+ type: 'baseline', value: { projections: {
+ [agent.session.id]: { values: { 'test/late-capability': 7 } },
+ [empty.id]: { asOfSeq: -1, values: { 'test/late-capability': 7 } },
+ } },
+ } })
+ removeSecond()
+ const removed = await iterator.next()
+ if (removed.done || removed.value.type !== 'baseline') throw new Error('missing removal baseline')
+ expect(removed.value.value.projections[agent.session.id]?.values).not.toHaveProperty('test/late-capability')
+ ctx.sessionProjections.register(unit)
+ await expect(iterator.next()).resolves.toMatchObject({ value: {
+ type: 'baseline', value: { projections: {
+ [agent.session.id]: { values: { 'test/late-capability': 7 } },
+ [empty.id]: { asOfSeq: -1, values: { 'test/late-capability': 7 } },
+ } },
+ } })
+ } finally {
+ abort.abort()
+ await iterator.return?.()
+ }
+ })
+
it('projects both pending lists in baselines and live replacement frames', async () => {
const { control, inbox } = await harness()
const queued = message('queued')
diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml
index 696a104fa5..e95fcf19b7 100644
--- a/packages/experimental/agent-team/README.i18n.yaml
+++ b/packages/experimental/agent-team/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md
-README.md: 094e7a781ff01ebab1498b6ccd207814ae722dfb
-README.zh.md: e6be6e5b500c4ca635eeb52f82a12e5e07faaaab
+README.md: 7295ff0c15ba441ab07b786de1958acc218a2759
+README.zh.md: 07fe1a237a8c5dafa8e9640114d22aa76223f2e0
diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md
index 094e7a781f..7295ff0c15 100644
--- a/packages/experimental/agent-team/README.md
+++ b/packages/experimental/agent-team/README.md
@@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t
The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types.
-The [Web UI](../client-ui-agent-team/README.md) owns projection baseline loading and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types.
+The [Web UI](../client-ui-agent-team/README.md) reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types.
## Model Experience
diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md
index e6be6e5b50..07fe1a237a 100644
--- a/packages/experimental/agent-team/README.zh.md
+++ b/packages/experimental/agent-team/README.zh.md
@@ -176,7 +176,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch
`agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。
-[Web UI](../client-ui-agent-team/README.zh.md) 负责投影基线加载,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。
+[Web UI](../client-ui-agent-team/README.zh.md) 读取共享 Session 投影,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。
## 模型体验
diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml
index d661eeb8c1..4895ad308e 100644
--- a/packages/experimental/client-ui-agent-team/README.i18n.yaml
+++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md
-README.md: 267c607f311500884e60d399a9e65ef0bb7fd868
-README.zh.md: 81d218379d3a5f370f7fbb6759b218cb3f92c47f
+README.md: c17f358332d3c61d1f6fa4dc5f4e4941d2dedec9
+README.zh.md: 66da22cd25a24aec1d9df72a2a7f85d126f379f3
diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md
index 267c607f31..c17f358332 100644
--- a/packages/experimental/client-ui-agent-team/README.md
+++ b/packages/experimental/client-ui-agent-team/README.md
@@ -29,7 +29,9 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`]
### Inspect and navigate the roster
-The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's projection baseline once per connection, both in the Lead conversation and in a teammate conversation. A successful prior read is reused; a failed read shows a retry control alongside any last valid Team, and a reconnect reloads it. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field.
+The trigger shows the teammate count, and the panel shows the Lead Session's roster and task board from the shared Session store. Task and roster updates appear while the panel stays open. Opening the panel performs no projection requests. The panel shows a loading notice while the conversation or Session list is loading, and an unavailable notice when no Team value is present afterward.
+
+Roster rows show durable names and phases. Live Session status supplies running activity; the shared `modelSelection` projection supplies a model when available. Selecting an active teammate opens its ordinary continuable child address. The Host validates the parent, child, and mode when history opens; later human prompts use the same addressed-subagent conversation.
### Inspect the task board
@@ -47,11 +49,11 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso
The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations.
-The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` reads its Team value. Separate `useSessions` selectors read the Lead's baseline-read state and error in both conversation types. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure.
+The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. `useSession` supplies the Lead identity; `useSessions` selects its Team and each member's model from the shared store. Each roster row selects its own running state. The only injected callback opens a teammate using the current and child Session ids. Switching conversations closes the panel and clears a navigation failure.
| File | Role |
|---|---|
-| [`src/client/mount.ts`](src/client/mount.ts) | Locale, projection-read, navigation, and slot registrations |
+| [`src/client/mount.ts`](src/client/mount.ts) | Locale, navigation, and slot registrations |
| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Projection-derived roster and task board with panel interaction state |
| [`src/client/locales.ts`](src/client/locales.ts) | English and Chinese panel copy |
| [`src/index.ts`](src/index.ts) | Inert Host entry |
@@ -83,10 +85,8 @@ No direct effect; the Team tools and ordinary conversation submission own any la
-- **Cached capability absence** — if another UI loaded the Lead projection baseline before Agent Teams was enabled, opening this panel reuses that baseline; reconnect to load the newly enabled capability.
-- **Member baseline failures** — the panel shows read errors only for the Lead. Failed reads for other active members retry when the panel reopens or receives a roster or task update; persistent failures can issue repeated requests while the Team is active.
- **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown.
-- **Model before the first request** — a member without a durable model selection or request shows no model. Inactive persisted members can supply their model through the explicit projection read; live activity still requires a running Host agent.
+- **Model availability** — a model appears only when the shared store has a durable selection or request for that member. Missing cold-cache values stay absent until normal Session loading or a live update supplies them.
- **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox.
- **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata.
diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md
index 81d218379d..66da22cd25 100644
--- a/packages/experimental/client-ui-agent-team/README.zh.md
+++ b/packages/experimental/client-ui-agent-team/README.zh.md
@@ -29,7 +29,9 @@ kind: "package-reference"
### 检查并导航 roster
-触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。无论在 Lead 会话还是 teammate 会话中,打开 panel 时都按每次连接一次的规则请求 Lead 的投影基线。之前读取成功则复用;读取失败会在最后有效 Team 旁显示重试控件,重连后会重新加载。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。
+触发按钮显示 teammate 数量,面板从共享 Session store 展示 Lead Session 的 roster 与任务板。面板保持打开时,任务和成员更新会直接出现。打开面板不发起投影请求。会话或 Session 列表正在加载时,面板显示加载提示;加载结束后仍无 Team 值时,显示不可用提示。
+
+Roster 行展示持久名称与阶段。实时 Session 状态提供运行活动;共享 `modelSelection` 投影在可用时提供模型。选择 active teammate 会打开其普通 continuable 子会话地址。Host 在打开历史时校验 parent、child 与 mode;后续人类提示词使用同一 addressed-subagent 会话。
### 查看任务板
@@ -47,11 +49,11 @@ kind: "package-reference"
Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。
-面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 读取其 Team 值。两类会话均通过独立的 `useSessions` selector 读取 Lead 的基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。
+面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。`useSession` 提供 Lead 身份;`useSessions` 从共享 store 选择其 Team 及每个成员的模型。每个 roster 行只选择自己的运行状态。唯一的注入回调通过当前与子 Session 的 id 打开 teammate。切换会话会关闭面板并清除导航失败。
| 文件 | 职责 |
|---|---|
-| [`src/client/mount.ts`](src/client/mount.ts) | locale、投影读取、导航与 slot registration |
+| [`src/client/mount.ts`](src/client/mount.ts) | locale、导航与 slot registration |
| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | 由投影派生的 roster 与任务板及面板交互状态 |
| [`src/client/locales.ts`](src/client/locales.ts) | 中英文 panel 文案 |
| [`src/index.ts`](src/index.ts) | 不执行行为的 Host entry |
@@ -83,10 +85,8 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati
-- **能力缺失的缓存** — 如果其他 UI 在启用 Agent Teams 前已读取 Lead 的投影基线,打开本面板会复用该基线;重新连接后才能加载新启用的能力。
-- **成员基线读取失败** — 面板只显示 Lead 的读取错误。其他 active 成员的读取失败会在重新打开面板或收到 roster、任务更新时重试;Team 活跃期间,持续失败可能反复发出请求。
- **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。
-- **首次请求前的模型** — 没有持久模型选择或请求的成员不显示模型。未运行的持久成员可通过显式投影读取提供模型;实时活动仍需要正在运行的 Host agent。
+- **模型可用性** — 仅当共享 store 包含该成员的持久选择或请求时才显示模型。冷缓存中缺少的值会保持缺失,直到正常 Session 加载或实时更新提供它们。
- **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。
- **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。
diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx
index cd1b765f0c..5a4dd88b65 100644
--- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx
+++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx
@@ -7,7 +7,7 @@ import type {
} from '@deepseek-ai/dsh-experimental-agent-team/client'
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
import {
- IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot,
+ IconCloseOutlineRegular, IconUserOutlineRegular, StateDot,
useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -17,9 +17,8 @@ import css from './TeamAction.module.css'
/** Business actions injected by the browser plugin. */
export interface TeamActionInjected {
- /** Read a Session's projection baseline once per connection, or retry a failed read. */
- loadProjections: (sessionId: SessionId) => void
- openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void
+ /** Open a continuable child from the current conversation. */
+ openTeammate: (sessionId: SessionId, childSessionId: SessionId) => void
}
/** Full props of the Team conversation-header action. */
@@ -29,10 +28,6 @@ export type TeamActionProps =
/** Durable lifecycle overlaid with the member Session's live turn activity. */
type MemberStatus = 'running' | 'inactive' | 'provisioning' | 'failed'
-function failureText(error: { readonly code: string; readonly message: string }): string {
- return `${error.message} (${error.code})`
-}
-
function statusKey(status: TeamTask['status']): TeamKey {
switch (status) {
case 'pending': return 'status.pending'
@@ -72,26 +67,21 @@ function taskDotState(task: TeamTask): StateDotState {
}
type TeamMemberRowProps = Pick & {
member: TeamMemberProjection
onError: (message: string) => void
}
function TeamMemberRow({
- member, sessionId, useProjection, useSessions, useSessionStatus, openTeammate, onError, t,
+ member, sessionId, useSessions, useSessionStatus, openTeammate, onError, t,
}: TeamMemberRowProps) {
- const currentModel = useProjection('modelSelection', value =>
- member.id === sessionId ? value?.next?.model : undefined)
- const otherModel = useSessions(state => member.id === sessionId
- ? undefined
- : state.projectionsBySession[member.id]?.values.modelSelection?.next?.model)
+ const model = useSessions(state => state.projectionsBySession[member.id]?.values.modelSelection?.next?.model)
const running = useSessionStatus(state => state.get(member.id)?.running)
const summaryRunning = useSessions(state => state.byId[member.id]?.running)
const status: MemberStatus = member.phase === 'active'
? (running ?? summaryRunning) === true ? 'running' : 'inactive'
: member.phase
- const model = member.id === sessionId ? currentModel : otherModel
return (