Explorar o código

Merge PR #500 head into all-gates recovery

# Conflicts:
#	package.json
#	scripts/run-gates.ts
Tianyi Cui hai 2 meses
pai
achega
6d054fd31b
Modificáronse 100 ficheiros con 9352 adicións e 103 borrados
  1. 6 0
      .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml
  2. 253 0
      .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
  3. 251 0
      .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
  5. 148 0
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
  6. 148 0
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
  8. 47 0
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
  9. 47 0
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
  10. 6 0
      .agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml
  11. 61 0
      .agents/notes/implemented/process/2026-07-19-web-styling-system.md
  12. 61 0
      .agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md
  13. 6 0
      .agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
  14. 59 0
      .agents/notes/implemented/process/2026-07-20-gui-testing-system.md
  15. 59 0
      .agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md
  16. 2 0
      .gitignore
  17. 23 0
      apps/cli/package.json
  18. 22 0
      apps/cli/src/bin.ts
  19. 104 0
      apps/cli/src/headless.ts
  20. 89 0
      apps/cli/src/web.ts
  21. 18 0
      apps/cli/tsconfig.json
  22. 12 0
      apps/web/index.html
  23. 37 0
      apps/web/package.json
  24. 10 0
      apps/web/src/main.ts
  25. 147 0
      apps/web/tests/smoke-fixture.e2e.ts
  26. 235 0
      apps/web/tests/smoke-real.e2e.ts
  27. 47 0
      apps/web/tests/support.ts
  28. 22 0
      apps/web/tsconfig.json
  29. 26 0
      apps/web/vite.config.ts
  30. 16 1
      docs/config-catalog.md
  31. 1 1
      docs/cordis-catalog/events.md
  32. 2 2
      docs/cordis-catalog/services.md
  33. 9 7
      docs/event-producer-consumer.md
  34. 49 0
      docs/module-graph.md
  35. 3 3
      docs/persistence-catalog.md
  36. 107 0
      docs/web-styling.md
  37. 16 0
      eslint.config.mjs
  38. 449 87
      knip.json
  39. 11 2
      package.json
  40. 71 0
      packages/client/AGENTS.md
  41. 16 0
      packages/client/connection/README.md
  42. 53 0
      packages/client/connection/package.json
  43. 46 0
      packages/client/connection/src/client/api.ts
  44. 190 0
      packages/client/connection/src/client/connection.ts
  45. 560 0
      packages/client/connection/src/client/fixture.ts
  46. 76 0
      packages/client/connection/src/client/index.ts
  47. 12 0
      packages/client/connection/src/client/web-api-client.ts
  48. 10 0
      packages/client/connection/src/index.ts
  49. 32 0
      packages/client/connection/src/invariant.ts
  50. 21 0
      packages/client/connection/tests/api-helpers.spec.ts
  51. 65 0
      packages/client/connection/tests/client-apply.spec.ts
  52. 238 0
      packages/client/connection/tests/connection.spec.ts
  53. 160 0
      packages/client/connection/tests/fake-api.ts
  54. 339 0
      packages/client/connection/tests/fixture.spec.ts
  55. 10 0
      packages/client/connection/tests/node-half.spec.ts
  56. 42 0
      packages/client/connection/tsconfig.json
  57. 3 0
      packages/client/connection/tsdown.config.ts
  58. 16 0
      packages/client/i18n/README.md
  59. 54 0
      packages/client/i18n/package.json
  60. 108 0
      packages/client/i18n/src/client/index.ts
  61. 11 0
      packages/client/i18n/src/index.ts
  62. 32 0
      packages/client/i18n/src/invariant.ts
  63. 2 0
      packages/client/i18n/src/locales/en.ts
  64. 2 0
      packages/client/i18n/src/locales/zh.ts
  65. 53 0
      packages/client/i18n/tests/i18n.spec.ts
  66. 30 0
      packages/client/i18n/tests/invariant.spec.ts
  67. 27 0
      packages/client/i18n/tsconfig.json
  68. 3 0
      packages/client/i18n/tsdown.config.ts
  69. 18 0
      packages/client/runtime/README.md
  70. 63 0
      packages/client/runtime/package.json
  71. 128 0
      packages/client/runtime/src/client/index.ts
  72. 247 0
      packages/client/runtime/src/client/loader/index.ts
  73. 165 0
      packages/client/runtime/src/client/sessions/conversation.ts
  74. 194 0
      packages/client/runtime/src/client/sessions/fold-adapter.ts
  75. 63 0
      packages/client/runtime/src/client/sessions/lineage.ts
  76. 250 0
      packages/client/runtime/src/client/sessions/manager.ts
  77. 61 0
      packages/client/runtime/src/client/sessions/notifier.ts
  78. 89 0
      packages/client/runtime/src/client/sessions/partial.ts
  79. 227 0
      packages/client/runtime/src/client/sessions/service.ts
  80. 527 0
      packages/client/runtime/src/client/sessions/session.ts
  81. 107 0
      packages/client/runtime/src/client/slots.ts
  82. 11 0
      packages/client/runtime/src/index.ts
  83. 52 0
      packages/client/runtime/src/invariant.ts
  84. 64 0
      packages/client/runtime/tests/client-apply.spec.ts
  85. 77 0
      packages/client/runtime/tests/client-loader-bundle.e2e.ts
  86. 289 0
      packages/client/runtime/tests/client-loader.spec.ts
  87. 23 0
      packages/client/runtime/tests/conversation.spec.ts
  88. 50 0
      packages/client/runtime/tests/event-script.ts
  89. 161 0
      packages/client/runtime/tests/fake-api.ts
  90. 145 0
      packages/client/runtime/tests/fold-adapter.spec.ts
  91. 47 0
      packages/client/runtime/tests/invariant.spec.ts
  92. 55 0
      packages/client/runtime/tests/lineage.spec.ts
  93. 223 0
      packages/client/runtime/tests/manager.spec.ts
  94. 10 0
      packages/client/runtime/tests/node-half.spec.ts
  95. 75 0
      packages/client/runtime/tests/notifier.spec.ts
  96. 91 0
      packages/client/runtime/tests/partial.spec.ts
  97. 625 0
      packages/client/runtime/tests/session.spec.ts
  98. 193 0
      packages/client/runtime/tests/sessions-service.spec.ts
  99. 80 0
      packages/client/runtime/tests/slots-service.spec.ts
  100. 39 0
      packages/client/runtime/tsconfig.json

+ 6 - 0
.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml

@@ -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
+2026-07-19-gui-layering-and-rpc-protocol.md: ebe21a6060ec69ba9807ab9fbf9906ae24b07823
+2026-07-19-gui-layering-and-rpc-protocol.zh.md: 0c256b60ce44a8e16ec6edfba146c776c4ae2129

+ 253 - 0
.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md

@@ -0,0 +1,253 @@
+# Agent Note: GUI layering and the RPC protocol — host/client layering by capability provider, the four-quadrant message model, and the fetch carrier
+
+Status: implemented
+
+English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md)
+
+> Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation (HTTP+SSE) is in the [web client architecture RFC](2026-07-19-gui-web-client-architecture.md).
+
+## Problem
+
+We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities:
+
+- One `dsh` process supporting both `dsh web` (serve) and `dsh -p` (headless) — one process, two modes (a design reservation)
+- Launching inside Electron with the same Web technology shape as `dsh web`
+
+That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly.
+
+At the same time the physical channels differ per consumer (HTTP/SSE, in-process direct calls, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable.
+
+## Decision
+
+### Layering
+
+Directories layer as follows:
+
+- `packages/host/*`: packages provide host-side capability only (representing the Node.js engineering core built on the existing harness plugin system), and additionally
+    - the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below
+- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Two kinds live here:
+    - **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`): ordinary root-index packages, statically bundled into the shell and seeded into the browser plugin loader's module table.
+    - **dshClient plugin packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the entire implementation and its types live under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle), and cross-package consumption imports the `/client` form. `runtime` additionally exports `./loader` (the shell-held browser bundle loader — a loader cannot load itself).
+- `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures.
+    - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`.
+    - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP.
+    - A future Electron shape reuses the same web client packages over an IPC fetch carrier.
+
+```
+apps/*  (application shapes: apps/web = vite app, apps/cli = bin dispatch)
+  │ consume
+  ▼
+packages/host/*                      packages/client/*
+  apiproxy   front layer: protocol     pure libs: ui-slots / web-react / ui-primitives
+  runtime    assembly / host entity    dshClient plugins ×8 (node half = empty apply,
+  webserver  web-shape HTTP carriage                        client half = src/client/)
+  │ ctx.plugin(...)                      ▲ import only apiproxy's /api /client subpaths
+  ▼                                      │ (type-only + the client base class)
+harness core packages ──────────────────┘ (types reach the browser via import type)
+```
+
+Direction discipline (every rule auditable from package deps):
+
+- `runtime → apiproxy` is one-way; apiproxy depends only on type definitions.
+- Client-side packages **never import** host-side package runtime (they consume only the two browser-safe subpaths `/api` and `/client`).
+- `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency.
+- Cross-package client imports use the `/client` subpath for plugin packages (a bare package name would inline a second runtime instance into a browser bundle; the tsdown purity gate rewrites or rejects it).
+
+TypeScript checks in **two aggregate programs** (`tsconfig.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs.
+
+On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is shaped by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect).
+
+#### Layer roles
+
+| Layer | Package | Responsibility | Key discipline |
+|---|---|---|---|
+| Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx |
+| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly |
+| Carrier layer | `dsh-host-webserver` | Web-shape HTTP: static serving + `/api/*`→handler forwarding + SSE write-out + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it |
+| Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell |
+| Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture RFC | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy |
+| Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app |
+
+#### Naming rule
+
+Packages under `packages/host/*` and `packages/client/*` **must carry the directory-group prefix in the package name**: host/runtime → `dsh-host-runtime`, client/runtime → `dsh-client-runtime`. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the `dsh-*` wildcard in tsconfig.base.json (which resolves by directory name) misses them — **each package in these two groups needs an explicit paths entry**, including separate entries for the plugin packages' `/client` (and runtime's `/loader`) subpaths so source-level resolution matches the exports map.
+
+#### How to integrate a new shape (operational checklist)
+
+1. **Pick a fetch impersonation**: browser same-origin HTTP / in-process `host.handler.fetch` injection / your own transport-aspect subclass (e.g. future Electron IPC, see the "Subclass table" below).
+2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app.
+3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports.
+
+The two existing shapes are the template: `apps/cli/src/web.ts` (startHost + dist location + startWebServer + signal shutdown) and `headless.ts` (startHost + InProcessApiClient isomorphic direct calls, zero HTTP zero ports). ACP-class protocol bridges do not follow this checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch.
+
+## Message protocol
+
+The sections from here down are the protocol body carried by the front layer (`dsh-host-apiproxy`). The wire has exactly four message kinds (the four quadrants) — the Web carriage in the right column is only an example; swapping the carrier (in-process/IPC) leaves the quadrants unchanged:
+
+```
+                 client 发起                      server 发起
+  request   ① ClientRequest                 ③ ServerRequest
+            (POST /api/<method> body)      (SSE 帧:session 事件、审批/问答 requested)
+  response  ② ServerResponse                ④ ClientResponse
+            (该 POST 的 HTTP 应答体)        (POST /api/respond body,回填 ③ 的 rpcId)
+```
+
+### Wire full forms: a four-member named discriminated union (`api/rpc.ts`)
+
+| Type | Discriminant tag | Fields | rpcId ownership | Web carriage |
+|---|---|---|---|---|
+| `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mints | `POST /api/<method>` body |
+| `ServerResponse` | `'server-response'` | `rpcId` `result` | echoes ① | that POST's response body (always HTTP 200) |
+| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mints | SSE `data:` line |
+| `ClientResponse` | `'client-response'` | `rpcId` `result` | echoes ③ | `POST /api/respond` body |
+
+`RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`, narrowed via `switch (message.type)`.
+
+**rpcId discipline** (`RpcId` is a branded string with constructor `RpcId()`):
+
+- Whoever initiates mints; a response always echoes the corresponding request's rpcId and **never mints a new id**.
+- server-requests split into two kinds, distinguished statically by `method` (= the frame type), with **no third kind**: answerable frames (`approval/requested`, `question/requested`) carry a stable logical request id (minted once on acceptance, reused verbatim on baseline replay, echoed by the client's answer); pure-push frames (`session/event` etc.) carry an rpcId identifying that one push (freshly minted each time).
+- Business code never mints: unary minting funnels into the client base class `callUnary`, frame minting funnels into the host side.
+
+### Signature narrow forms and carrier completion
+
+Domain interface signatures perceive only the narrow forms: `RpcRequest<P> = { rpcId, payload }`, `RpcResponse<T> = { rpcId, result: RpcResult<T> }`. The carrier layer completes narrow forms into full forms (adding the `type` tag and `method`); direction is never inferred from the channel. `RpcResult<T> = { ok: true; value } | { ok: false; error: RpcError }` — methods do not throw business errors.
+
+### RpcReceipt: the carrier receipt
+
+The HTTP response body of a `ClientResponse` is `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }` — a carrier-layer receipt, **not** an RpcMessage (a response has no response); late/duplicate answers get `not-pending`, and the logical convergence surface is the `*/resolved` frames.
+
+## The type system: signatures are the source of truth
+
+### RpcMethodMap and derived generics (`api/rpc-map.ts`)
+
+Method parameter/return structures **live only in the interface method signatures**; the map registers the methods themselves; every other position (handler, client, store, tests) references the derived generics — copying literals or introducing flat named types is banned:
+
+```ts ignore-check
+export interface RpcMethodMap {
+  'session.list': SessionsApi['list']        // map key 即 wire 路径段
+  // …其余方法同形登记,全集见 api/rpc-map.ts
+}
+// 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束)
+export type RequestPayload<K> = Parameters<RpcMethodMap[K]>[0]['payload']
+export type ResponseValue<K> =
+  Awaited<ReturnType<RpcMethodMap[K]>> extends RpcResponse<infer T> ? T : never
+```
+
+Stream methods (`events.mux`/`events.host`) stay out of the map (not unary); `respond` stays out of the map (it is a client-response, not a method call).
+
+### The error model (`RpcErrorDetailsMap`)
+
+One example row of an error code:
+
+| code | details | when |
+|---|---|---|
+| `bad-request` | `{ issues: ZodIssue[] }` | wire/payload zod validation failed |
+
+The full code set is `RpcErrorDetailsMap` in `api/rpc.ts`. `RpcError` is the distributive union expanded from the map: `code` discriminates, `details` narrows automatically after a `switch`; **details is required** — a new code = one map row + one error-schema branch, and omission is a compile error. Transport failures (network down, host not up) are thrown by the carrier as exceptions; the two layers never mix.
+
+### Bidirectional zod validation and anchoring
+
+- **Two-level parse**: the full-form schema once (type/rpcId/method structure + the handler checking path==method) → the business payload dispatched by method/frame type for a second parse; rejection = `bad-request`.
+- **Anchoring**: schemas uniformly `satisfies z.ZodType<Wire<T>>` (`api/rpc.schema.ts`). `Wire<T>` is a deep "| undefined" widening — the repo enables `exactOptionalPropertyTypes` while zod `.optional()` outputs `T | undefined`, so anchoring the original type is unusable across the board; on the JSON wire, absence and undefined are indistinguishable, so the widening loses no validation semantics. Passthrough wide branches (`SessionEvent`/`ContentBlock`/frame unions/`RpcError`) and brand-id schemas use explicit casts with comments.
+- Brand casts have one point each: every schema file funnels its id cast into one place (`rpcIdSchema` is the only cast point in rpc.schema.ts).
+
+## The contract face (ApiProxy)
+
+The root interface is `ApiProxy = { sessions, host, events, respond }` (`api/index.ts`). A new client-request domain = one new file pair (`<domain>.ts` + `<domain>.schema.ts`) + one root-interface field + one map row.
+
+### The unary method table
+
+One example row (the table structure is the reading key):
+
+| method key | request payload | return value | semantics |
+|---|---|---|---|
+| `session.list` | `{ cursor?: string }` (cursor is a reserved seat, unimplemented) | `{ items: SessionSummary[] }` | persisted sessions, updatedAt descending; v1 builds no index |
+
+The remaining methods (`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`) are not re-copied here — signatures are the source of truth; see `api/sessions.ts`, `api/host.ts`, and `RpcMethodMap`.
+
+### Frames (server→client, named unions)
+
+Two SSE streams: the mux stream (`GET /api/events.mux`, all-session aggregate) and the host stream (`GET /api/events.host`, host-level events). One example frame row:
+
+| frame type | payload | when |
+|---|---|---|
+| `session/event` | `{ sessionId; event: SessionEvent }` | core passthrough: core events pass verbatim, `assistant/chunk` IS the token stream, no separate delta frame |
+
+The remaining frame types are not re-copied here; the full unions are `MuxFrame`/`HostFrame` in `api/events.ts`. Three semantic points to know: `session/subscribed` carries lastSeq for history seam-race detection; the `approval/question` requested frames are answerable (stable rpcId) and the resolved frames are the convergence surface; `host/agent-error` is the only outlet for live failures with no turn position.
+
+**Passthrough discipline**: events/messages/content blocks on the wire ARE the core types (`SessionEvent`/`ContentBlock`) — no second DTO set; types reach the browser through the `import type` dependency chain. `SessionEventMap` is merge-extensible: the client applies its documented default (ignore) to unknown types, and the event schema keeps a "valid envelope + unknown type" branch — the envelope stays strict; this is not field-level passthrough.
+
+### Session semantics (impl-side commitments)
+
+- **History = event replay**: one fold (client side); history pagination and live increments share one code path; the server maintains no second materialized-snapshot system. History **page boundaries align to message boundaries** (never cut mid-message; chunks group with their finalized message), and the tail page includes the in-flight partial's chunks.
+- **Prompt correlation**: the prompt's rpcId rides MessageSource (`'user-rpc'`) into the `user/message` event; the client uses it to promote the optimistic echo.
+- **Reconnect = rebuild**: no resume cursor (`mux`'s `since` signature is a reserved seat, ignored if passed); on disconnect reopen the stream + refetch history; compare `subscribed.lastSeq` with the history tail seq and backfill once if there is a seam.
+- **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it).
+- **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only.
+- **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears.
+- **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`.
+
+## The client carrier: the AbstractApiClient class family (`fetch/client.ts`)
+
+**Protocol invariants live in the base class; platform differences are two aspects**: the abstract method `doFetch(url, init)` (transport) + the overridable `onEnvelope` (observation).
+
+### IApiClient: the caller view
+
+The same domain tree as `ApiProxy`, but unary methods **take the business payload directly** — the carrier mints the rpcId and wraps the envelope; business code never mints, and code needing this call's rpcId reads it from the returned `RpcResponse` echo. `ApiProxy` is the narrow-form signature contract the impl side implements; `IApiClient` is the payload-direct view clients consume; `AbstractApiClient` bridges the two. Methods derive per key from `RpcMethodMap` — a map row addition updates them mechanically.
+
+### Protocol paths held by the base class
+
+| Path | Content |
+|---|---|
+| `callUnary` | mint → tap → POST full form → `serverResponseSchema` parse → **rpcId echo check** (mismatch throws) → tap → emit narrow form |
+| `readSse` | streaming fetch (not EventSource), `\n\n` framing, `data:` concatenation, ServerRequest full-form parse, tap, emit narrow `RpcRequest<frame>` |
+| `respond` | client-response passthrough (rpcId is an echo — never minted here); response body parsed by `rpcReceiptSchema` |
+| unary timeout | `AbortSignal.timeout` (default 30s, constructor-tunable); streams have no timeout (long-lived by nature) |
+| `resolveBase` | browser = same-origin origin; no-location environment (Node) = the `http://dsh.internal` fake authority |
+
+### The instance-level envelope observation aspect
+
+All four quadrant full forms pass through `onEnvelope`; the base implementation is an **instance-owned microtask-batched buffer** (frame storms must not disturb consumers per frame; module-level state would leak across instances/tests, hence instance-owned). Observers subscribe via `subscribeEnvelopes(listener)` (receiving whole batches as `readonly RpcMessage[]`, returning an unsubscribe function); a listener throw is isolated (observation must never bite the carrier). With no subscribers the buffering costs nothing. No shipped consumer subscribes today — the aspect is the designated seat for wire diagnostics (the retired RPC debug panel was its first consumer, and a future one plugs in without touching the carrier).
+
+### The subclass table (transport carriage)
+
+| Subclass | Package | doFetch | Purpose |
+|---|---|---|---|
+| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer |
+| `WebApiClient` | dsh-client-connection | `globalThis.fetch` (same-origin `/api/*`) | the browser shape; HTTP+SSE carriage details in the web client architecture RFC |
+| `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) |
+| (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged |
+
+## How to extend (operational checklists)
+
+**Add a unary method (5 steps)**: ① add the method signature to the domain interface (parameters/return inline — this is the single source of truth); ② add one `RpcMethodMap` row; ③ add the request/value schema pair in `<domain>.schema.ts` (anchored `Wire<RequestPayload<'…'>>`); ④ add one handler `UNARY_ROUTES` row (the handler's Web carriage is in the web client architecture RFC); ⑤ implement in the impl (echo `request.rpcId`). On the client side, add the passthrough row to the `IApiClient`/`AbstractApiClient` domain method tables.
+
+**Add a frame type (3 steps)**: ① add a branch to the `MuxFrame`/`HostFrame` union (answerable frames must note the stable-rpcId semantics); ② add a frame-schema branch; ③ the consumers' fold/routing documented-default already covers unknown types — add an explicit branch as needed.
+
+**Add an error code (2 steps)**: ① add one `RpcErrorDetailsMap` row (details required); ② add one `rpcErrorSchema` discriminatedUnion branch.
+
+**Plug in a new carrier**: subclass `AbstractApiClient` implementing only `doFetch`; to intercept at the protocol layer (like the fixture), override the `callUnary`/`openMux`/`openHost` virtuals instead. Contract and base class stay unchanged.
+
+**Promote a reserved seam**: copy the reserved signature into the domain interface → add the map row → add the schema pair → add the UNARY_ROUTES row → implement.
+
+## Consequences
+
+Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. The accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives.
+
+## Alternatives considered
+
+| Rejected | One-line reason |
+|---|---|
+| Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages |
+| A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable |
+| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | A second command plane bypasses the contract, losing wire validation/observability/multi-client consistency; ctx keeps exactly two formal uses — front doors and headless event subscription |
+| webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer |
+| Package names without the group prefix (continuing dsh-<tail>) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package |
+| Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention |
+| A three-envelope model (Request/Response/Frame envelopes, signatures direction-blind) | rpcId correlation is logical-layer; frame and response direction semantics inferred from the channel break the moment the carrier changes |
+| Named Request/Response type pairs as the source of truth (map registering type pairs) | Flat named types are a second name for the same fact; signature inference makes adding a method a one-place change |
+| REST-style paths | The consumer is our own client with no third-party REST expectations; RPC mapping straight onto the method table is more mechanical |
+| A DTO layer (a second wire-only structure set) | Core types reach the browser type-only at zero cost; a DTO is a permanent two-way synchronization tax |
+| Cursor resumption (implementing mux since) | Reconnect = rebuild (opencode-style) covers all v1 needs; the signature keeps the seat, implementation waits for a real consumer |
+| A createApiClient factory function (the original implementation) | Platform differences (transport/observation) are inheritance aspects, not parameters; the class family lets the fixture substitute at the protocol layer instead of wrapping a fake envelope |

+ 251 - 0
.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md

@@ -0,0 +1,251 @@
+# RFC: GUI 分层与 RPC 协议——host/client 按能力支持方分层、四象限消息模型与 fetch 载体
+
+Status: implemented
+
+[English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文
+
+> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现(HTTP+SSE)见 [Web 客户端架构 RFC](2026-07-19-gui-web-client-architecture.md)。
+
+## Problem
+
+需要提供 UI 对接层,除已有 ACP/stdio基础版本外,还需要 Web(server) 、 Electron 、等其他产品 UI 形态。我们把这些形态统一称为 Client。希望有如下能力支持:
+- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh -p`(headless) ,一个进程两种模式(设计预留)
+- 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动
+
+那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。
+
+同时各消费端的物理通道不同(HTTP/SSE、进程内直调、将来 IPC),还需要一个通道无关的消息模型和单一契约事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。
+
+## Decision
+
+### 分层
+
+目录按照如下分层:
+- `packages/host/*`: 包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含
+    - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节
+- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住两类包:
+    - **纯库**(`ui-slots`、`web-react`、`ui-primitives`):普通根入口包,静态打包进壳,并播种进浏览器插件 loader 的模块表。
+    - **dshClient 插件包**(`connection`、`runtime`、`ui-theme`、`i18n`、`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现与类型全部住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle),跨包消费一律 import `/client` 形式。`runtime` 额外导出 `./loader`(壳持有的浏览器 bundle loader——loader 加载不了自己)。
+- `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。
+    - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。
+    - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh -p` = headless 进程内直调,零 HTTP。
+    - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。
+
+```
+apps/*  (application shapes: apps/web = vite app, apps/cli = bin dispatch)
+  │ consume
+  ▼
+packages/host/*                      packages/client/*
+  apiproxy   front layer: protocol     pure libs: ui-slots / web-react / ui-primitives
+  runtime    assembly / host entity    dshClient plugins ×8 (node half = empty apply,
+  webserver  web-shape HTTP carriage                        client half = src/client/)
+  │ ctx.plugin(...)                      ▲ import only apiproxy's /api /client subpaths
+  ▼                                      │ (type-only + the client base class)
+harness core packages ──────────────────┘ (types reach the browser via import type)
+```
+
+方向纪律(每条都由包 deps 可核):
+
+- `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。
+- client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。
+- `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。
+- client 侧跨包 import 插件包一律走 `/client` 子路径(裸包名会把第二份运行时实例内联进浏览器 bundle;tsdown 纯度门禁会改写或拒收)。
+
+TypeScript 以**两个聚合 program** 检查(`tsconfig.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用。
+
+协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。
+
+#### 分层角色
+
+| 层 | 包 | 职责 | 关键纪律 |
+|---|---|---|---|
+| 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api |
+| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 |
+| 承载层 | `dsh-host-webserver` | Web 形态 HTTP:静态服务 + `/api/*`→handler 转发 + SSE 写出 + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 |
+| client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 |
+| client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构 RFC | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy |
+| 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app |
+
+#### 命名规则
+
+`packages/host/*` 与 `packages/client/*` 下的包名**必须含目录组前缀**:host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀(host/ 已表达)。因此包名尾段 ≠ 目录名,tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且插件包的 `/client`(以及 runtime 的 `/loader`)子路径要单列条目,使源码级解析与 exports map 一致。
+
+#### 怎么接入一个新形态(操作清单)
+
+1. **选 fetch 伪造方式**:浏览器同源 HTTP / 进程内 `host.handler.fetch` 注入 / 自写传输切面子类(如将来 Electron IPC,见下文「子类表」)。
+2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。
+3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。
+
+现有两形态即模板:`apps/cli/src/web.ts`(startHost + dist 定位 + startWebServer + 信号停机)与 `headless.ts`(startHost + InProcessApiClient 同构直调,零 HTTP 零端口)。ACP 类协议桥不走本清单:它把 core 暴露给外部生态,直接 `ctx.plugin(前门插件)` 挂载、不套 fetch。
+
+## 消息协议
+
+以下各节是前置层(`dsh-host-apiproxy`)承载的协议本体。wire 上只有四种消息(四象限)——右列的 Web 承载只是示例,换载体(进程内/IPC)时四象限不变:
+
+```
+                 client 发起                      server 发起
+  request   ① ClientRequest                 ③ ServerRequest
+            (POST /api/<method> body)      (SSE 帧:session 事件、审批/问答 requested)
+  response  ② ServerResponse                ④ ClientResponse
+            (该 POST 的 HTTP 应答体)        (POST /api/respond body,回填 ③ 的 rpcId)
+```
+
+### wire 全形:四具名判别 union(`api/rpc.ts`)
+
+| 类型 | 判别 tag | 字段 | rpcId 归属 | Web 承载 |
+|---|---|---|---|---|
+| `ClientRequest` | `'client-request'` | `rpcId` `method` `payload` | client mint | `POST /api/<method>` body |
+| `ServerResponse` | `'server-response'` | `rpcId` `result` | 回填 ① | 该 POST 的应答体(恒 HTTP 200) |
+| `ServerRequest` | `'server-request'` | `rpcId` `method` `payload` | server mint | SSE `data:` 行 |
+| `ClientResponse` | `'client-response'` | `rpcId` `result` | 回填 ③ | `POST /api/respond` body |
+
+`RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse`,`switch (message.type)` 窄化。
+
+**rpcId 纪律**(`RpcId` 是 branded string,构造函数 `RpcId()`):
+
+- 谁发起谁 mint;应答一律回填对应 request 的 rpcId,**绝不 mint 新 id**。
+- server-request 分两类,静态按 `method`(=帧 type)区分,**不设第三种 kind**:可应答帧(`approval/requested`、`question/requested`)的 rpcId 是稳定逻辑请求 id(受理时 mint 一次、基线重放原样复用、client 以它回填应答);纯推送帧(`session/event` 等)的 rpcId 标识该次推送(每次新 mint)。
+- 业务代码不 mint:unary 的 mint 收口在客户端基类 `callUnary`,帧的 mint 收口在 host 侧。
+
+### 签名窄形与载体补全
+
+域接口签名只感知窄形:`RpcRequest<P> = { rpcId, payload }`、`RpcResponse<T> = { rpcId, result: RpcResult<T> }`。载体层把窄形补全为全形(补 `type` tag 与 `method`),方向不靠通道推断。`RpcResult<T> = { ok: true; value } | { ok: false; error: RpcError }`——方法不 throw 业务错误。
+
+### RpcReceipt:载体回执
+
+`ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessage(response 不再有 response);迟到/重复应答收 `not-pending`,逻辑收敛面是 `*/resolved` 帧。
+
+## 类型体系:函数签名即事实源
+
+### RpcMethodMap 与派生泛型(`api/rpc-map.ts`)
+
+方法的参数/返回结构**只住在接口方法签名里**;map 登记方法本身;其余一切位置(handler、client、store、测试)引用派生泛型,禁止复写字面量或另起平铺具名类型:
+
+```ts ignore-check
+export interface RpcMethodMap {
+  'session.list': SessionsApi['list']        // map key 即 wire 路径段
+  // …其余方法同形登记,全集见 api/rpc-map.ts
+}
+// 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束)
+export type RequestPayload<K> = Parameters<RpcMethodMap[K]>[0]['payload']
+export type ResponseValue<K> =
+  Awaited<ReturnType<RpcMethodMap[K]>> extends RpcResponse<infer T> ? T : never
+```
+
+流方法(`events.mux`/`events.host`)不进 map(不是 unary);`respond` 不进 map(是 client-response 不是方法调用)。
+
+### 错误模型(`RpcErrorDetailsMap`)
+
+错误码示例一行:
+
+| code | details | 何时 |
+|---|---|---|
+| `bad-request` | `{ issues: ZodIssue[] }` | wire/payload zod 校验失败 |
+
+码全集见 `api/rpc.ts` 的 `RpcErrorDetailsMap`。`RpcError` 是 map 展开的分布式 union:`code` 判别、`switch` 后 `details` 自动窄化;**details 必填**——新码=map 加一行+错误 schema 加一支,漏填是编译错误。transport 故障(断网、host 没起)由载体抛异常,与业务错误两层不混。
+
+### zod 双向校验与锚定
+
+- **两级 parse**:全形 schema 一次(type/rpcId/method 结构 + handler 校验 path==method)→ 业务 payload 按 method/帧型分派二次 parse;拒收 = `bad-request`。
+- **锚定**:schema 统一 `satisfies z.ZodType<Wire<T>>`(`api/rpc.schema.ts`)。`Wire<T>` 是深度「| undefined」宽化——仓库开 `exactOptionalPropertyTypes` 而 zod `.optional()` 输出 `T | undefined`,直接锚原类型全线不可用;JSON wire 上缺席与 undefined 同形,宽化不损失校验语义。透传宽分支(`SessionEvent`/`ContentBlock`/帧 union/`RpcError`)与 brand id schema 用显式 cast + 注释。
+- brand cast 单点:每个 schema 文件的 id cast 收口一处(`rpcIdSchema` 是 rpc.schema.ts 唯一 cast 点)。
+
+## 契约面(ApiProxy)
+
+根接口 `ApiProxy = { sessions, host, events, respond }`(`api/index.ts`)。新 client-request 域 = 新的一对文件(`<域>.ts` + `<域>.schema.ts`)+ 根接口一个字段 + map 加行。
+
+### unary 方法表
+
+方法示例一行(表结构即读法):
+
+| method key | 请求 payload | 返回 value | 语义 |
+|---|---|---|---|
+| `session.list` | `{ cursor?: string }`(cursor 留座不实现) | `{ items: SessionSummary[] }` | 已持久化 session,updatedAt 倒序;v1 不建索引 |
+
+其余方法(`session.create`/`session.history`/`session.prompt`/`session.cancel`/`host.describe`)的参数与返回不在此复写——签名即事实源,见 `api/sessions.ts`、`api/host.ts` 与 `RpcMethodMap`。
+
+### 帧(server→client,具名 union)
+
+两条 SSE 流:mux 流(`GET /api/events.mux`,全 session 聚合)与 host 流(`GET /api/events.host`,host 级事件)。帧示例一行:
+
+| 帧 type | 载荷 | 何时发 |
+|---|---|---|
+| `session/event` | `{ sessionId; event: SessionEvent }` | 核心透传:core 事件原样过,`assistant/chunk` 即 token 流,无独立 delta 帧 |
+
+其余帧型不在此复写,union 全集见 `api/events.ts` 的 `MuxFrame`/`HostFrame`。语义上须知三点:`session/subscribed` 的 lastSeq 供 history 补缝竞态检测;`approval/question` 的 requested 帧可应答(rpcId 稳定)、resolved 帧是收敛面;`host/agent-error` 是无 turn 位置 live 失败的唯一出口。
+
+**透传纪律**:wire 上的事件/消息/内容块就是 core 类型(`SessionEvent`/`ContentBlock`),不造第二套 DTO;类型经 `import type` 依赖链直达浏览器。`SessionEventMap` merge-extensible:client 对未知 type documented-default(忽略),事件 schema 留「合法信封+未知类型」分支——信封仍严格,不是字段级 passthrough。
+
+### 会话语义(impl 侧承诺)
+
+- **历史 = 事件重放**:一套 fold(client 侧),历史分页与 live 增量同一条代码路径;server 不做物化快照第二套。history **页边界对齐消息边界**(绝不从消息中间截断;chunk 随定稿消息归组),尾页含进行中 partial 的 chunk。
+- **prompt 关联**:prompt 的 rpcId 经 MessageSource(`'user-rpc'`)透传进 `user/message` 事件,client 以此把乐观回显转正。
+- **重连 = 重建**:不做续传 cursor(`mux` 的 `since` 签名留座、传了忽略);断线重开流 + 重拉 history;`subscribed.lastSeq` 与 history 尾 seq 比对,有缝再补拉一次。
+- **冷 session 隐式 resume**:`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume,并发触发用在途表去重;attach 与否不对客暴露(`running` 已覆盖)。
+- **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。
+- **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。
+- **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。
+
+## 客户端载体:AbstractApiClient 类体系(`fetch/client.ts`)
+
+**协议不变量住基类,平台差异是两个切面**:抽象方法 `doFetch(url, init)`(传输)+ 可覆写 `onEnvelope`(观测)。
+
+### IApiClient:caller 视图
+
+与 `ApiProxy` 同域树,但 unary 方法**收业务 payload 直传**——载体 mint rpcId 并包信封,业务代码永不 mint;需要本次调用 rpcId 的从返回的 `RpcResponse` 回显里读。`ApiProxy` 是 impl 侧实现的窄形签名契约,`IApiClient` 是 client 侧消费的 payload 直传视图,`AbstractApiClient` 桥接两者。方法逐 key 从 `RpcMethodMap` 派生——map 加行即机械更新。
+
+### 基类持有的协议路径
+
+| 路径 | 内容 |
+|---|---|
+| `callUnary` | mint → tap → POST 全形 → `serverResponseSchema` parse → **rpcId 回显校验**(不符即 throw)→ tap → 吐窄形 |
+| `readSse` | streaming fetch(非 EventSource)、`\n\n` 分帧、`data:` 拼接、ServerRequest 全形 parse、tap、吐窄形 `RpcRequest<帧>` |
+| `respond` | client-response 透传(rpcId 是回填,此处不 mint);应答体 `rpcReceiptSchema` parse |
+| unary 超时 | `AbortSignal.timeout`(默认 30s,构造参数可调);流不设超时(长连接本性) |
+| `resolveBase` | 浏览器=同源 origin;无 location 环境(Node)=`http://dsh.internal` 假 authority |
+
+### 实例级 envelope 观测切面
+
+四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费者;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`,返回退订函数);listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。当前没有任何现役消费者订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费者,将来的诊断消费者接入时不动载体)。
+
+### 子类表(传输承载)
+
+| 子类 | 所在包 | doFetch | 用途 |
+|---|---|---|---|
+| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 |
+| `WebApiClient` | dsh-client-connection | `globalThis.fetch`(同源 `/api/*`) | 浏览器形态;HTTP+SSE 承载落地见 Web 客户端架构 RFC |
+| `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) |
+| (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 |
+
+## 怎么扩展(操作清单)
+
+**加一个 unary 方法(5 步)**:①域接口加方法签名(参数/返回内联,这是唯一事实源);②`RpcMethodMap` 加一行;③`<域>.schema.ts` 加 request/value schema 对(锚 `Wire<RequestPayload<'…'>>`);④handler `UNARY_ROUTES` 加一行(handler 的 Web 承载见 Web 客户端架构 RFC);⑤impl 实现(回显 `request.rpcId`)。client 侧 `IApiClient`/`AbstractApiClient` 的域方法表同步加一行透传。
+
+**加一个帧型(3 步)**:①`MuxFrame`/`HostFrame` union 加一支(可应答帧须注明 rpcId 稳定语义);②帧 schema 加一支;③消费端 fold/路由的 documented-default 已兜底未知型,按需加显式分支。
+
+**加一个错误码(2 步)**:①`RpcErrorDetailsMap` 加一行(details 必填);②`rpcErrorSchema` discriminatedUnion 加一支。
+
+**接一种新载体**:继承 `AbstractApiClient` 只实现 `doFetch`;需要拦截协议层(如 fixture)再覆写 `callUnary`/`openMux`/`openHost` 虚方法。契约与基类零改。
+
+**升格一个预留接缝**:把预留签名抄进域接口 → map 加行 → schema 加对 → UNARY_ROUTES 加行 → impl 实现。
+
+## Consequences
+
+所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。接受的代价:两组包需要显式 tsconfig paths 条目;预留接缝(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。
+
+## Alternatives considered
+
+| 放弃项 | 一句话理由 |
+|---|---|
+| 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 |
+| 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 |
+| 消费型 client 直连 ctx(省 apiproxy 一层) | 第二命令面绕开契约,wire 校验/观测/多端一致性全失;ctx 只留给前门与 headless 事件订阅两个正式用途 |
+| webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 |
+| 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths |
+| 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、契约双份人肉对齐、命名无 convention 自然漂移 |
+| 三信封模型(Request/Response/Frame 各一信封,签名不感知方向) | rpcId 是逻辑层关联,帧与应答的方向语义靠通道推断在换载体时即失效 |
+| 具名 Request/Response 类型对为事实源(map 登记类型对) | 平铺具名类型是同一事实的第二个名字;签名 infer 反推让加方法只改一处 |
+| REST 风格路径 | 消费者是自家 client,无第三方 REST 体验诉求;RPC 直映方法表更机械 |
+| DTO 层(wire 专用第二套结构) | core 类型 type-only 直达浏览器零成本;DTO 是永久的双向同步税 |
+| cursor 续传(mux since 实装) | 重连=重建(opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费者 |
+| createApiClient 工厂函数(原实现) | 平台差异(传输/观测)是继承切面不是参数;类体系让 fixture 在协议层替换而不是包一层假信封 |

+ 6 - 0
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml

@@ -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
+2026-07-19-gui-web-client-architecture.md: 58320570f752d4259004172d3b4527172c2cc646
+2026-07-19-gui-web-client-architecture.zh.md: 744fdaa4b89a01e2710f85b177228713189e3025

+ 148 - 0
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md

@@ -0,0 +1,148 @@
+# Agent Note: Web client architecture — the client cordis plugin tree, the slot system, and the React-free object layer
+
+Status: implemented
+
+English | [中文](2026-07-19-gui-web-client-architecture.zh.md)
+
+> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol RFC](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots.
+
+## Problem
+
+Two forces shape the browser client. First, streaming: in an event-driven conversation UI, if business state (the event window, streaming accumulation, pending interactions, the connection state machine) scatters across React components and a global store, every token chunk shakes the render tree, and swapping the UI library means rewriting the business logic. Second, modularity: UI features (layout, sidebar, conversation, theme, locale) must be independently loadable plugins — composed at runtime from a host-served manifest, not compiled into one bundle — without giving up compile-time type safety across plugin boundaries.
+
+## Decision
+
+Both ends run cordis. The host is a cordis plugin tree; the browser runs a second, client-side cordis tree whose every UI capability is a plugin loaded dynamically by a shell-held loader. Inside that tree, cordis ctx hosts all runtime facts (services, stores, session scopes) and React is pure projection: components import nothing from the framework, receive everything through props, and subscribe to immutable snapshots via `useSyncExternalStore` (uSES below).
+
+```
+┌─ Host ─────────────────────────┐   ┌─ Browser ─────────────────────────────────────────┐
+│ sessions/agents/SessionLog     │   │ client cordis root ctx                             │
+│ apiproxy: RPC + mux/host 双流  │◀─▶│  ├ loader(壳静态持有,不能经自己装载)             │
+│ webserver:                     │   │  ├ immediately 先行组: connection/runtime/         │
+│  ├ GET /plugins/<id>/client.js │   │  │   ui-theme/i18n(动态 bundle,并行先装)        │
+│  └ GET / 注入 __DSH_BOOT__     │   │  ├ 后续组: layout/sidebar/conversation/trajectory  │
+└────────────────────────────────┘   │  └ session scope ×N(观看驱动,惰性建)            │
+                                     │ React: loading 页 → settled → 整 UI 一次成型       │
+                                     └────────────────────────────────────────────────────┘
+```
+
+## The client cordis tree and the loading chain
+
+Every UI plugin is simultaneously a host plugin (dual-entry package): the node half sits in the host's plugin tree so the host Loader governs its lifecycle, and the browser half is a tsdown closure bundle under the package's `exports["./client"]`. The host webserver derives the boot manifest from loaded plugins carrying a `dshClient` manifest field and injects it into the page as `window.__DSH_BOOT__` — the HTML alone tells the browser everything to fetch, zero extra round trips.
+
+The loading chain, end to end:
+
+1. `GET /` → the shell boots, mounts `ctx.loader` (the loader mechanism is held statically by the shell — a loader cannot load itself; its code home is `packages/client/runtime/src/client/loader/`, imported through the `./loader` subpath so the shell bundle does not swallow the rest of the runtime package), seeds the require module table with the pure-library instances (react, react-dom, cordis, ui-slots, web-react, ui-primitives), and renders a plugin-independent loading page.
+2. `loader.start()` reads `__DSH_BOOT__`. Entries flagged `immediately` form the early-load group (connection, runtime, ui-theme, i18n): fetched in parallel, applied in intra-group `inject` topological order, and **the whole group must land before anything else loads**. Remaining plugins then load in inject order.
+3. Each bundle executes `window.DSHClientProxy.loadPlugin({ id, factory })`. The loader calls `factory(require)` — bundles are closure factories whose external dependencies arrive through the injected `require`, resolved against the module table (no globals, no import maps; an unresolvable specifier fails loud). The factory returns its module export surface (including the cordis `apply`); the loader runs `ctx.plugin(apply)`, then **registers that export surface into the module table under the package name**, so inject topology guarantees later plugins can `require` earlier ones. Plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` (CSS Modules hashing + ownership tag = isolation).
+4. `await loader.settled()` → the shell flips from the loading page to the real UI in one pass. A single failed plugin fails loud on the loading page; there is no partial-availability mode (progressive rendering is deferred work).
+
+**The dual-instance ban**: a module-table package inlined into a plugin bundle would duplicate runtime identity (two React copies, two store registries — the root cause of an actual white-screen P0). The tsdown client preset enforces purity at build time: a bare-name import of a module-table package must resolve external (rewritten to its `/client` form where applicable), and any other workspace leak that is not an inline-safe wire/type layer fails the build (`packages/client/tsdown.client.ts`, pinned by `scripts/client-bundle-purity.spec.ts`).
+
+Dev equals prod: plugins rebuild under `tsdown --watch`, refresh reloads the same chain; vite serves only the shell (`apps/web`). Type universes stay split at the aggregate level — the root `tsconfig.json` is the host program, `tsconfig.client.json` the client program, because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program.
+
+## The slot system: how the page composes
+
+A page is a tree of slots; whoever owns a region declares its slots. Contracts live in one place — the `SlotMap` interface in `@deepseek-ai/dsh-client-ui-slots`, extended by declaration merging. An entry declares the slot's axes and the **owner share** only; the registrant's injected props never enter the global table ("whoever injects it, owns its type"):
+
+```ts ignore-check
+declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap {
+  sidebar:      { kind: 'single'; scope: 'root';    owner: SidebarOwnerProps }
+  conversation: { kind: 'single'; scope: 'session'; owner: ConvOwnerProps; children: 'conversation.empty' }
+} }
+ctx.slots.define('sidebar', { kind: 'single', scope: 'root' })   // declare=类型,define=落账
+ctx.slots.register('sidebar', SidebarRoot, { inject: (b) => ({ /* ... */ }) })
+```
+
+- Three kinds: `single` (duplicate registration throws), `list` (id/order), `keyed` (runtime dispatch, duplicate key throws). Register before define throws. Two scopes: `root` (no session context) and `session` — the scope decides the injection shape below.
+- **Full component props are composed by reference, never re-typed**: a registrant's component declares `OwnerOf<K> & StandardOf<K> & OwnInjected` — the owner share referenced from the slot owner's package, the standard share supplied by the framework (session slots: `useSession`), and the registrant's own injected share declared locally next to the component. `register<K, I>` enforces the composition at the call site: the component parameter is `SlotComponent<ComposedProps<K, NoInfer<I>>>` (a bare call signature, not `FC` — FC's `propTypes` static position generates contravariance noise against the standard share), and `I` is inferred exclusively from the inject factory's return type (`NoInfer` pins it), so a drifted component or a mismatched factory is a compile error at the registration point. In ui-conversation the injected shares live in `src/client/contract/slots.ts` (`ConversationInjected` and kin) and each skeleton component's props is a one-line reference composition.
+- **Delegation is a hand-written whitelist with an optional declared ceiling**: an owner component receives a whitelist-narrowed `slots: ScopedSlots<'a' | 'b'>` through its own props and calls `slots.renderSlot(key, props)`; passing a narrowed subset to a child goes through `narrowSlots` (pure type covariance). Overreach is a compile error, and the runtime whitelist backstops plain-JS callers. An entry may additionally declare `children: <key>` — register then validates the component's whitelist ⊆ the declared ceiling (opt-in visibility layer, not mandatory). Every rendered entry is wrapped in a per-entry error boundary: a crashing registrant (component or inject factory) blacks out only its own entry, while assembly errors (missing providers) rethrow — a miswired shell fails loud instead of degrading.
+- **Props merge from three sources** (the outlet does it; owners write only the first): ① owner-supplied props (identity, display parameters, frozen slices) — typed as the entry's owner share, exact at the renderSlot point; ② scope-standard injection — session slots automatically receive `useSession` bound to the right Session; ③ the registrant's `inject` factory, called once per (entry × session) for session slots and once per entry for root slots, cached in WeakMaps so a session switch-back reuses the cached result. Inject factories receive the assembly handle (`SessionBinding { sessionId, session, ctx }` or `RootBinding { ctx }`) — an apply-world object that never enters React.
+- Two supply channels close the loop: `RootBindingProvider` (mounted once by the shell) feeds root-slot inject factories their ctx; `createSessionProvider(deps)` builds the single session provider — dependency-inverted (`useCurrent` / `resolveBinding` / `renderBody`), so web-react never imports the runtime. It subscribes to the current session id, resolves a reference-stable binding, remounts its body under `key={id}`, and delegates body rendering to the assembler's `renderBody` closure (slot ownership stays with layout; the provider knows no slot names).
+
+Implementation homes: registry core in `packages/client/ui-slots` (zero dependencies), outlet/providers/uSES bridge in `packages/client/web-react`.
+
+## Services and scope addressing
+
+A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-map merges). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`), `ctx.sessions` (list store, scope tree, bindings), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (navigation + panel viewing state), `ctx.conversation` (send/cancel/selection/views/startSession), `ctx.toolviews` (named per-tool render registry with per-session scope filters).
+
+Beyond SlotMap, two more typed registration rings follow the same declare-merge idiom: the **view ring** (`ConversationViewMap` — an entry may declare `chromeProps`/`extraProps` extension shapes; `ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` compose base + extension, so a view with no declaration gets the base for free while ui-trajectory's entries carry real per-view props) and the **tool ring** (tool names stay an open set — no global key table; typing hardens inside the entry: `ToolViewProps.block` is the real `ToolCallBlock` union defined in runtime, and register infers the registrant's injected share like slots do).
+
+**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
+
+## The data object layer (`packages/client/runtime/src/client/sessions/`)
+
+Frames enter, snapshots exit, the fold sits between — React-free (zero React imports, grep-assertable):
+
+```
+mux/host 帧(ConnectionController 泵入,sinks 注入)
+        │
+        ▼
+SessionManager.handleMuxEnvelope / handleHostEnvelope
+        │ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲)
+        ▼
+Session.handleMuxEnvelope ──► events 窗口(seq 连续升序)
+        │                        │ 定稿事件            │ chunk
+        │                        ▼                    ▼
+        │                   FoldAdapter        PartialAccumulator
+        │                  (→ nodes)          (→ partial)
+        ▼
+Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
+```
+
+- **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot<ConversationSnapshot>`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental fold; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail.
+- **ConversationSnapshot** (conversation.ts): the immutable snapshot contract — `nodes` (folded, surface-ordered), `partial`, `runningCalls`, `pending`, `running`, `removed`, `openState`, `hasMore`, `promptError` and kin. **Reference discipline** (the premise of memo and uSES): the top-level object is fresh on every change; the nodes array is rebuilt but element references come from the cache; unchanged substructures reuse the previous snapshot's references.
+- **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation.
+- **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned.
+- **FoldAdapter / PartialAccumulator**: the fold reuses the core SurfaceManager (`@deepseek-ai/dsh-session/surface`), padding sentinel events so a paged window starting at seq > 0 satisfies the core's `seq === index` assertion; a cross-window replace degrades to a tolerant linear scan and sets `foldDegraded`. Chunks stay out of the fold entirely (O(1) skip): the accumulator folds StreamChunks into `AssistantBlock[]`, a delta swapping only that block's reference, and the finalizing message discards the accumulator in the same batch (no flicker on promotion). Cost model: one chunk = one string concatenation + a dirty mark; an unsubscribed Session under a frame storm costs only the mark.
+- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; the Web carriage (HTTP POST for the two client→server quadrants, SSE for the two server→client) and the client class family are the layering RFC's territory.
+
+## The React face (`packages/client/web-react`)
+
+The glue package is the whole ctx↔React boundary; components stay framework-free.
+
+- `createSnapshotStore<T>(init, opts)`: the store engine for plugin-owned data and shell viewing state — zustand vanilla with draft-based updates, `flush: 'sync'` by default (controlled inputs need same-tick echo) with opt-in `'raf'` batching for frame-driven stores, opt-in whole-value localStorage persistence, dev-mode deep freeze. Both a Session object and a snapshot store satisfy the one data contract React consumes: `ObservableSnapshot<T>` (`getSnapshot`/`subscribe`).
+- `bindSnapshotSelector(source)`: binds a source into a typed selector hook over uSES-with-selector. The four uSES contract clauses hold by construction: getSnapshot returns the cached reference; subscribe is a bind-time closure (reference-stable forever); pure CSR passes no server snapshot; equality defaults to `Object.is` with `shallowEqual` opt-in per call.
+- `useInvoke(fn)`: wraps an async action into a stable trigger plus pending flag; pending rides a per-hook external store read through uSES (no setState on the render path), concurrent invocations are counted, and the invoke reference never changes.
+- Equality protocol, whole chain: producers use structural sharing; consumers short-circuit with `Object.is` or `shallowEqual`; `React.memo` shallow. Deep comparison is banned everywhere.
+
+## Directory shape
+
+Twelve `packages/client/*` packages (ui-slots, ui-primitives, web-react, connection, runtime, ui-layout, ui-sidebar, ui-conversation, ui-trajectory, ui-theme, i18n, web) plus `apps/web` — the vite application, a thin `main` over the shell's boot export. Plugin packages keep their browser half under `src/client/`; **every build artifact lands in `lib/`** — the node half as `lib/index.js`/`lib/invariant.js`, the browser bundle as `lib/client.js` (the shared tsdown client preset emits both; there is no `dist/` directory, and `exports["./client"]` points at `./lib/client.js`). Dependency direction: `ui-slots ← web-react ← runtime ← ui-* (peers) ← web`, with ui-primitives/ui-theme/i18n as zero-dependency side paths.
+
+A multi-domain plugin package additionally splits its client half by future package boundaries — ui-conversation is the exemplar:
+
+```
+src/client/
+  contract/    the only shared face between domains (types + composed props shares)
+  service.ts   cross-domain orchestration (imports contract only)
+  skeleton/    domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
+  chat/        domain: the chat view
+  toolviews/   domain: the tool-row registry and samples
+  apply.ts     the ONLY file allowed to import across domains (assembly point)
+  index.ts     thin re-export shell (contract + apply + components)
+```
+
+Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. chat consumes the tool registry through a `ToolViewResolver` read-face interface, not the registry class). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths.
+
+## How to develop
+
+- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores, registers slots and toolviews), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
+- **A new slot**: merge the contract into `SlotMap`, `define` at the owner, render through the owner's own `ScopedSlots` whitelist; registrants `register` with an optional inject factory. Never export components globally.
+- **Consuming a new frame type**: sessionId-bearing → a branch in Session's dispatch switch; host-level → the Manager routing table; if the UI needs it, a `ConversationSnapshot` field with the reference discipline kept.
+- **Where does this state live**: per-session and must survive switches → the Session object / scope-mounted store; private to one view (selection, scroll) → component state; shell viewing state (navigation, panel widths, preferences) → `ctx.layout`'s stores; business data → always the object layer, never a viewing-state store.
+- **Notification channel**: frame-driven/async = `markDirty` batching; direct user-gesture echo whose controlled input needs the same tick = `notifyNow`.
+
+## Consequences
+
+Token streams no longer shake the render tree: a frame storm costs unsubscribed sessions one dirty bit and the subscribed view one batched re-render per microtask (raf-batched for frame-driven stores). UI features load, fail, and get disabled as independent plugins — one crashing slot entry blacks out one card, one failed bundle fails loud before the UI flips in. The accepted costs: the loader/module-table machinery is bespoke infrastructure the team owns end to end; the one-flip boot (no progressive rendering) trades first-paint granularity for assembly simplicity; and the dual type programs make "which aggregate sees this file" a question developers occasionally have to answer.
+
+## Alternatives considered
+
+| Rejected | One-line reason |
+|---|---|
+| One statically-linked SPA bundle | Plugins must be host-composable at runtime (config-driven); a monolith re-couples every UI feature to one build |
+| window globals / import maps for shared deps | The DI require table keeps sharing explicit, fail-loud, and swappable; globals leak identity and version silently |
+| Business data in zustand slices | The event window/accumulator is a behavioral state machine, not a flat slice; the object layer keeps snapshot granularity and batching controllable |
+| String-keyed global component registry for tool rows | Tool views are consumed by multiple views and need per-session differentiation — a named service (`ctx.toolviews`) with scope filters is the honest shape |
+| Progressive/Suspense boot in P-I | One-flip boot is strictly simpler; the loader's per-plugin status face is kept so progressive lighting can land later without re-architecture |

+ 148 - 0
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md

@@ -0,0 +1,148 @@
+# RFC: Web 客户端架构——client cordis 插件树、slot 体系与 React-free 对象层
+
+Status: implemented
+
+[English](2026-07-19-gui-web-client-architecture.md) | 中文
+
+> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/契约面/客户端基类)见 [分层与 RPC 协议 RFC](2026-07-19-gui-layering-and-rpc-protocol.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。
+
+## Problem
+
+浏览器客户端受两股力塑形。其一是流式:事件驱动的对话 UI 里,若业务状态(事件窗口、流式累积、待答交互、连接状态机)散落在 React 组件与全局 store 中,每个 token 分片都会震荡渲染树,且换 UI 库等于重写业务逻辑。其二是模块化:UI 功能(布局、侧栏、对话、主题、语言包)必须是可独立装载的插件——按 host 下发的 manifest(元数据清单)在运行时组合,而非编译进单一 bundle——同时不放弃跨插件边界的编译期类型安全。
+
+## Decision
+
+两端都跑 cordis。host 是一棵 cordis 插件树;浏览器里跑第二棵 client 侧 cordis 树,其中每一项 UI 能力都是插件,由壳静态持有的 loader 动态装载。树内 cordis ctx 承载一切运行时事实(服务、store、会话 scope),React 是纯投影:组件对框架零 import,一切经 props 注入,经 `useSyncExternalStore`(下称 uSES)订阅不可变快照。
+
+```
+┌─ Host ─────────────────────────┐   ┌─ Browser ─────────────────────────────────────────┐
+│ sessions/agents/SessionLog     │   │ client cordis root ctx                             │
+│ apiproxy: RPC + mux/host 双流  │◀─▶│  ├ loader(壳静态持有,不能经自己装载)             │
+│ webserver:                     │   │  ├ immediately 先行组: connection/runtime/         │
+│  ├ GET /plugins/<id>/client.js │   │  │   ui-theme/i18n(动态 bundle,并行先装)        │
+│  └ GET / 注入 __DSH_BOOT__     │   │  ├ 后续组: layout/sidebar/conversation/trajectory  │
+└────────────────────────────────┘   │  └ session scope ×N(观看驱动,惰性建)            │
+                                     │ React: loading 页 → settled → 整 UI 一次成型       │
+                                     └────────────────────────────────────────────────────┘
+```
+
+## client cordis 树与装载链
+
+每个 UI 插件同时是一个 host 插件(双入口包):node 半边住在 host 的插件树里,由 host Loader 管辖其生命周期;浏览器半边是 tsdown 闭包 bundle,挂在包的 `exports["./client"]` 下。host webserver 从带 `dshClient` manifest 字段的已加载插件推导启动清单,注入页面为 `window.__DSH_BOOT__`——HTML 到手即知要拉什么,零额外往返。
+
+装载链全程:
+
+1. `GET /` → 壳启动,挂 `ctx.loader`(loader 机件由壳静态持有——装载器不能经自己装载;其代码家在 `packages/client/runtime/src/client/loader/`,壳经 `./loader` 子路径 import,避免壳 bundle 吞掉 runtime 包其余部分),把纯库实体(react、react-dom、cordis、ui-slots、web-react、ui-primitives)播种进 require 模块表,渲染一张不依赖任何插件的 loading 页。
+2. `loader.start()` 读取 `__DSH_BOOT__`。带 `immediately` 标记的条目构成先行装载组(connection、runtime、ui-theme、i18n):并行拉取、按组内 `inject` 拓扑序 apply,**全组就位后才开始装载其余插件**。其余插件随后按 inject 序装载。
+3. 每个 bundle 执行 `window.DSHClientProxy.loadPlugin({ id, factory })`。loader 调 `factory(require)`——bundle 是闭包工厂,external 依赖经注入的 `require` 到达,从模块表解析(无全局变量、无 import map;解析不到的标识符即刻大声失败)。factory 返回其模块导出面(含 cordis `apply`);loader 执行 `ctx.plugin(apply)`,随后**以包名把该导出面登记进模块表**——inject 拓扑保证后装插件可 `require` 先装插件。插件 CSS 内联在 bundle 里,注入为 `<style data-plugin="<id>">`(CSS Modules 哈希 + 归属标记 = 隔离)。
+4. `await loader.settled()` → 壳从 loading 页一次切换到真 UI。单插件装载失败在 loading 页大声报错;不存在部分可用模式(渐进渲染为后置工作)。
+
+**双实例禁令**:模块表包若被内联进插件 bundle,会复制运行时身份(两份 React、两套 store 注册表——一次真实白屏 P0 的根因)。tsdown client 预设在构建期把守纯度:模块表包的裸名 import 必须解析为 external(适用时改写为其 `/client` 形态),其余任何非 inline 安全 wire/类型层的 workspace 泄漏都令构建大声失败(`packages/client/tsdown.client.ts`,由 `scripts/client-bundle-purity.spec.ts` 钉住)。
+
+dev 与 prod 同链:插件在 `tsdown --watch` 下重编译,刷新即重走同一条链;vite 只管壳(`apps/web`)。类型宇宙在聚合层拆分——根 `tsconfig.json` 是 host program,`tsconfig.client.json` 是 client program,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。
+
+## slot 体系:页面怎么拼
+
+页面是一棵坑位树;谁拥有区域谁声明坑位。契约只有一个家——`@deepseek-ai/dsh-client-ui-slots` 的 `SlotMap` 接口,经声明合并扩展。entry 只声明坑的轴与 **owner 份额**;注册方的注入 props 永不进全局表(「谁注入的放谁那里」):
+
+```ts ignore-check
+declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap {
+  sidebar:      { kind: 'single'; scope: 'root';    owner: SidebarOwnerProps }
+  conversation: { kind: 'single'; scope: 'session'; owner: ConvOwnerProps; children: 'conversation.empty' }
+} }
+ctx.slots.define('sidebar', { kind: 'single', scope: 'root' })   // declare=类型,define=落账
+ctx.slots.register('sidebar', SidebarRoot, { inject: (b) => ({ /* ... */ }) })
+```
+
+- 三型:`single`(重复注册即 throw)、`list`(id/order)、`keyed`(运行时按 key 分发,重 key 即 throw)。define 之前 register 即 throw。两 scope:`root`(无会话语境)与 `session`——scope 决定下述注入形态。
+- **组件全量 props 一律引用组合,不重抄**:注册方组件声明 `OwnerOf<K> & StandardOf<K> & OwnInjected`——owner 份额从坑位 owner 的包引用、标配份额由框架供给(session 坑:`useSession`)、注册方自己的注入份额就地声明在组件旁。`register<K, I>` 在调用点强制组合:组件形参位是 `SlotComponent<ComposedProps<K, NoInfer<I>>>`(裸调用签名而非 `FC`——FC 的 `propTypes` 静态位对标配份额产生反变噪音),`I` 只从 inject 工厂返回值推断(`NoInfer` 钉死),组件漂移或工厂不匹配都在注册点编译报错。ui-conversation 的注入份额住 `src/client/contract/slots.ts`(`ConversationInjected` 族),各骨架组件的 props 是一行引用组合。
+- **转授=手写白名单+可选声明上限**:owner 组件经自己的 props 拿到白名单收窄的 `slots: ScopedSlots<'a' | 'b'>`,调 `slots.renderSlot(key, props)` 渲染;把收窄子集递给子组件走 `narrowSlots`(纯类型协变)。越权是编译错误,运行时白名单再兜住纯 JS 调用方。entry 可另声明 `children: <key>`——register 校验组件白名单 ⊆ 声明上限(可选可见层,不强制)。每个被渲染的注册项都包在 per-entry 错误边界里:注册方崩溃(组件或 inject 工厂)只黑自己那一格,装配错误(缺 provider)则重抛——接错线的壳大声失败而不是静默降级。
+- **props 三源合并**(出口组件来做;owner 只写第一份):① owner 供参(身份、展示参数、冻结切片)——按 entry 的 owner 份额强类型,renderSlot 点即精确;② scope 标配注入——session 坑自动获得绑定正确 Session 的 `useSession`;③ 注册方的 `inject` 工厂,session 坑 per-(注册项 × 会话) 调一次、root 坑 per-注册项调一次,以 WeakMap 缓存——切回会话时复用缓存结果。inject 工厂收到装配句柄(`SessionBinding { sessionId, session, ctx }` 或 `RootBinding { ctx }`)——apply 世界的对象,永不进入 React。
+- 两条供给通道收拢闭环:`RootBindingProvider`(壳顶部挂一次)为 root 坑 inject 工厂供给 ctx;`createSessionProvider(deps)` 构造唯一的会话 provider——依赖倒置(`useCurrent` / `resolveBinding` / `renderBody`),web-react 永不 import runtime。它订阅当前会话 id、解析引用恒等的 binding、以 `key={id}` 重挂其 body,并把 body 渲染委托给装配方的 `renderBody` 闭包(坑位所有权留在 layout;provider 不认识坑名)。
+
+实现的家:注册表纯核在 `packages/client/ui-slots`(零依赖),出口组件/provider/uSES 桥在 `packages/client/web-react`。
+
+## 服务与 scope 寻址
+
+服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只 merge 视图表)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`)、`ctx.sessions`(列表 store、scope 树、binding)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(导航 + 面板观看态)、`ctx.conversation`(send/cancel/selection/views/startSession)、`ctx.toolviews`(具名按工具渲染注册表,带按会话 scope 过滤)。
+
+SlotMap 之外还有两条同 declare-merge 惯例的类型化注册环:**视图环**(`ConversationViewMap`——entry 可声明 `chromeProps`/`extraProps` 扩展形状;`ConvViewPropsOf<Id>`/`ChromePropsOf<Id>` 组合基座+扩展,无声明的视图免费得基座,ui-trajectory 的两个 entry 带真 per-view props)与**工具环**(tool 名保持开放集——无全局键表;类型强化在 entry 内部:`ToolViewProps.block` 是 runtime 定义的真 `ToolCallBlock` union,register 同 slots 一样推断注册方注入份额)。
+
+**scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
+
+## 数据对象层(`packages/client/runtime/src/client/sessions/`)
+
+帧从这里进、快照从这里出、fold 坐在中间——React-free(零 React import,grep 可断言):
+
+```
+mux/host 帧(ConnectionController 泵入,sinks 注入)
+        │
+        ▼
+SessionManager.handleMuxEnvelope / handleHostEnvelope
+        │ 带 sessionId 的帧只投已存在实例(审批/问答 requested 例外:进 pendingBuffers 缓冲)
+        ▼
+Session.handleMuxEnvelope ──► events 窗口(seq 连续升序)
+        │                        │ 定稿事件            │ chunk
+        │                        ▼                    ▼
+        │                   FoldAdapter        PartialAccumulator
+        │                  (→ nodes)          (→ partial)
+        ▼
+Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──► 组件
+```
+
+- **Session**(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`(RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot<ConversationSnapshot>`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量 fold;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。
+- **ConversationSnapshot**(conversation.ts):不可变快照契约——`nodes`(fold 产物,surface 序)、`partial`、`runningCalls`、`pending`、`running`、`removed`、`openState`、`hasMore`、`promptError` 等。**引用纪律**(memo 与 uSES 的前提):顶层对象每变必新;nodes 数组重建但元素引用来自缓存;未变的子结构复用上一快照的引用。
+- **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。
+- **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。
+- **FoldAdapter / PartialAccumulator**:fold 复用核心 SurfaceManager(`@deepseek-ai/dsh-session/surface`),垫哨兵事件使 seq > 0 起头的分页窗口满足核心的 `seq === index` 断言;跨窗口 replace 时降级为容错线性扫描并置 `foldDegraded`。分片完全不进 fold(O(1) 跳过):累积器把 StreamChunk 折叠成 `AssistantBlock[]`,一次增量只换该块引用;定稿消息到达即在同一批内弃掉累积器(提升无闪烁)。成本模型:一个分片 = 一次字符串拼接 + 一个脏标记;帧风暴下未订阅的 Session 只花那个标记。
+- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载(HTTP POST 载两个 client→server 象限、SSE 载两个 server→client 象限)与客户端类族归分层 RFC 属地。
+
+## React 面(`packages/client/web-react`)
+
+胶水包就是整条 ctx↔React 边界;组件保持零框架依赖。
+
+- `createSnapshotStore<T>(init, opts)`:插件自有数据与壳观看态的 store 引擎——zustand vanilla + 草稿式更新,缺省 `flush: 'sync'`(受控输入要求同 tick 回响),帧驱动 store 可选 `'raf'` 合批,可选整值 localStorage 持久化,dev 深冻结。Session 对象与快照 store 同构满足 React 消费的唯一数据契约:`ObservableSnapshot<T>`(`getSnapshot`/`subscribe`)。
+- `bindSnapshotSelector(source)`:把一个源绑定为经 uSES-with-selector 的带类型 selector hook。uSES 契约四条按构造成立:getSnapshot 恒返缓存引用;subscribe 是绑定期闭包(引用永稳);纯 CSR 不传 server snapshot;相等性缺省 `Object.is`,按调用可选 `shallowEqual`。
+- `useInvoke(fn)`:把异步动作包成引用恒定的触发器加 pending 标志;pending 走 per-hook 外部 store 经 uSES 读出(渲染路径零 setState),并发调用计数,invoke 引用永不变。
+- 相等性协议,全链一致:生产端结构共享;消费端以 `Object.is` 或 `shallowEqual` 短路;`React.memo` 浅比较。深比较全链禁止。
+
+## 目录形态
+
+十二个 `packages/client/*` 包(ui-slots、ui-primitives、web-react、connection、runtime、ui-layout、ui-sidebar、ui-conversation、ui-trajectory、ui-theme、i18n、web)加 `apps/web`——vite 应用,壳 boot 导出之上的薄 `main`。插件包的浏览器半边在 `src/client/` 下;**一切构建产物落 `lib/`**——node 半边为 `lib/index.js`/`lib/invariant.js`,浏览器 bundle 为 `lib/client.js`(共享 tsdown client 预设两者皆出;无 `dist/` 目录,`exports["./client"]` 指向 `./lib/client.js`)。依赖方向:`ui-slots ← web-react ← runtime ← ui-*(并列)← web`,ui-primitives/ui-theme/i18n 为零依赖旁路。
+
+多域插件包的 client 半边还按未来包边界再拆——ui-conversation 即样板:
+
+```
+src/client/
+  contract/    the only shared face between domains (types + composed props shares)
+  service.ts   cross-domain orchestration (imports contract only)
+  skeleton/    domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
+  chat/        domain: the chat view
+  toolviews/   domain: the tool-row registry and samples
+  apply.ts     the ONLY file allowed to import across domains (assembly point)
+  index.ts     thin re-export shell (contract + apply + components)
+```
+
+域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 chat 经 `ToolViewResolver` 读面接口消费工具注册表,不碰注册表类)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。
+
+## 怎么开发
+
+- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot 与 toolview),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;清单与装载随之自动跟上。
+- **新 slot**:契约合并进 `SlotMap`,owner 处 `define`,经 owner 自己的 `ScopedSlots` 白名单渲染;注册方 `register`,按需带 inject 工厂。永不全局导出组件。
+- **消费新帧类型**:带 sessionId → Session 分发 switch 加一个分支;host 级 → Manager 路由表;UI 需要时给 `ConversationSnapshot` 加字段并守住引用纪律。
+- **状态住哪**:per-session 且要跨切换存续 → Session 对象 / scope 挂账 store;单视图私有(选中、滚动)→ 组件状态;壳观看态(导航、面板宽、偏好)→ `ctx.layout` 的 store;业务数据 → 永远对象层,永不进观看态 store。
+- **通知通道**:帧驱动/异步 = `markDirty` 合批;受控输入需要同 tick 的用户手势直接回响 = `notifyNow`。
+
+## Consequences
+
+token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位,对被订阅视图每微任务一次合批重渲染(帧驱动 store 走 raf 合批)。UI 功能以独立插件的粒度装载、失败、停用——一个崩溃的 slot 注册项只黑一张卡,一个装载失败的 bundle 在 UI 切入之前大声报错。接受的代价:loader/模块表机件是团队端到端自持的定制基建;一次成型启动(无渐进渲染)用首屏粒度换装配简单;双类型 program 让「这个文件归哪个聚合」成为开发者偶尔要回答的问题。
+
+## Alternatives considered
+
+| Rejected | One-line reason |
+|---|---|
+| 静态链接的单 SPA bundle | 插件必须由 host 在运行时按配置组合;单体把每个 UI 功能重新耦回一次构建 |
+| window 全局变量 / import map 供共享依赖 | DI require 表让共享显式、大声失败、可替换;全局变量静默泄漏身份与版本 |
+| 业务数据进 zustand 切片 | 事件窗口/累积器是行为状态机,不是扁平切片;对象层保住快照粒度与合批的可控性 |
+| 工具行走字符串键的全局组件注册表 | 工具视图被多个视图共同消费且要按会话差异化——带 scope 过滤的具名服务(`ctx.toolviews`)才是诚实形态 |
+| P-I 就做渐进/Suspense 启动 | 一次成型严格更简单;loader 的按插件状态面已保留,渐进点亮日后可落地而无需重构 |

+ 6 - 0
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml

@@ -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
+2026-07-22-slot-type-chain-implementation.md: b4ec761b9777f5dfbd59efde8c472f9be4c2e1b6
+2026-07-22-slot-type-chain-implementation.zh.md: 28b6e4a3db0c87322582125825492703e62371b2

+ 47 - 0
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md

@@ -0,0 +1,47 @@
+# Agent Note: Slot type-chain hardening — the non-obvious implementation rulings
+
+Status: implemented
+
+English | [中文](2026-07-22-slot-type-chain-implementation.zh.md)
+
+> Scope: why the slot registration/render type chain (`packages/client/ui-slots/src/index.ts`, consumed by `packages/client/web-react/src/scoped-slots.tsx`) is implemented the way it is. The design-level trade-offs (registration-site inference over declaration tables, hand-written whitelists over derived ones) live in the web client architecture RFC; this note pins the five implementation decisions a future editor would otherwise re-litigate or accidentally revert.
+
+## Problem
+
+The hardened chain types every hop from `SlotMap` declaration to rendered component: owner share + framework-standard share + registrant-injected share compose into the component's props, checked at `register()`. Making that constraint hold without false rejections forced five choices that look arbitrary from the code alone — each one exists because the obvious alternative fails in a specific, reproducible way.
+
+## Decision
+
+### 1. `SlotComponent<P>` (bare call signature) instead of `FC<P>` at the registration position
+
+`register()` constrains components as `SlotComponent<ComposedProps<K, NoInfer<I>>>` where `SlotComponent<P> = (props: P) => ReactNode`. React's `FC` carries static fields (`propTypes`, `defaultProps`) whose types reference `P` in covariant positions; assignability between two `FC` instantiations therefore checks those statics too, and the bottom-typed standard share (see ruling 4's `useSession: never`) makes those covariant checks reject components that narrow it — precisely the components the design wants to accept. The bare call signature checks through clean parameter contravariance only. Components stay ordinary functions; nothing observable changes at runtime.
+
+### 2. `NoInfer<I>` pins the registrant share's inference to the inject factory
+
+`I` (the registrant's injected share) must be inferred from the `inject` factory's return type — the single authoritative source. Without `NoInfer`, TS also collects inference candidates from the component parameter position, and a drifted component (consuming a key the factory does not supply) silently WIDENS `I` to make the call check, absorbing the drift instead of reporting it. `NoInfer<I>` at the component position removes that candidate site, so negative sample ⑥ (a hand-drifted copy of the owner share fails at `register`) actually fails — with inference bleed it would pass. If the `NoInfer` ever gets "simplified away", the type-chain spec's expect-error site goes red first.
+
+### 3. `ComposedProps` dispatches on the entry's `owner` key for progressive migration
+
+`ComposedProps<K, I>` composes `owner & standard & I` only when the SlotMap entry declares an `owner` share; entries without one fall back to the legacy full-`props` constraint (`PropsShape`). This conditional is the migration seam: legacy declarations keep compiling unchanged while entries opt into the composed model one at a time, and both forms flow through the same `register()` overload — no parallel API, no flag. Removing the fallback branch is the flip-the-switch moment for the whole repo, not a cleanup.
+
+### 4. The standard share is bottom-typed, and bare `register` bivariance is accepted, not fought
+
+Session slots' framework-supplied hook is constrained as `{ useSession: never }` (`StandardOf`): `never` in a parameter-ish position means any registrant narrowing (e.g. a runtime-typed conversation hook) is accepted, and the responsibility for what actually arrives lives with the injecting renderer. Known boundary rider: for components typed with METHOD syntax or otherwise bivariant parameter positions, TS can accept a `register` call it strictly shouldn't (parameter bivariance is unsound by design in TS). The accepted stance is documented rather than tested: we do not add negative samples that depend on strictness TS does not guarantee — they would pin compiler-version behavior, not our contract. The samples we do pin (six expect-error sites in `packages/client/ui-slots/tests/type-chain.spec.tsx`) all fail for contract reasons.
+
+### 5. `ChildrenChecked` is an opt-in validation layer keyed on the entry's `children` declaration
+
+Sub-slot delegation authority stays a hand-written whitelist (`slots: ScopedSlots<'a' | 'b'>` in the component's own props). `ChildrenChecked<K, P>` adds an optional second check: only when the entry declares `children` does the component's `slots` face get validated against the authorized union (violation collapses `slots` to `never`, surfacing at the register call). Entries without `children` pass through untouched. The hook point is inside `ComposedProps` — i.e. it fires exactly at the registration boundary, not at render — because register is where both halves (entry declaration, component face) are statically visible at once; a render-time check would need runtime plumbing for a purely static guarantee.
+
+## Consequences
+
+The register call site is now the chain's single choke point: share drift, missing inject keys, unauthorized sub-slot faces, and keyed/list option omissions all surface there at compile time, and the six-sample negative spec pins each failure mode. Costs: the conditional types make hover-signatures at register sites noticeably wider; the bottom-typed standard share shifts arrival-type responsibility onto web-react's renderer (documented on `StandardOf`); and the bivariance boundary means one unsound-accept class is knowingly tolerated.
+
+## Alternatives considered
+
+| Rejected | One-line reason |
+|---|---|
+| Keep `FC` and cast at register sites | The casts hide exactly the drift the chain exists to catch; FC statics' covariant noise is the mechanical cause, so remove the noise, not the check |
+| Infer `I` from the component parameter | Inference bleed absorbs props drift silently — negative sample ⑥ becomes unwritable |
+| Big-bang migration to composed props | Every SlotMap declarant lands in one PR; the `owner`-keyed conditional lets entries migrate one by one with both forms live |
+| Test the bivariant-accept edge as a negative sample | Would pin TS soundness behavior we don't own; compiler upgrades would break the spec without any contract change |
+| Derive delegation whitelists from `children` declarations | The hand-written face is the API the component author reads; derivation inverts ownership and was rejected at design level — `ChildrenChecked` validates instead of generating |

+ 47 - 0
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md

@@ -0,0 +1,47 @@
+# Agent Note: slot 类型链硬化——五条非显然实现裁定
+
+Status: implemented
+
+[English](2026-07-22-slot-type-chain-implementation.md) | 中文
+
+> 范围:slot 注册/渲染类型链(`packages/client/ui-slots/src/index.ts`,消费方 `packages/client/web-react/src/scoped-slots.tsx`)为什么这样实现。设计层取舍(注册点推断优于声明表、手写白名单优于派生)住 Web 客户端架构 RFC;本文钉住五条实现决定——不写下来,将来的编辑者要么重新争论一遍,要么不经意地回退它们。
+
+## Problem
+
+硬化后的类型链给从 `SlotMap` 声明到组件渲染的每一跳定型:owner 份额 + 框架标配份额 + 注册方注入份额组合成组件 props,在 `register()` 处校验。让这条约束既成立又不误伤,逼出了五个单看代码显得任意的选择——每一个的存在都是因为显然的替代方案会以一种具体的、可复现的方式失败。
+
+## Decision
+
+### 1. 注册位用 `SlotComponent<P>`(裸调用签名)而非 `FC<P>`
+
+`register()` 以 `SlotComponent<ComposedProps<K, NoInfer<I>>>` 约束组件,其中 `SlotComponent<P> = (props: P) => ReactNode`。React 的 `FC` 携带静态字段(`propTypes`、`defaultProps`),其类型在协变位引用 `P`;两个 `FC` 实例化之间的可赋性因此连这些静态位一起查,而 bottom 型的标配份额(见裁定 4 的 `useSession: never`)使这些协变检查拒绝掉收窄它的组件——恰恰是设计想接受的那批组件。裸调用签名只走干净的参数逆变检查。组件仍是普通函数;运行时零可见差异。
+
+### 2. `NoInfer<I>` 把注册方份额的推断钉在 inject 工厂上
+
+`I`(注册方注入份额)必须从 `inject` 工厂的返回类型推断——唯一权威源。没有 `NoInfer` 时,TS 还会从组件参数位收集推断候选,漂移的组件(消费一个工厂并不供给的键)会静默地把 `I` 加宽到让调用通过,把漂移吸收掉而不是报出来。组件位的 `NoInfer<I>` 移除了那个候选位,负样本⑥(owner 份额的手抄漂移件在 register 处失败)才得以成立——有推断渗漏时它会通过。将来若有人把这个 `NoInfer`「顺手简化」掉,类型链 spec 的 expect-error 位会第一个变红。
+
+### 3. `ComposedProps` 按条目的 `owner` 键分派,支撑渐进迁移
+
+`ComposedProps<K, I>` 只在 SlotMap 条目声明了 `owner` 份额时才组合 `owner & standard & I`;未声明的条目回落到 legacy 全量 `props` 约束(`PropsShape`)。这个条件类型就是迁移接缝:legacy 声明原样编译,条目逐个转入组合模型,两种形态走同一个 `register()`——无平行 API、无开关旗。删掉回落分支的那一刻=全仓切换时刻,不是一次清理。
+
+### 4. 标配份额 bottom 型化;裸 `register` 的双变接受面认账不硬测
+
+session 坑的框架供给 hook 约束为 `{ useSession: never }`(`StandardOf`):参数性位置上的 `never` 意味着任何注册方收窄(如 runtime 定型的会话 hook)都被接受,实际到达什么的类型责任归注入侧渲染器。已知边界搭车项:对以方法语法定型或参数位本就双变的组件,TS 可能接受一个严格意义上不该过的 `register` 调用(参数双变是 TS 的有意不健全)。这个立场以文档记账而不加测试:我们不写依赖 TS 并不承诺的严格性的负样本——那钉住的是编译器版本行为,不是我们的契约。真正钉住的六个 expect-error 位(`packages/client/ui-slots/tests/type-chain.spec.tsx`)全部因契约原因失败。
+
+### 5. `ChildrenChecked` 是按条目 `children` 声明挂载的 opt-in 校验层
+
+子坑转授权威仍是手写白名单(组件自己 props 上的 `slots: ScopedSlots<'a' | 'b'>`)。`ChildrenChecked<K, P>` 加一层可选的第二道检查:仅当条目声明了 `children`,组件的 `slots` 面才对照授权并集校验(越界时 `slots` 坍缩为 `never`,在 register 调用处暴露)。未声明 `children` 的条目原样通过。挂点选在 `ComposedProps` 内部——即恰好在注册边界而非渲染期起效——因为 register 是条目声明与组件面两个半边同时静态可见的唯一位置;渲染期检查要为一个纯静态保证铺运行时管线。
+
+## Consequences
+
+register 调用点成为全链唯一收口:份额漂移、inject 键缺失、越权子坑面、keyed/list options 缺省全部在编译期于此暴露,六样本负样本 spec 逐一钉住失败模式。代价:条件类型让 register 位的悬停签名明显变宽;bottom 型标配份额把到达类型的责任转给 web-react 渲染器(记录于 `StandardOf`);双变边界意味着一类不健全接受被知情容忍。
+
+## Alternatives considered
+
+| Rejected | One-line reason |
+|---|---|
+| 保留 `FC`、在 register 位 cast | cast 恰好藏起类型链要抓的漂移;FC 静态位的协变噪音是机械成因,该移除噪音而非移除检查 |
+| 从组件参数位推断 `I` | 推断渗漏静默吸收 props 漂移——负样本⑥无从写起 |
+| 组合 props 一次性全仓迁移 | 所有 SlotMap 声明方挤进一个 PR;`owner` 键分派让条目逐个迁移、两形态共存 |
+| 给双变接受边缘加负样本 | 钉住的是我们不拥有的 TS 健全性行为;编译器升级会在契约零变化时打红 spec |
+| 从 `children` 声明派生转授白名单 | 手写面才是组件作者读到的 API;派生反转所有权,设计层已否——`ChildrenChecked` 做校验不做生成 |

+ 6 - 0
.agents/notes/implemented/process/2026-07-19-web-styling-system.i18n.yaml

@@ -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
+2026-07-19-web-styling-system.md: c80ef0d56a0e57b38fbb52bd07cbc0f69ec85912
+2026-07-19-web-styling-system.zh.md: 59013a4a950196f3a065ac18415f9b5ed42f3ec3

+ 61 - 0
.agents/notes/implemented/process/2026-07-19-web-styling-system.md

@@ -0,0 +1,61 @@
+# Agent Note: Web styling system — the token framework and engineering constraints
+
+Status: implemented
+
+> Token-system update (2026-07-22): the framework rulings here (CSS Modules + clsx, no component library, no tailwind, tokens-only colors) remain in force, but the two-layer `--bg-*`/`--text-*` token table and its `web-ui/src/style/global.css` home were replaced by the `--dsw-*` static+alias sheets in `packages/client/ui-theme/src/styles/` (dark = `body[data-ds-dark-theme]` override). Current authority: `missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §15.
+
+English | [中文](2026-07-19-web-styling-system.zh.md)
+
+> Division of labor: this RFC fixes the framework and constraints (rarely changes); [docs/web-styling.md](../../../../docs/web-styling.md) is the living spec (authoritative token values, the coding-rule checklist, the deviation record — it evolves with the implementation). Token changes and new rules go there; only changes to the framework itself come back here (overturning it requires a new RFC).
+
+## Problem
+
+The GUI has no designer supply; styles are written by an agent and reviewed. Without a machine-checkable token system and coding rules, colors/radii/motion drift as literals across components, and dark mode grows into conditional branches scattered inside components.
+
+## Decision
+
+| # | Decision | Content |
+|---|---|---|
+| 1 | **Visual baseline = Chat alignment** | Every value comes from the Chat front-end survey (brand blue `--accent: #3964fe`, gray scale, bubble/sidebar geometry, shadow tiers…); deviation is allowed but must be recorded in the web-styling.md deviation table |
+| 2 | **Two token layers, not three** | The baseline repo uses static→alias→specific three layers; at our size this compresses to "a semantic layer holding real values directly (comments cite the base palette source) + a handful of component-specific slots (`--bg-sidebar`/`--bubble-bg`)" — two layers, all living in `web-ui/src/style/global.css` |
+| 3 | **Font sizes/spacing are not tokenized** | Same decision as the baseline repo: font sizes are written in px inside components and **always paired with a line height** (16/24, 14/22, 12/18); spacing uses multiples of 4; tokenization covers only colors/radii/motion/font stacks/shadows |
+| 4 | **Borders and interaction states use the opacity scheme** | Borders `rgba(0,0,0,.04/.1)`, hover/active `rgba(38,49,72,.06/.1)` — they hold when layered on any elevation background, no new solid grays |
+| 5 | **Dark mode happens only in the token table** | `:root` holds light real values + `[data-theme='dark']` overrides the same-named variables; **component CSS has zero theme selectors**; when a non-token value genuinely must vary by theme, use the "CSS variable bridge" (the component defines a local variable, the theme block only overrides the variable) |
+
+## Engineering constraints
+
+- **CSS Modules + clsx, no component library, no tailwind**: each component has a same-named `.module.css` in the same directory; class names are camelCase, single-adjective state classes are attached via clsx; components pass `className` through.
+- **`composes` is banned**; `:global` only pierces third-party/cross-package class names and never defines new global classes; global utility classes live only in global.css and stay in the single digits (currently `.scrollable`).
+- **PostCSS plugins are currently zero** (vite has no postcss config; flat CSS suffices — adopting nested/custom-media requires recording it in web-styling.md first); CSS Modules type declarations use the wildcard declare in `css-modules.d.ts` (re-evaluate typed-css-modules per-file generation past 20 components).
+- **Dynamic styles go through the CSS variable bridge**: JS writes only variables (`style={{'--x': v}}`), rules stay in CSS; assembling style objects in TSX for theme/state branches is banned.
+- Transitions are always `var(--dur*) var(--ease)` and only transition opacity/transform/background-color/shadow; scroll containers uniformly use `.scrollable` (writing `::-webkit-scrollbar` inside components is banned).
+
+## The execution shape for agents
+
+The spec is maintained as a **review checklist** (web-styling.md §3, 12 items): each item is a decidable "see X, reject" — not a style suggestion — and writing styles and reviewing styles share the same table.
+
+Entry points for common tasks (operational checklists):
+
+- **Styling a new component**: same-named `.module.css` in the same directory, self-check against web-styling.md §3 item by item; colors/radii/motion reference only §1 tokens.
+- **Adding a token**: first add a row to the web-styling.md §1 table (light value + dark column + base palette source comment) → update both the global.css `:root` and `[data-theme='dark']` blocks → only then reference it in a component.
+- **Deviating from a visual-baseline constant** (the geometry/shadow values of web-styling.md §2): record a row in the §5 deviation table first (date/item/reason), then land the code.
+- **A non-token value that must vary by theme** (gradient endpoints and the like): the component defines a local CSS variable and the theme block only overrides the variable (the variable bridge); component CSS keeps zero `[data-theme]` selectors.
+
+## Division of labor with web-styling.md
+
+| Content | Home |
+|---|---|
+| The five framework rules, engineering constraints, why two layers / why font sizes are not tokenized | This RFC (changing it = a new superseding RFC) |
+| Per-token authoritative values (dark included), visual-baseline constants (sidebar/bubble/session-row/input-card geometry), the RPC four-quadrant direction-marker visual vocabulary, the 12 coding rules, the deviation record | web-styling.md (living document, evolves with the implementation) |
+| Value evidence (deepseekchat file:line) | The survey archive has served its purpose; git history keeps it |
+
+## Consequences
+
+Styles converge machine-checkably: colors/radii/motion/shadows reference only the §1 tokens of web-styling.md, dark mode is a single attribute-selector override table, and review runs off the same 12-item checklist the author self-checks against. The cost accepted: font sizes/spacing rely on the paired-line-height and multiples-of-4 disciplines rather than tokens, and any framework change requires a superseding RFC.
+
+## Alternatives considered
+
+| Rejected | One-line reason |
+|---|---|
+| Tokenizing font sizes/spacing | The baseline repo demonstrates convergence without it (the paired-line-height discipline substitutes); a bloated token table dilutes the authority of the color tokens |
+| Dark mode via `prefers-color-scheme` or in-component branches | Attribute-selector whole-table override keeps components oblivious; system preference can be layered onto the toggle later without touching the token mechanism |

+ 61 - 0
.agents/notes/implemented/process/2026-07-19-web-styling-system.zh.md

@@ -0,0 +1,61 @@
+# RFC: Web 样式体系——token 框架与工程约束
+
+Status: implemented
+
+> token 体系更新(2026-07-22):本文框架裁决(CSS Modules + clsx、无组件库、无 tailwind、组件只用 token)仍然生效,但两层 `--bg-*`/`--text-*` token 表及其宿主 `web-ui/src/style/global.css` 已被 `packages/client/ui-theme/src/styles/` 的 `--dsw-*` static+alias 双层表取代(暗色=`body[data-ds-dark-theme]` 覆写)。现行权威:`missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §15。
+
+[English](2026-07-19-web-styling-system.md) | 中文
+
+> 分工:本 RFC 定框架与约束(少变);[docs/web-styling.md](../../../../docs/web-styling.md) 是活规范(token 权威值、编码规范打勾清单、偏离记录,随实现演进)。改 token/加规则去那边;动框架本身才回这里(推翻须新 RFC)。
+
+## Problem
+
+GUI 无设计师供给,样式由 agent 编写并 review;没有一套机器可对照的 token 体系与编码规范,颜色/圆角/动效会在组件间字面量漂移,暗色主题会长成组件内散落的条件分支。
+
+## Decision(框架五条)
+
+| # | 决策 | 内容 |
+|---|---|---|
+| 1 | **视觉基线 = Chat 对齐** | 取值全部来自对 Chat 前端调研(品牌蓝 `--accent: #3964fe`、灰阶、气泡/侧边栏几何、阴影分级……);允许偏离但须在 web-styling.md 偏离表记录 |
+| 2 | **token 两层不三层** | 基线仓是 static→alias→specific 三层;我们体量下压成「语义层直接持实值(注释标 base 色板出处)+ 极少数组件专属槽位(`--bg-sidebar`/`--bubble-bg`)」两层,全部住 `web-ui/src/style/global.css` |
+| 3 | **字号/间距不 token 化** | 基线仓同款决策:字号在组件里写 px 且**成对写行高**(16/24、14/22、12/18),间距用 4 的倍数;token 化只覆盖颜色/圆角/动效/字体栈/阴影 |
+| 4 | **边框与交互态用透明度制** | 边框 `rgba(0,0,0,.04/.1)`、hover/active `rgba(38,49,72,.06/.1)`——叠加在任意海拔底色上都成立,不新造实色灰 |
+| 5 | **暗色只在 token 表做** | `:root` 亮色实值 + `[data-theme='dark']` 覆盖同名变量;**组件 CSS 零主题选择器**;确需按主题换非 token 值时用「CSS 变量桥」(组件定义局部变量、主题块只覆写变量) |
+
+## 工程约束
+
+- **CSS Modules + clsx,无组件库、无 tailwind**:每组件同目录同名 `.module.css`;类名 camelCase、状态类单形容词由 clsx 挂载;组件透传 `className`。
+- **禁 `composes`**;`:global` 仅穿透第三方/跨包类名,不定义新全局类;全局工具类只住 global.css 且个位数(现状 `.scrollable`)。
+- **PostCSS 插件现状为零**(vite 无 postcss 配置,平铺 CSS 即够用;引入 nested/custom-media 前需先记入 web-styling.md);CSS Modules 类型声明用 `css-modules.d.ts` 通配 declare(组件数超 20 再评估 typed-css-modules 逐文件生成)。
+- **动态样式走 CSS 变量桥**:JS 只写变量(`style={{'--x': v}}`),规则留在 CSS;禁止 TSX 内拼样式对象做主题/状态分支。
+- 过渡一律 `var(--dur*) var(--ease)` 且只过渡 opacity/transform/背景色/阴影;滚动容器统一 `.scrollable`(组件内禁写 `::-webkit-scrollbar`)。
+
+## 给 agent 的执行形态
+
+规范以 **review 对照打勾清单**形态维护(web-styling.md §3,12 条):每条是可判定的「见 X 即打回」,不是风格建议——写样式与 review 样式共用同一张表。
+
+常见事项的入口(操作清单):
+
+- **写新组件样式**:同目录同名 `.module.css`,对照 web-styling.md §3 逐条自查;颜色/圆角/动效只引 §1 token。
+- **加一个 token**:先进 web-styling.md §1 表补一行(亮色值+暗色列+base 色板出处注释)→ global.css `:root` 与 `[data-theme='dark']` 两块同步 → 再在组件里引用。
+- **偏离视觉基线常数**(web-styling.md §2 的几何/阴影值):先在 §5 偏离表记一行(日期/项/理由)再落码。
+- **需要按主题变化的非 token 值**(渐变端点等):组件定义局部 CSS 变量、主题块只覆写变量(变量桥),组件 CSS 保持零 `[data-theme]` 选择器。
+
+## 与 web-styling.md 的分工
+
+| 内容 | 归属 |
+|---|---|
+| 框架五条、工程约束、为何两层/为何不 token 化字号 | 本 RFC(改=新 RFC 供替) |
+| token 逐项权威值(含暗色)、视觉基线常数(侧边栏/气泡/会话列/输入卡片几何)、RPC 四象限方向符视觉词汇、编码规范 12 条、偏离记录 | web-styling.md(活文档,随实现演进) |
+| 取值证据(deepseekchat file:line) | 调研归档已完成使命,git 历史留档 |
+
+## Consequences
+
+样式收敛到机器可对照:颜色/圆角/动效/阴影只引 web-styling.md §1 token,暗色是单一属性选择器覆盖表,review 与自查共用同一张 12 条清单。接受的代价:字号/间距靠成对行高与 4 倍数纪律而非 token;动框架本身须新 RFC 供替。
+
+## Alternatives considered
+
+| 放弃项 | 一句话理由 |
+|---|---|
+| 字号/间距 token 化 | 基线仓实证不 token 化也能收敛(成对写行高纪律替代);token 表膨胀降低颜色 token 的权威性 |
+| 暗色用 `prefers-color-scheme` 或组件内分支 | 属性选择器整表覆盖让组件零感知;系统偏好可后续在 toggle 层适配,不动 token 机制 |

+ 6 - 0
.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml

@@ -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
+2026-07-20-gui-testing-system.md: db1b47566f5aa089ffcb10d130ecde1851b93112
+2026-07-20-gui-testing-system.zh.md: 691c6baf50c1025a09461effd28ac0f1650fb933

+ 59 - 0
.agents/notes/implemented/process/2026-07-20-gui-testing-system.md

@@ -0,0 +1,59 @@
+# Agent Note: GUI testing system — the three-tier structure
+
+Status: implemented
+
+> Path update (2026-07-22, plugin-system refactor): the three-tier philosophy and golden-path method here remain current; homes moved — object-layer specs now live in `packages/client/runtime/tests/` (was web-runtime), wire specs in `packages/client/connection/tests/`, and the `web-ui` coverage exclusion is gone with the package (component specs are per-plugin jsdom suites under each `packages/client/*/tests/`). Current test-system authority: `missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §18.
+
+English | [中文](2026-07-20-gui-testing-system.zh.md)
+
+> Division of labor: this note covers only the test structure specific to the GUI (`packages/{client,host}/*` + `apps/web`); repo-wide testing policy (tiering principles, the with-key policy, real-implementation-first, REAL-composition) lives in [docs/testing.md](../../../../docs/testing.md) and is not restated here.
+
+## Problem
+
+The GUI stack spans multiple application shapes, and within one shape multiple runtime environments (the Node host, the data protocol layer, the browser object layer, React/DOM); a single-lane test suite cannot give a meaningful signal. Every link needs effective tests of its own, plus the base capability for full-chain testing.
+
+## Decision
+
+Cut along the architecture's natural test seams into three tiers, bottom-up:
+
+| Tier | Under test | Key technique | File location |
+|---|---|---|---|
+| 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` |
+| 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/web-runtime/tests/{session,manager,connection,…}.spec.ts` |
+| 3 Browser smoke | Build artifacts × a real browser (the page boots, one conversation round-trips) | Bare playwright library (chromium headless, no @playwright/test framework), minimal pass-through; fixture level + real-host level (self-skips without a key) | `apps/web/tests/smoke-{fixture,real}.e2e.ts` |
+
+Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — smoke only proves the wiring is alive (the fixture level asserts zero `/api` requests and zero pageerror), interaction detail belongs to the verify scripts (see the lane map), wire semantics to tier 1, data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2.
+
+- **Host side** (apiproxy/runtime/webserver): under the repo-wide `test:coverage` gate, per-file 100%.
+- **Client side**: web-runtime **is already under the per-file 100% gate** (12 defensive unreachable arms carry reasoned `/* v8 ignore */` comments); the `vitest.config.ts` coverage.exclude is down to `packages/client/web-ui/src/**` (temporary — lifted progressively as component specs fill in after the component redo); tests still run, the exclusion only keeps web-ui src out of the thresholds. web-ui takes the **jsdom route (landed)**: jsdom + @testing-library/react entered root devDependencies (dev-only), first spec `web-ui/tests/utils.spec.tsx` (utils pure functions + component RTL render + hook uSES probe); the environment uses the per-file `// @vitest-environment jsdom` pragma, zero impact on the other node-env packages.
+- The exclusion is an **explicitly annotated ruling**, not a silent waiver; the lift path = delete the exclude line + add a justified exclusion or the missing tests.
+
+## Lane map
+
+| Scenario | Command | Content | When to run |
+|---|---|---|---|
+| Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source |
+| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 two-level smoke (fixture level + real-host level self-skip) | After touching the build surface/boot/carriage; before delivery |
+| Gate | `pnpm run test:coverage` | The repo-wide gate (host-side GUI packages included, client side excluded) | The PR window |
+
+**Division of labor between the verify scripts and vitest**: verify owns browser black-box regression (sequential steps = a user-operation script, one shared browser session, streaming PASS/FAIL output for the agent to locate the break), vitest owns first-class data-layer semantic assertions (reference stability `toBe`, state-machine timing, wire shapes). The two lanes complement each other, neither absorbs the other — scripts do not migrate to vitest (tearing apart an ordered script is a net loss); promoting one means wrapping a spawn shell hooked into the e2e lane, never rewriting the script body.
+
+## Anti-regression discipline
+
+- **Every bug fix pins an assertion**: a browser-visible bug is pinned into the regression section of its owning verify script (one pin = one report line); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense).
+- **All-green on fixture is not done, the real host must pass too**: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run `verify-session-real`.
+- The code-on-disk-is-the-answer reconciliation workflow: when a behavior change lands and turns existing cases red, reconcile on the spot (fix the test or fix the code, with the RFC/contract as arbiter); no red left hanging.
+
+## Consequences
+
+Each lane tests its own tier: touching any GUI source gets seconds-fast `test:gui` feedback, wire/object-layer semantics assert in milliseconds in node env, and the browser carries only wiring-liveness smoke. On the gate surface, the host side is fully under per-file 100%; on the client side web-runtime is under the gate while web-ui waits behind the explicitly annotated exclude. The accepted cost: the inter-tier discipline (upper tiers never re-test lower ones) is upheld by review rather than a machine gate, and web-ui's coverage gap persists until component specs fill in after the component redo.
+
+## Alternatives considered
+
+| Rejected | One-line reason |
+|---|---|
+| Single e2e (everything through the browser) | Browser startup is seconds × N slower and timing is uncontrollable; wire/object-layer invariants can be fully asserted in milliseconds in node env |
+| Migrating the verify scripts to vitest | An ordered script shares one browser session; splitting the cases either formalizes it (sequential + shared page) or re-runs the preamble × N; streaming PASS/FAIL output is exactly the agent's locating interface |
+| Reusing FixtureApiClient in tests | The demo script runs on a real clock, tests need deferred hand-controlled timing — orthogonal purposes; forced reuse chains the tests to the demo's rhythm |
+| A standalone vitest config for GUI packages (once designed as vitest.gui.config.ts) | Package-level tests/ are already scanned by the root include; `vitest run packages/client packages/host` path filtering is the tight loop — zero new config |
+| Deferring hooks/component-layer unit tests (the original ruling) | Once deferred as "components are consumables, revisit after the redo"; overturned by the user on 2026-07-20 — **the jsdom mainline enters coverage** (no browser infrastructure in CI is the decisive reason, playwright demoted to a local enhancement), the RTL dependencies entered devDependencies, the first spec landed |

+ 59 - 0
.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md

@@ -0,0 +1,59 @@
+# RFC: GUI 测试体系——三层结构
+
+Status: implemented
+
+> 路径更新(2026-07-22,插件体系重构):本文三层理念与金路径方法仍为现行;家搬了——对象层 spec 现居 `packages/client/runtime/tests/`(原 web-runtime)、wire spec 现居 `packages/client/connection/tests/`,`web-ui` 覆盖豁免随包消亡(组件 spec 为各 `packages/client/*/tests/` 的 jsdom 套件)。测试体系现行权威:`missions/tasks/20260721-1520-web-plugin-rfc/architecture.md` §18。
+
+[English](2026-07-20-gui-testing-system.md) | 中文
+
+> 分工线:本篇只讲 GUI(`packages/{client,host}/*` + `apps/web`)特有的测试结构;全仓测试政策(分层原则、with-key 政策、真实体优先、REAL-composition)见 [docs/testing.md](../../../../docs/testing.md),不在此复述。
+
+## Problem
+
+GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境(Node host、数据协议层、浏览器对象层、React/DOM),单一车道的测试给不了有效信号。需要对各环节都进行有效测试,并具备全链路测试的基础能力
+
+## Decision(三层结构)
+
+贴架构天然测试缝切三层,自底向上:
+
+| 层 | 被测物 | 关键手段 | 文件落点 |
+|---|---|---|---|
+| 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` |
+| 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/web-runtime/tests/{session,manager,connection,…}.spec.ts` |
+| 3 浏览器 smoke | 构建产物 × 真浏览器(页面起得来、一轮对话跑得通) | playwright 裸库(chromium headless,无 @playwright/test 框架)最简跑通;fixture 级 + 真 host 级(无 key self-skip) | `apps/web/tests/smoke-{fixture,real}.e2e.ts` |
+
+层间纪律:**下层各测各的,上层不重测下层**——smoke 只证接线活着(fixture 级断零 `/api` 请求、零 pageerror),交互细节归 verify 脚本(见车道地图),wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。
+
+- **host 侧**(apiproxy/runtime/webserver):进全仓 `test:coverage` 门禁,per-file 100%。
+- **client 侧**:web-runtime **已进 per-file 100% 门禁**(12 处防御性不可达臂带理由 `/* v8 ignore */` 注释);`vitest.config.ts` coverage.exclude 只剩 `packages/client/web-ui/src/**`(暂时——组件重做后随组件 specs 铺满逐步解除),测试照跑,只是不拉 web-ui src 进阈值。web-ui 走 **jsdom 路线(已落地)**:jsdom + @testing-library/react 入 root devDeps(dev-only),首个 spec `web-ui/tests/utils.spec.tsx`(utils 纯函数 + 组件 RTL render + hook uSES 探针);环境用 per-file `// @vitest-environment jsdom` pragma,node env 的其他包零影响。
+- 排除是**显式注释的裁决**不是静默豁免;解除路径=删 exclude 行 + 补 justified 排除或补测。
+
+## 车道地图
+
+| 场景 | 命令 | 内容 | 何时跑 |
+|---|---|---|---|
+| 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 |
+| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层双级 smoke(fixture 级 + 真 host 级 self-skip) | 改构建面/boot/承载后;交付前 |
+| 门禁 | `pnpm run test:coverage` | 全仓 gate(host 侧 GUI 包在内,client 侧 excluded) | PR 窗口 |
+
+**verify 脚本与 vitest 的分工**:verify 管浏览器黑盒回归(顺序步骤=用户操作剧本,共享一次浏览器会话,PASS/FAIL 流式输出供 agent 定位断点),vitest 管数据层语义一等断言(引用稳定性 `toBe`、状态机时序、wire 形)。两车道互补不收编——脚本不迁 vitest(拆散有序剧本是负收益),转正时包一层 spawn 壳挂 e2e 车道即可,脚本本体不改写。
+
+## 防回归纪律
+
+- **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属 verify 脚本的回归节(一钉一行 report);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。
+- **fixture 全绿不算完,真 host 也要过**:fixture 短路的恰是 wire 承载链(node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,`verify-session-real` 必跑。
+- 落盘代码即答案的对表工作流:行为改动落盘打红既有用例时,当场对表校准(改测试还是改代码以 RFC/契约为裁),不留悬红。
+
+## Consequences
+
+各车道各测各层:改任意 GUI 源码有秒级 `test:gui` 反馈,wire/对象层语义在 node env 毫秒级断言,浏览器只承担接线存活冒烟。门禁面上 host 侧全量进 per-file 100%;client 侧 web-runtime 已进门,web-ui 暂留显式注释的 exclude 之后。接受的代价:层间纪律(上层不重测下层)靠 review 而非机器门禁维持;web-ui 的覆盖缺口持续到组件重做后组件 specs 铺满为止。
+
+## Alternatives considered
+
+| 放弃项 | 一句话理由 |
+|---|---|
+| 单一 e2e (全走浏览器) | 浏览器起步秒级×N 倍慢+时序不可控;wire/对象层不变量在 node env 可毫秒级全断言 |
+| verify 脚本迁 vitest | 有序剧本共享浏览器会话,拆 case 要么形式化(sequential+共享 page)要么重走前置×N;PASS/FAIL 流式输出正是 agent 定位接口 |
+| 测试复用 FixtureApiClient | 演示脚本走真实时钟,测试需要 deferred 手控时序——用途正交,硬复用把测试绑死在演示节奏上 |
+| GUI 包独立 vitest config(曾设计 vitest.gui.config.ts) | 包级 tests/ 本就被根 include 扫到,`vitest run packages/client packages/host` 路径过滤即窄循环——零新 config |
+| hooks/组件层暂缓单测(原裁决) | 曾以「组件是耗材、等重做后再议」暂缓;2026-07-20 用户改判——**jsdom 主线进覆盖率**(CI 无浏览器基建是决定性理由,playwright 降级为本地增强),RTL 依赖入 devDeps、首个 spec 已落 |

+ 2 - 0
.gitignore

@@ -25,3 +25,5 @@ python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*
 python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/
 python/**/__pycache__/
 python/**/.pytest_cache/
+apps/web/dist/
+.artifacts/

+ 23 - 0
apps/cli/package.json

@@ -0,0 +1,23 @@
+{
+  "name": "@deepseek-ai/dsh",
+  "description": "dsh CLI: `dsh web` serves the built web UI over HTTP; `dsh -p` runs one headless task through the in-process ApiProxy carrier",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "bin": {
+    "dsh": "lib/bin.js"
+  },
+  "files": [
+    "lib/bin.js",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-frontend": "workspace:^",
+    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+    "@deepseek-ai/dsh-host-runtime": "workspace:^",
+    "@deepseek-ai/dsh-host-webserver": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^"
+  }
+}

+ 22 - 0
apps/cli/src/bin.ts

@@ -0,0 +1,22 @@
+#!/usr/bin/env node
+/**
+ * dsh — command-line entry. Coarse dispatch only; each subcommand module owns
+ * its parseArgs. Dynamic imports keep the shapes independent: `web` never
+ * loads the headless consumer, `-p` never loads node:http or the static server.
+ */
+
+import { loadEnv } from '@deepseek-ai/dsh-app-boot'
+
+loadEnv('dsh')
+const argv = process.argv.slice(2)
+
+if (argv[0] === 'web') {
+  const { runWeb } = await import('./web.ts')
+  await runWeb(argv.slice(1))
+} else if (argv.includes('-p') || argv.includes('--prompt')) {
+  const { runHeadless } = await import('./headless.ts')
+  await runHeadless(argv)
+} else {
+  process.stderr.write('usage: dsh web [--port N] | dsh -p "task"\n')
+  process.exit(1)
+}

+ 104 - 0
apps/cli/src/headless.ts

@@ -0,0 +1,104 @@
+/**
+ * `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic
+ * injection (InProcessApiClient over the host handler, so the full carrier
+ * chain — wire serialization, zod, SSE framing — really runs; this is the
+ * protocol's second real consumer). No HTTP server, no port, no dist
+ * resolution. Runs one task turn, prints the final assistant text, exits
+ * (completed → 0, else 1).
+ */
+
+import { parseArgs } from 'node:util'
+import { startHost } from '@deepseek-ai/dsh-host-runtime'
+import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy'
+import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
+import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
+import type { SessionId } from '@deepseek-ai/dsh-session'
+
+/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
+interface TurnOutcome {
+  text: string
+  reason: string
+}
+
+/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (dispose first). */
+async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
+  if (response.result.ok) return response.result.value
+  const { code, message } = response.result.error
+  process.stderr.write(`dsh: ${code}: ${message}\n`)
+  await dispose()
+  process.exit(1)
+}
+
+/**
+ * Consume mux frames until the task turn ends, per the cli-demo runOneShot
+ * correlation precedent: anchor on the first turn/start whose trigger kind is
+ * 'message' (startup-injected turns are skipped), aggregate text from that
+ * turn's assistant/message events (last one wins), finish on its turn/end.
+ */
+async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>, sessionId: SessionId): Promise<TurnOutcome> {
+  let targetTurn: number | undefined
+  let text = ''
+  try {
+    for await (const frame of frames) {
+      const payload = frame.payload
+      if (payload.type === 'stream/error') {
+        process.stderr.write(`dsh: stream error: ${payload.error.message}\n`)
+        return { text, reason: 'error' }
+      }
+      if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
+      const event = payload.event
+      if (targetTurn === undefined) {
+        if (event.type === 'turn/start' && event.data.trigger.kind === 'message') targetTurn = event.data.turn
+        continue
+      }
+      if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
+        const joined = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
+        if (joined !== '') text = joined
+      }
+      if (event.type === 'turn/end' && event.data.turn === targetTurn) {
+        return { text, reason: event.data.reason.kind }
+      }
+    }
+  } catch (error: unknown) {
+    process.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
+  }
+  return { text, reason: 'error' }
+}
+
+export async function runHeadless(argv: string[]): Promise<void> {
+  const { values } = parseArgs({
+    args: argv,
+    options: { prompt: { type: 'string', short: 'p' } },
+    allowPositionals: false,
+  })
+  const task = values.prompt
+  if (task === undefined || task === '') {
+    process.stderr.write('usage: dsh -p "task"\n')
+    process.exit(1)
+  }
+
+  // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
+  const host = await startHost({ boot: { persistenceRoot: './.sessions' } })
+  const api = new InProcessApiClient(host.handler)
+
+  const created = await unwrap(await api.sessions.create({}), host.dispose)
+
+  // Open the stream before prompting so no frame is lost — kept in this order
+  // even though in-process delivery has no race, so the code survives a move
+  // to a remote HTTP carrier unchanged.
+  const abort = new AbortController()
+  const frames = api.events.mux({}, abort.signal)
+  const done = consumeUntilTurnEnd(frames, created.sessionId)
+
+  await unwrap(await api.sessions.prompt({
+    sessionId: created.sessionId,
+    mode: 'queue',
+    content: [{ type: 'text', text: task }],
+  }), host.dispose)
+
+  const outcome = await done
+  process.stdout.write(outcome.text + '\n')
+  abort.abort()
+  await host.dispose()
+  process.exit(outcome.reason === 'completed' ? 0 : 1)
+}

+ 89 - 0
apps/cli/src/web.ts

@@ -0,0 +1,89 @@
+/**
+ * `dsh web` — the web-shape assembly: startHost + dist resolution +
+ * startWebServer + the URL line + signal wiring. Mixing host and carrier
+ * concerns is this app module's job (packages stay single-sided).
+ */
+
+import { parseArgs } from 'node:util'
+import { networkInterfaces } from 'node:os'
+import { createRequire } from 'node:module'
+import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
+import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
+
+export async function runWeb(argv: string[]): Promise<void> {
+  const { values } = parseArgs({
+    args: argv,
+    options: { port: { type: 'string', default: '3080' } },
+    allowPositionals: false,
+  })
+  const port = Number(values.port)
+  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
+    process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
+    process.exit(1)
+  }
+
+  // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
+  const host = await startHost({ boot: { persistenceRoot: './.sessions' } })
+
+  // Web UI plugin chain: in-memory Loader tree over the eight UI packages,
+  // then the registry that feeds __DSH_BOOT__ and /plugins/<id>/client.js.
+  const mounted = await mountWebPlugins(host.ctx)
+  const webPlugins = createHostWebPluginRegistry({
+    ctx: host.ctx,
+    loader: mounted.loader,
+    resolvePkgJson: mounted.resolvePkgJson,
+    onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) },
+  })
+  // Published so the webserver invariant companion can audit manifest/bundle
+  // consistency; nothing else reads this key.
+  host.ctx.reflect.provide('webPlugins', webPlugins)
+
+  // Dist location is workspace knowledge of this app: resolved through
+  // @deepseek-ai/dsh-frontend's package exports, not configured.
+  const require = createRequire(import.meta.url)
+  let distIndex: string
+  try {
+    distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
+  } catch {
+    process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n')
+    await host.dispose()
+    process.exit(1)
+  }
+
+  let exiting = false
+  async function shutdown(code: number): Promise<void> {
+    if (exiting) return
+    exiting = true
+    try {
+      await server.close()
+      await host.dispose()
+    } finally {
+      process.exit(code)
+    }
+  }
+
+  let server: Awaited<ReturnType<typeof startWebServer>>
+  try {
+    server = await startWebServer(
+      { port, distIndex, apiHandler: host.handler, webPlugins },
+      (err: Error) => {
+        process.stderr.write(`dsh web: ${String(err)}\n`)
+        void shutdown(1)
+      },
+    )
+  } catch (error: unknown) {
+    // listen failed (EADDRINUSE…): no server to close, dispose the host directly.
+    process.stderr.write(`dsh web: ${String(error)}\n`)
+    await host.dispose()
+    process.exit(1)
+  }
+
+  // The server binds 0.0.0.0 (remote-container + LAN-browser is the primary scenario);
+  // print the LAN address alongside loopback so the printed URL is copy-usable from outside.
+  const lan = Object.values(networkInterfaces()).flat()
+    .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
+  console.log(`dsh web: http://127.0.0.1:${server.port}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`)
+
+  process.on('SIGTERM', () => { void shutdown(0) })
+  process.on('SIGINT', () => { void shutdown(130) })
+}

+ 18 - 0
apps/cli/tsconfig.json

@@ -0,0 +1,18 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    { "path": "../../vendor/cordis" },
+    { "path": "../../packages/host/apiproxy" },
+    { "path": "../../packages/host/runtime" },
+    { "path": "../../packages/host/webserver" },
+    { "path": "../../packages/core/session" },
+    { "path": "../../packages/ui/app-boot" }
+  ]
+}

+ 12 - 0
apps/web/index.html

@@ -0,0 +1,12 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>DeepSeek Harness</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 37 - 0
apps/web/package.json

@@ -0,0 +1,37 @@
+{
+  "name": "@deepseek-ai/dsh-frontend",
+  "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "exports": {
+    "./dist/*": "./dist/*",
+    "./package.json": "./package.json"
+  },
+  "scripts": {
+    "build": "vite build",
+    "dev": "vite",
+    "watch": "vite build --watch"
+  },
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "@deepseek-ai/dsh-client-web": "workspace:^",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-web-react": "workspace:^",
+    "@deepseek-ai/dsh-host-webserver": "workspace:^",
+    "@types/node": "^22.0.0",
+    "@types/react": "~18.3.1",
+    "@types/react-dom": "~18.3.0",
+    "@vitejs/plugin-react": "^4.0.0",
+    "playwright": "^1.49.0",
+    "typescript": "^6.0.3",
+    "vite": "^6.0.0",
+    "vitest": "^4.1.8"
+  }
+}

+ 10 - 0
apps/web/src/main.ts

@@ -0,0 +1,10 @@
+/**
+ * Web application entry: thin bootstrap over the shell library. Everything —
+ * loader holding, module-table seeding, AppRoot gate, plugin assembly — lives
+ * in @deepseek-ai/dsh-client-web; this file only finds the mount point.
+ */
+import { bootWebShell } from '@deepseek-ai/dsh-client-web'
+
+const el = document.getElementById('root')
+if (el === null) throw new Error('web app: missing #root')
+bootWebShell(el)

+ 147 - 0
apps/web/tests/smoke-fixture.e2e.ts

@@ -0,0 +1,147 @@
+// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
+// registry surface + __DSH_BOOT__ injection + built shell dist in a real
+// chromium. First describe: manifest injection + fail-loud half. Second
+// describe: the settled success pass — five REAL tsdown bundles (the
+// infrastructure four + layout) load through the DI chain in ?fixture mode
+// and the three-column frame appears in one flip. The full conversation
+// round lands in smoke-real under the W5 real-host standard.
+import { existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { startWebServer } from '@deepseek-ai/dsh-host-webserver'
+import type { WebPluginBootEntry } from '@deepseek-ai/dsh-host-webserver'
+import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts'
+
+const bundlePath = (dir: string): string =>
+  fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
+
+/** id ↔ bundle table for the success pass (immediately four + layout). */
+const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
+  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
+]
+
+/** Manifest served by the fake registry: one live bundle row, one missing row. */
+const ROWS: WebPluginBootEntry[] = [
+  { id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: [] },
+  { id: '@probe/absent', url: '/plugins/@probe/absent/client.js', inject: [] },
+]
+const LAYOUT_BUNDLE = bundlePath('ui-layout')
+
+describe('web boot chain (keyless, real carrier)', () => {
+  let server: Awaited<ReturnType<typeof startWebServer>>
+  let browser: Browser
+  let page: Page
+  const pageErrors: string[] = []
+
+  beforeAll(async () => {
+    requireDist()
+    const port = await probeFreePort()
+    const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) }
+    server = await startWebServer({
+      port,
+      distIndex: DIST_INDEX,
+      apiHandler,
+      webPlugins: {
+        snapshot: () => ROWS,
+        clientPath: (id) => (id === ROWS[0]!.id ? LAYOUT_BUNDLE : undefined),
+      },
+    }, (err) => { pageErrors.push(`server: ${String(err)}`) })
+    browser = await chromium.launch()
+    page = await browser.newPage()
+    page.on('pageerror', (e) => pageErrors.push(String(e)))
+    await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' })
+  })
+
+  afterAll(async () => {
+    await browser?.close()
+    await server?.close()
+  })
+
+  it('GET / injects the manifest verbatim', async () => {
+    onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
+    const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
+    expect(boot).toEqual({ plugins: ROWS })
+  })
+
+  it('serves a real bundle through the plugins endpoint', async () => {
+    const res = await page.request.get(`${new URL(page.url()).origin}${ROWS[0]!.url}`)
+    expect(res.status()).toBe(200)
+    expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin')
+  })
+
+  it('boots to the loading page and fail-louds the absent plugin', async () => {
+    onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
+    await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
+    await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
+    await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
+    // The real UI must not have flipped in: the gate opens only on settled().
+    expect(await page.locator('[class*="frame"]').count()).toBe(0)
+  })
+
+  it('applies the token sheets before any plugin CSS', async () => {
+    const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family'))
+    expect(family.trim().length).toBeGreaterThan(0)
+  })
+})
+
+describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => {
+  const missing = REAL_PLUGINS.filter((p) => !existsSync(bundlePath(p.dir)))
+  let server: Awaited<ReturnType<typeof startWebServer>>
+  let browser: Browser
+  let page: Page
+  const pageErrors: string[] = []
+
+  beforeAll(async () => {
+    requireDist()
+    if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter <pkg> bundle): ${missing.map((m) => m.dir).join(', ')}`)
+    const port = await probeFreePort()
+    const rows: WebPluginBootEntry[] = REAL_PLUGINS.map((p) => {
+      const row: WebPluginBootEntry = { id: p.id, url: `/plugins/${p.id}/client.js`, inject: p.inject }
+      if (p.immediately === true) row.immediately = true
+      return row
+    })
+    const byId = new Map(REAL_PLUGINS.map((p) => [p.id, bundlePath(p.dir)]))
+    // ?fixture never opens HTTP streams; /api is a tripwire like the first describe.
+    const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }
+    server = await startWebServer({
+      port,
+      distIndex: DIST_INDEX,
+      apiHandler,
+      webPlugins: { snapshot: () => rows, clientPath: (id) => byId.get(id) },
+    }, (err) => { pageErrors.push(`server: ${String(err)}`) })
+    browser = await chromium.launch()
+    page = await browser.newPage()
+    page.on('pageerror', (e) => pageErrors.push(String(e)))
+    await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' })
+  })
+
+  afterAll(async () => {
+    await browser?.close()
+    await server?.close()
+  })
+
+  it('settles and flips to the three-column frame in one pass', async () => {
+    onTestFailed(() => saveFailureShot(page, 'smoke-boot-settled'))
+    await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
+    // Loading page is gone; the grid carries the three tracks.
+    expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
+    const template = await page.locator('[class*="frame"]').evaluate((el) => getComputedStyle(el).gridTemplateColumns)
+    expect(template.split(' ').length).toBe(3)
+  })
+
+  it('every plugin CSS landed with its ownership tag', async () => {
+    const owners = await page.evaluate(() =>
+      [...document.querySelectorAll('style[data-plugin]')].map((s) => (s as HTMLElement).dataset['plugin']))
+    expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout')
+  })
+
+  it('stayed clean: no page errors across the whole load chain', () => {
+    expect(pageErrors).toEqual([])
+  })
+})

+ 235 - 0
apps/web/tests/smoke-real.e2e.ts

@@ -0,0 +1,235 @@
+// W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
+// list in a real chromium, screenshot every screen into .artifacts/ for the
+// figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
+// convention); the runner loads the repo-root .env explicitly because the CLI
+// only auto-loads .env from its cwd (a temp dir here, so sessions never land
+// in the repo's .sessions).
+//
+// Selector convention: CSS Modules hash as [hash]_[local], so class-substring
+// selectors are unreliable — anchor on data-* attributes (data-variant /
+// data-clickable / data-sample) or visible text. The one [class*=] use below
+// (frame/handle) rides local names that survive hashing as suffixes; prefer
+// data-* for anything new.
+//
+// Flow order matters: chat rounds first (5 depends on 3's session), geometry
+// and theme after, reload recovery last. Tests run sequentially in-file.
+import type { ChildProcess } from 'node:child_process'
+import { spawn } from 'node:child_process'
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { REPO_ROOT, probeFreePort, requireDist, saveFailureShot } from './support.ts'
+
+/** Repo-root .env → process.env (never overrides an already-set variable). */
+function loadRootEnv(): void {
+  const envPath = join(REPO_ROOT, '.env')
+  if (!existsSync(envPath)) return
+  for (const line of readFileSync(envPath, 'utf8').split('\n')) {
+    const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
+    if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
+  }
+}
+loadRootEnv()
+
+function waitForReadyLine(child: ChildProcess): Promise<string> {
+  return new Promise((resolveReady, reject) => {
+    let out = ''
+    const timer = setTimeout(() => reject(new Error(`dsh web not ready in 90s; output:\n${out}`)), 90_000)
+    const onData = (chunk: Buffer): void => {
+      out += chunk.toString()
+      const match = /dsh web: (http:\/\/[^\s]+)/.exec(out)
+      if (match?.[1] !== undefined) {
+        clearTimeout(timer)
+        resolveReady(match[1])
+      }
+    }
+    child.stdout?.on('data', onData)
+    child.stderr?.on('data', onData)
+    child.once('exit', (code) => {
+      clearTimeout(timer)
+      reject(new Error(`dsh web exited early (code ${code}); output:\n${out}`))
+    })
+  })
+}
+
+/** W5 screenshot: evidence for the figma comparison, not a failure artifact. */
+async function screen(page: Page, name: string): Promise<void> {
+  await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
+}
+
+/** First column track (px string) of the frame grid. */
+async function firstTrack(page: Page): Promise<string> {
+  return (await page.locator('[class*="frame"]').evaluate(
+    (el) => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
+}
+
+/** Last column track (details) as a number of pixels. */
+async function detailsTrack(page: Page): Promise<number> {
+  const cols = await page.locator('[class*="frame"]').evaluate(
+    (el) => getComputedStyle(el).gridTemplateColumns)
+  return Number(cols.split(' ').pop()!.replace('px', ''))
+}
+
+// Readiness gate: `dsh web` serves ALL eight manifest plugins; until every UI
+// plugin's client bundle exists and exports apply, the loader fail-louds and
+// the frame never appears.
+const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-trajectory']
+const notReady = UI_PLUGIN_DIRS.filter((dir) => {
+  const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
+  return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply')
+})
+if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`)
+
+describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
+  let child: ChildProcess
+  let sessionsDir: string
+  let baseUrl: string
+  let browser: Browser
+  let page: Page
+  const pageErrors: string[] = []
+
+  beforeAll(async () => {
+    requireDist()
+    sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
+    const port = await probeFreePort()
+    // tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. cwd is a
+    // temp dir (persistenceRoot is cwd-relative), so tsx needs the repo's loader
+    // and tsconfig paths pointed at explicitly.
+    const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
+    child = spawn(
+      process.execPath,
+      ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
+      {
+        cwd: sessionsDir,
+        env: { ...process.env, TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json') },
+        stdio: ['ignore', 'pipe', 'pipe'],
+      },
+    )
+    baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1')
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+    page.on('pageerror', (e) => pageErrors.push(String(e)))
+    await page.goto(baseUrl, { waitUntil: 'load' })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    if (child !== undefined && child.exitCode === null) {
+      const gone = new Promise<void>((resolveExit) => child.once('exit', () => resolveExit()))
+      child.kill('SIGTERM')
+      await Promise.race([gone, new Promise((r) => setTimeout(r, 10_000).unref())])
+      if (child.exitCode === null) child.kill('SIGKILL')
+    }
+    if (sessionsDir !== undefined) rmSync(sessionsDir, { recursive: true, force: true })
+  })
+
+  it('1 cold start: loading page settles into the three-column frame', async () => {
+    onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
+    const template = await page.locator('[class*="frame"]').evaluate((el) => getComputedStyle(el).gridTemplateColumns)
+    expect(template.split(' ').length).toBe(3)
+    await screen(page, '01-cold-start')
+  })
+
+  it('2+3 empty-state first send completes a real model round', async () => {
+    onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
+    const input = page.locator('textarea').first()
+    await input.waitFor({ timeout: 10_000 })
+    await screen(page, '02-empty-state')
+    await input.fill('请简单介绍事件溯源,两句话即可,最后以「介绍完毕」结尾')
+    await input.press('Enter')
+    // startSession chain: session mounts, composer moves to the bottom.
+    // Regression pin (P0, 585671106): this send used to white-screen the tree
+    // (scope tag lost to a duplicate inlined runtime instance) — body going
+    // near-empty here means that class of bug is back.
+    await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
+    expect(pageErrors).toEqual([])
+    await page.waitForFunction(() => document.body.innerText.includes('介绍完毕'), undefined, { timeout: 120_000 })
+    await screen(page, '04-round-complete')
+  }, 150_000)
+
+  it('4 view tabs: Chat / Trajectory / Waterfall all switch', async () => {
+    onTestFailed(() => saveFailureShot(page, 'w5-tabs'))
+    await page.locator('button', { hasText: /Trajectory/i }).first().click()
+    await screen(page, '05-trajectory-tab')
+    await page.locator('button', { hasText: /Waterfall/i }).first().click()
+    await screen(page, '06-waterfall-tab')
+    await page.locator('button', { hasText: /^Chat$/i }).first().click()
+    await screen(page, '07-back-to-chat')
+  })
+
+  it('5 bash differential rendering: tool row click opens the details column', async () => {
+    onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
+    const input = page.locator('textarea').first()
+    await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
+    await input.press('Enter')
+    // Wait for the tool ROW, not response text (the reply echoes any marker).
+    // bash renders through the third-party sample registration (data-sample) —
+    // that IS the differential-rendering acceptance; the generic path renders
+    // data-variant rows with the handler on the data-clickable inner row.
+    const toolRow = page.locator('[data-sample], [data-variant] [data-clickable]').first()
+    await toolRow.waitFor({ timeout: 120_000 })
+    await screen(page, '08-bash-round')
+    expect(await detailsTrack(page)).toBe(0)
+    await toolRow.click()
+    // Selection channel: click writes selection + layout.openDetails.
+    await page.waitForFunction(() => {
+      const frame = document.querySelector('[class*="frame"]')
+      if (frame === null) return false
+      return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0
+    }, undefined, { timeout: 10_000 })
+    await screen(page, '09-details-open')
+  }, 150_000)
+
+  it('6 sidebar drag widens the column and persists across reload', async () => {
+    onTestFailed(() => saveFailureShot(page, 'w5-drag'))
+    const before = await firstTrack(page)
+    const handle = page.locator('[class*="handle"]').first()
+    const box = await handle.boundingBox()
+    expect(box).not.toBeNull()
+    await page.mouse.move(box!.x + box!.width / 2, box!.y + 300)
+    await page.mouse.down()
+    await page.mouse.move(box!.x + 70, box!.y + 300, { steps: 6 })
+    await page.mouse.up()
+    const after = await firstTrack(page)
+    expect(after).not.toBe(before)
+    await screen(page, '10-sidebar-dragged')
+    await page.reload({ waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    expect(await firstTrack(page)).toBe(after)
+  })
+
+  it('7 dark mode: the body attribute cascades the token sheets', async () => {
+    onTestFailed(() => saveFailureShot(page, 'w5-dark'))
+    // theme.apply === toggling this attribute (v3 §8); no switcher UI owns it
+    // in P-I, so the acceptance drives the documented mechanism directly.
+    const dark = await page.evaluate(() => {
+      document.body.setAttribute('data-ds-dark-theme', '')
+      return getComputedStyle(document.body).backgroundColor
+    })
+    await screen(page, '11-dark-mode')
+    const light = await page.evaluate(() => {
+      document.body.removeAttribute('data-ds-dark-theme')
+      return getComputedStyle(document.body).backgroundColor
+    })
+    expect(dark).not.toBe(light)
+  })
+
+  it('8 reload recovery: history replays after a fresh boot', async () => {
+    onTestFailed(() => saveFailureShot(page, 'w5-reload'))
+    await page.reload({ waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await page.waitForFunction(() => document.body.innerText.includes('介绍完毕'), undefined, { timeout: 30_000 })
+    await screen(page, '12-reload-recovery')
+  })
+
+  it('stayed clean: no page errors across every flow', () => {
+    expect(pageErrors).toEqual([])
+  })
+})

+ 47 - 0
apps/web/tests/support.ts

@@ -0,0 +1,47 @@
+// Shared plumbing for the web smoke tests (dist location, free port, failure shots).
+import { existsSync, mkdirSync } from 'node:fs'
+import { createServer } from 'node:net'
+import { fileURLToPath } from 'node:url'
+import type { Page } from 'playwright'
+
+/** The built page under test; `pnpm run test:web` rebuilds it before running. */
+export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
+
+export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
+
+/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
+export function requireDist(): void {
+  if (!existsSync(DIST_INDEX)) {
+    throw new Error('web app dist not built — run `pnpm --filter @deepseek-ai/dsh-frontend build` (pnpm run test:web does this first)')
+  }
+}
+
+/**
+ * OS-assigned free port, released before use. startWebServer echoes
+ * options.port instead of the bound one, so passing 0 directly is unusable.
+ */
+export function probeFreePort(): Promise<number> {
+  return new Promise((resolvePort, reject) => {
+    const probe = createServer()
+    probe.once('error', reject)
+    probe.listen(0, '127.0.0.1', () => {
+      const address = probe.address()
+      if (address === null || typeof address === 'string') {
+        probe.close(() => reject(new Error('port probe returned no address')))
+        return
+      }
+      probe.close(() => resolvePort(address.port))
+    })
+  })
+}
+
+/** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
+export async function saveFailureShot(page: Page, name: string): Promise<void> {
+  const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
+  mkdirSync(dir, { recursive: true })
+  try {
+    await page.screenshot({ path: `${dir}/${name}.png`, fullPage: true })
+  } catch {
+    // Best-effort evidence: a dead page/browser at failure time must not mask the real assertion error.
+  }
+}

+ 22 - 0
apps/web/tsconfig.json

@@ -0,0 +1,22 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": ".",
+    "outDir": "lib/types",
+    "jsx": "react-jsx",
+    "lib": [
+      "ES2024",
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "types": ["node"]
+  },
+  "include": [
+    "src",
+    "tests"
+  ],
+  "references": [
+    { "path": "../../packages/client/web" },
+    { "path": "../../packages/host/webserver" }
+  ]
+}

+ 26 - 0
apps/web/vite.config.ts

@@ -0,0 +1,26 @@
+import { fileURLToPath } from 'node:url'
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
+
+export default defineConfig({
+  plugins: [react()],
+  resolve: {
+    // Workspace packages resolve to SOURCE: package.json exports point at lib
+    // for Node/type consumers, but the browser bundle must compile src directly
+    // so CSS rides vite's pipeline instead of the CSS-externalized lib bundle.
+    // Only the shell's static surface is aliased — UI plugin packages are NOT
+    // bundled here; they arrive as dynamic bundles through the client loader.
+    // Order matters — subpath aliases must win over bare-name prefixes.
+    alias: [
+      { find: /^@deepseek-ai\/dsh-client-web$/, replacement: src('../../packages/client/web/src/boot.tsx') },
+      { find: /^@deepseek-ai\/dsh-client-web-react\/store$/, replacement: src('../../packages/client/web-react/src/store/index.ts') },
+      { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
+      { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
+      { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
+      { find: /^@deepseek-ai\/dsh-client-runtime\/loader$/, replacement: src('../../packages/client/runtime/src/client/loader/index.ts') },
+      { find: /^@deepseek-ai\/dsh-client-runtime$/, replacement: src('../../packages/client/runtime/src/index.ts') },
+    ],
+  },
+})

+ 16 - 1
docs/config-catalog.md

@@ -1572,7 +1572,7 @@ export interface Config {
 export type ApprovalPolicy = 'ask' | 'never'
 ```
 
-Source: [`packages/ui/user-approval/src/index.ts:214`](../packages/ui/user-approval/src/index.ts)
+Source: [`packages/ui/user-approval/src/index.ts:198`](../packages/ui/user-approval/src/index.ts)
 
 ## `@deepseek-ai/dsh-web`
 
@@ -1738,6 +1738,14 @@ Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/cont
 These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.
 
 - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
+- `@deepseek-ai/dsh-client-connection` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts))
+- `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts))
+- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
 - `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts))
 - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts))
 - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
@@ -1774,8 +1782,15 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts))
 - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
 - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts))
+- `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))
+- `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
 - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
 - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
+- `@deepseek-ai/dsh-host-apiproxy` ([`packages/host/apiproxy/src/index.ts`](../packages/host/apiproxy/src/index.ts))
+- `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts))
+- `@deepseek-ai/dsh-host-webserver` ([`packages/host/webserver/src/index.ts`](../packages/host/webserver/src/index.ts))
 - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
 - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts))

+ 1 - 1
docs/cordis-catalog/events.md

@@ -417,7 +417,7 @@ Ask composed answerers for one decision. Return an outcome to claim the request
 
 Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [ApprovalService](../core-data-structures/approval.md) · [Scoped](../core-data-structures/scope.md)
 
-Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts)
+Source: [`packages/ui/user-approval/src/index.ts:30`](../../packages/ui/user-approval/src/index.ts)
 
 ## `commands/*`
 

+ 2 - 2
docs/cordis-catalog/services.md

@@ -246,7 +246,7 @@ async request(req: ApprovalRequest): Promise<ApprovalOutcome>
 
 Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md)
 
-Source: [`packages/ui/user-approval/src/index.ts:229`](../../packages/ui/user-approval/src/index.ts)
+Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-approval/src/index.ts)
 
 ## `ctx.bash` — `BashExecutor` (abstract seam)
 
@@ -1482,7 +1482,7 @@ async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
 
 Types: [AskUserQuestionAnswer](../core-data-structures/user-interaction.md) · [AskUserQuestionRequest](../core-data-structures/user-interaction.md) · [UserInteractionProvider](../core-data-structures/user-interaction.md)
 
-Source: [`packages/ui/user-interaction/src/index.ts:82`](../../packages/ui/user-interaction/src/index.ts)
+Source: [`packages/ui/user-interaction/src/index.ts:50`](../../packages/ui/user-interaction/src/index.ts)
 
 ## `ctx.web` — `WebService`
 

+ 9 - 7
docs/event-producer-consumer.md

@@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:201`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
 | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
 | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
-| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
+| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
 | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
 | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
 | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
@@ -20,20 +20,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
 | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
+| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
 | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
 | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
-| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
+| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
 | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-title`](../packages/session-title/session-title) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
-| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:90`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
+| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:90`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:100`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -57,7 +57,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
-| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
+| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
+| `internal/plugin` | - | `webserver` |
 | `internal/status` | - | [`agent`](../packages/core/agent) |
+| `slots/changed` | `runtime` (`emit`) | - |
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 49 - 0
docs/module-graph.md

@@ -126,6 +126,20 @@ flowchart TD
     pkg_user_approval["user-approval"]
     pkg_user_interaction["user-interaction"]
   end
+  subgraph group_client["packages/client"]
+    pkg_client_connection["client-connection"]
+    pkg_client_i18n["client-i18n"]
+    pkg_client_runtime["client-runtime"]
+    pkg_client_ui_conversation["client-ui-conversation"]
+    pkg_client_ui_layout["client-ui-layout"]
+    pkg_client_ui_primitives["client-ui-primitives"]
+    pkg_client_ui_sidebar["client-ui-sidebar"]
+    pkg_client_ui_slots["client-ui-slots"]
+    pkg_client_ui_theme["client-ui-theme"]
+    pkg_client_ui_trajectory["client-ui-trajectory"]
+    pkg_client_web["client-web"]
+    pkg_client_web_react["client-web-react"]
+  end
   subgraph group_code_runtime["packages/code-runtime"]
     pkg_code_runtime["code-runtime"]
     pkg_code_runtime_worker["code-runtime-worker"]
@@ -144,6 +158,11 @@ flowchart TD
   subgraph group_guard["packages/guard"]
     pkg_repeat_tool_guard["repeat-tool-guard"]
   end
+  subgraph group_host["packages/host"]
+    pkg_host_apiproxy["host-apiproxy"]
+    pkg_host_runtime["host-runtime"]
+    pkg_host_webserver["host-webserver"]
+  end
   subgraph group_lsp["packages/lsp"]
     pkg_lsp["lsp"]
     pkg_lsp_local["lsp-local"]
@@ -182,8 +201,23 @@ flowchart TD
   pkg_acp_snapshot --> pkg_invariants
   pkg_loader_smoke --> pkg_invariants
   pkg_app_boot --> pkg_invariants
+  pkg_client_connection --> pkg_invariants
+  pkg_client_i18n --> pkg_invariants
+  pkg_client_runtime --> pkg_invariants
+  pkg_client_ui_conversation --> pkg_invariants
+  pkg_client_ui_layout --> pkg_invariants
+  pkg_client_ui_primitives --> pkg_invariants
+  pkg_client_ui_sidebar --> pkg_invariants
+  pkg_client_ui_slots --> pkg_invariants
+  pkg_client_ui_theme --> pkg_invariants
+  pkg_client_ui_trajectory --> pkg_invariants
+  pkg_client_web --> pkg_invariants
+  pkg_client_web_react --> pkg_invariants
   pkg_code_runtime --> pkg_invariants
   pkg_jsonrpc_demo --> pkg_invariants
+  pkg_host_apiproxy --> pkg_invariants
+  pkg_host_runtime --> pkg_invariants
+  pkg_host_webserver --> pkg_invariants
   pkg_llm --> pkg_brand
   pkg_llm --> pkg_invariants
   pkg_code_runtime_worker --> pkg_code_runtime
@@ -666,8 +700,23 @@ flowchart TD
 | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) |
 | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) |
 | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants) |
+| [`client-connection`](../packages/client/connection) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) |
+| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
 | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) |
 | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
+| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
+| [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) |
+| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
 | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
 | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) |
 | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |

+ 3 - 3
docs/persistence-catalog.md

@@ -106,7 +106,7 @@ Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src
 
 Types: [CallId](core-data-structures/core.md)
 
-Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approval/src/index.ts)
+Source: [`packages/ui/user-approval/src/index.ts:44`](../packages/ui/user-approval/src/index.ts)
 
 #### `approval/decided` — log-only
 
@@ -122,7 +122,7 @@ Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approv
 }
 ```
 
-Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approval/src/index.ts)
+Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approval/src/index.ts)
 
 #### `approval/policy` — log-only
 
@@ -138,7 +138,7 @@ Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approv
 'approval/policy': { policy: ApprovalPolicy }
 ```
 
-Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approval/src/index.ts)
+Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approval/src/index.ts)
 
 ### `assistant/*`
 

+ 107 - 0
docs/web-styling.md

@@ -0,0 +1,107 @@
+# Web GUI 样式规范
+
+> **【token 体系已换代——§1 表格仅历史参考】** 本文的 `--bg-*`/`--text-*`/`--accent` token 族与其宿主包 `packages/client/web-ui` 已随插件化重构退役。现行 token 唯一来源=`packages/client/ui-theme/src/styles/` 的 `--dsw-*` 体系(static 色阶+alias 语义层,暗色=`body[data-ds-dark-theme]` 覆写);组件对账基准=`missions/tasks/20260721-1520-web-plugin-rfc/style-spec.md`。**仍然有效**:工程约束(CSS Modules + clsx、无组件库、无 tailwind、组件禁 hardcode 色值)、字号成对写行高、间距 4 倍数、代码字体栈末位不放 monospace——这些已收编进 architecture.md §15。
+
+> 状态:原「活文档」(随 `packages/client/web-ui` 演进)。视觉基线源自对 deepseekchat 前端仓的实测调研。框架决策与工程约束由 [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md) 拍板,本文不重复论证。
+
+## 1. 设计 token 表(权威定义)
+
+所有 token 住 `packages/client/web-ui/src/style/global.css`:`:root` 亮色实值,`[data-theme='dark']` 块覆盖同名变量(未补全前列为占位)。组件 CSS 只引 token,不出现字面量色值。
+
+### 1.1 颜色(两层:注释里是 base 色板出处,变量名即语义别名)
+
+| token | 亮色实值 | 暗色(占位) | 用途 |
+| --- | --- | --- | --- |
+| `--bg-base` | `#ffffff` | `#151517` | 页面底 |
+| `--bg-layer` | `#ffffff` | `#232324` | 浮层/面板 |
+| `--bg-sidebar` | `#f9fafb` | `#1b1b1c` | 侧边栏底 |
+| `--text-primary` | `#0f1115` | `#f9fafb` | 正文 |
+| `--text-secondary` | `#61666b` | `#cfd3d6` | 次要文字 |
+| `--text-tertiary` | `#81858c` | `#adb2b8` | 辅助/说明 |
+| `--border-l1` | `rgba(0,0,0,.04)` | `rgba(255,255,255,.06)` | 弱分隔(侧边栏右缘) |
+| `--border-l2` | `rgba(0,0,0,.1)` | `rgba(255,255,255,.12)` | 常规边框 |
+| `--hover-bg` | `rgba(38,49,72,.06)` | `rgba(255,255,255,.08)` | hover 态底 |
+| `--active-bg` | `rgba(38,49,72,.1)` | `rgba(255,255,255,.14)` | 按压/激活态底 |
+| `--accent` | `#3964fe` | `#5686fe` | 品牌蓝(deepseek-500;暗提亮一档) |
+| `--accent-soft` | `#edf3fe` | `#28313f` | 淡品牌底(强调块) |
+| `--accent-item` | `#e4edfd` | `#35363a` | 侧边栏选中条目底 |
+| `--bubble-bg` | `#edf3fe` | `#2c2c2e` | 用户消息气泡底 |
+| `--ok` / `--error` / `--warn` | `#22c55e` / `#ec1313` / `#f59e0b` | 同值 | 语义状态色 |
+| `--text-on-solid` | `#ffffff` | 同值 | 实色底(accent/error 徽标等)上的文字 |
+| `--ok-soft` / `--error-soft` | `#e6faed` / `#fee2e2` | `#233c2c` / `#570c0c` | 语义状态软底(徽章);green-100/red-100,暗为 900 档 |
+| `--color-frame-mux` / `--color-frame-host` | `#8250df` / `#0969da` | 同值 | RPC 调试面板方向色(自有,非基线) |
+| `--frame-mux-soft` / `--frame-host-soft` | `rgba(130,80,223,.1)` / `rgba(9,105,218,.1)` | 同色 `.24` | 方向色软底(徽章) |
+| `--scroll-color` / `--scroll-color-hover` | `rgba(0,0,0,.08)` / `.15` | `rgba(255,255,255,.15)` / `.24` | 滚动条(`.scrollable` 专用) |
+
+### 1.2 非颜色
+
+| token | 值 | 说明 |
+| --- | --- | --- |
+| `--font-ui` | `Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | 正文栈 |
+| `--font-mono` | `Menlo, Monaco, Consolas, 'JetBrains Mono', 'Courier New', sans-serif` | 代码栈;**末位不放 monospace**(防 Windows 中文回退宋体) |
+| `--fw-strong` | `600` | 粗体统一权重 |
+| `--ease` | `cubic-bezier(.4,0,.2,1)` | 唯一缓动曲线 |
+| `--dur` / `--dur-fast` / `--dur-slow` | `.2s` / `.1s` / `.3s` | 过渡三档 |
+| `--radius-s` / `--radius-m` / `--radius-l` / `--radius-bubble` / `--radius-xl` | `8px` / `12px` / `16px` / `22px` / `24px` | 圆角语义档:小控件 / 列表条目与面板内块 / 浮层 / 气泡 / 输入卡片(基线 inputWrapper 同值);胶囊直接写 `999px` |
+| `--shadow-panel` | `0 0 1px rgba(0,0,0,.2), 0 0 4px rgba(0,0,0,.02), 0 12px 32px rgba(0,0,0,.08)` | 浮层阴影(基线 lv3) |
+| `--shadow-float` | `0 0 1px rgba(0,0,0,.24), 0 4px 12px rgba(0,0,0,.06), 0 16px 48px rgba(0,0,0,.16)` | 强浮动面板(lv3 加强档,如 RPC 调试浮层) |
+| `--shadow-card` | `0 4px 10px rgba(0,0,0,.02), 0 2px 4px rgba(0,0,0,.04)`;暗色 `none` | 输入卡片微阴影(基线:亮色同底靠边框+微影区分,暗色靠提亮底、阴影关闭) |
+
+字号与间距**不 token 化**(基线仓同款决策):字号在组件里写 px 且**成对写行高**,常用对 16/24(气泡)、14/22(UI 默认)、12/18(辅助);间距用 4 的倍数。
+
+## 2. 视觉基线(源自 deepseekchat)
+
+- 侧边栏:宽 `260px + 1px` 右边框(`--border-l1`);底色 `--bg-sidebar`。
+- 侧边栏条目:高 `40px`、圆角 `--radius-m`、字号 14px;hover 底 `--hover-bg` 或 sidebar 专属灰、**选中底 `--accent-item` 且不改文字色**。
+- 侧边栏分组标题:12px / weight 500 / `--text-tertiary` / sticky 顶部(底色同侧边栏遮滚动内容)。
+- 会话列:`max-width: 840px` 居中,<1024px 降 712px。
+- 消息流:**仅用户侧有气泡**——`--bubble-bg` 底、圆角 `--radius-bubble`、padding `10px 16px`、字号 16px/24px、`max-width: calc(100% - 88px)`;**助手侧纯文档流无底色**。
+- 消息操作条:默认 `opacity: 0`,父块 hover/focus-within 淡入(`--dur` + `--ease`)。
+- 输入卡片:与会话列同宽(840px,<1024px 降 712px)居中悬浮(距底留白带);圆角 `--radius-xl`、边框 `--border-l2`、底 `--bg-base`、阴影 `--shadow-card`;内部上下两段=textarea(16px/24px,min 2 行 max 14 行=336px,镜像 div 自增高)+ 操作行(右下嵌 34px 主圆钮);focus 无边框/阴影变化(基线同款)。
+- 输入主按钮(拍板 2026-07-20 三连,视觉参照 Codex App):32px 实心正圆图标钮(内联 SVG)——空闲=`--accent` 底白↑箭头「发送」,运行中原地变 `--accent-soft` 底 accent ■「停止」(同色系不告警、不用红)。**运行中锁输入**(拍板 3,取代早先 hover 菜单方案):textarea disabled(灰、草稿内容保留可见)、无任何排队/插话菜单,停止是唯一动作;turn 结束解禁并 refocus。键盘 Enter=发送、Ctrl/Meta+Enter=换行(运行中键盘路径随锁失效)。
+- 滚动条:近隐形、hover 加深、`scrollbar-gutter: stable` 不占布局(统一走 `.scrollable`,见 §3-9)。
+- RPC 四象限方向符(官方视觉词汇,空间隐喻:上=去 server、下=来自 server;单线=unary、双线=SSE):
+
+| 符号 | 象限 | 徽章配色 |
+| --- | --- | --- |
+| `↑` | client-request(unary 出站) | `--accent` / `--accent-soft` |
+| `↓` | server-response(unary 回包) | ok `--ok`/`--ok-soft`,error `--error`/`--error-soft` |
+| `⇟` | server-request(SSE 帧推送) | mux `--color-frame-mux`/`--frame-mux-soft`,host `--color-frame-host`/`--frame-host-soft` |
+| `⇞` | client-response(SSE 侧回应) | `--accent`/`--accent-soft` 降透明度 |
+
+## 3. 样式编码规范(review 对照打勾)
+
+1. 颜色/圆角/动效/字体栈只引 §1 token;组件 CSS 出现字面量色值即打回(渐变遮罩等特效除外,须注释说明)。
+2. 组件 CSS 禁止出现 `[data-theme]` 选择器;暗色差异只在 global.css token 表做。确需按主题换非 token 值(渐变端点等),组件定义局部 CSS 变量、主题块只覆写变量(变量桥)。
+3. 类名 camelCase;状态类用单形容词(`.active` `.show`),由 clsx 挂载:`clsx(styles.x, cond && styles.active, className)`。
+4. 对外组件必须透传 `className` 并合入根元素。
+5. 禁用 `composes`;复用靠 token 与组件抽取。
+6. `:global` 仅用于穿透第三方/跨包类名;禁止用它定义新全局类。
+7. 交互过渡一律 `var(--dur*) var(--ease)`,只过渡 opacity / transform / 背景色 / 阴影;纯 hover 展示型元素包 `@media (hover: hover)`。
+8. hover/active 底色优先用透明度制 token(叠任意海拔底色都成立),不新造实色灰。
+9. 滚动容器统一挂 global.css 的 `.scrollable` 工具类;组件内禁写 `::-webkit-scrollbar`。
+10. 媒体查询写在组件 css 尾部、贴着被覆盖规则;断点当前仅 1024px 一档(会话列降档),加第二档需先记入本文档。
+11. 动态样式 JS 侧只写 CSS 变量(`style={{'--x': v}}`),规则留在 CSS;禁止在 TSX 里拼接样式对象做主题/状态分支。
+12. 文字灰阶只用 `--text-primary/secondary/tertiary` 三级,不新造灰色。
+
+## 4. 文件组织
+
+- `src/style/global.css` 固定分区顺序:① token 表(`:root` + `[data-theme='dark']`)② 全局基础(box-sizing、body、button reset)③ 全局工具类(`.scrollable` 等,总数保持个位数)。
+- `*.module.css` 与组件同目录同名;一个组件一个 module 文件。
+- 类型声明用现有 `css-modules.d.ts` 通配;组件数超 20 再评估引入 tcm 生成精确 `.css.d.ts`。
+- PostCSS 特性白名单:当前**零插件**(平铺 CSS + 原生嵌套按需);引入 nested/custom-media 需先记入本文档。
+
+## 5. 演进规则与偏离记录
+
+- **加新 token**:先进 §1 表(含暗色占位列)再在组件使用;review 见到未入表的 `--` 新变量即打回(组件局部变量桥除外)。
+- **偏离基线**:与 §2 任一常数不一致的实现,须在下方偏离表记一行(日期/项/理由)。
+- **暗色表补全验收**:`[data-theme='dark']` 覆盖 §1 全部占位列后,用 RPC 面板 + 侧边栏 + 会话流三个界面人工/截图核对一遍,无组件级主题选择器即达标。
+
+| 日期 | 偏离项 | 理由 |
+| --- | --- | --- |
+| (空) | | |
+
+## 6. 相关文档
+
+- [web-styling-system RFC](../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)(框架五条与工程约束的裁决记录)
+- 客户端消费架构与分层协议:[Web 客户端架构 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)、[GUI 分层与 RPC 协议 RFC](../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)

+ 16 - 0
eslint.config.mjs

@@ -18,6 +18,8 @@ export default tseslint.config(
       '**/*.js',
       '**/*.mjs',
       '*.config.ts', // root tool configs (vitest, tsdown) — no project service
+      '**/tsdown.config.ts', // package build configs — in no tsconfig program, and TS syntax breaks the parserless fallback
+      'packages/client/tsdown.client.ts', // shared client build preset, same standing
     ],
   },
 
@@ -108,6 +110,20 @@ export default tseslint.config(
     },
   },
 
+  // --- client tests: the root program excludes packages/client (host/client
+  // Context merges collide), so the shared project service cannot resolve
+  // them — parse these through the client aggregate explicitly.
+  {
+    files: ['packages/client/*/tests/**/*.ts', 'scripts/client-bundle-purity.spec.ts'],
+    languageOptions: {
+      parserOptions: {
+        projectService: false,
+        project: ['./tsconfig.client.json'],
+        tsconfigRootDir: import.meta.dirname,
+      },
+    },
+  },
+
   // --- file-local duplication (all owned TypeScript) ---------------------
   {
     files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'website/**/*.ts'],

+ 449 - 87
knip.json

@@ -1,11 +1,29 @@
 {
   "$schema": "https://unpkg.com/knip@5/schema.json",
-  "exclude": ["duplicates"],
-  "ignoreBinaries": ["bwrap", "python3", "sandbox-exec"],
-  "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"],
+  "exclude": [
+    "duplicates"
+  ],
+  "ignoreBinaries": [
+    "bwrap",
+    "python3",
+    "sandbox-exec"
+  ],
+  "ignoreWorkspaces": [
+    "vendor/*",
+    "python/sdk-runtime"
+  ],
+  "ignoreDependencies": [
+    "lightningcss"
+  ],
   "workspaces": {
     ".": {
-      "project": ["scripts/**/*.ts"]
+      "entry": [
+        "scripts/**/*.mjs"
+      ],
+      "project": [
+        "scripts/**/*.ts",
+        "scripts/**/*.mjs"
+      ]
     },
     "examples": {
       "entry": [
@@ -20,11 +38,93 @@
         "*/tests/**/*.e2e.ts",
         "*/tests/**/*.snapshot.ts"
       ],
-      "project": ["**/*.ts"],
-      "ignoreDependencies": ["@deepseek-ai/.+", "@cordisjs/.+"]
+      "project": [
+        "**/*.ts"
+      ],
+      "ignoreDependencies": [
+        "@deepseek-ai/.+",
+        "@cordisjs/.+"
+      ]
+    },
+    "packages/util/home": {
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
+    },
+    "packages/host/webserver": {
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
+    },
+    "packages/host/runtime": {
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ],
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-client-.+"
+      ]
+    },
+    "packages/client/web-ui": {
+      "entry": [
+        "tests/**/*.spec.{ts,tsx}"
+      ],
+      "project": [
+        "src/**/*.{ts,tsx}",
+        "tests/**/*.{ts,tsx}"
+      ]
+    },
+    "packages/client/runtime": {
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
+    },
+    "packages/client/ui-primitives": {
+      "entry": [
+        "tests/**/*.spec.tsx"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "src/**/*.tsx",
+        "tests/**/*.tsx"
+      ]
+    },
+    "packages/client/ui-layout": {
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.spec.tsx"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "src/**/*.tsx",
+        "tests/**/*.ts",
+        "tests/**/*.tsx"
+      ],
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-client-ui-slots"
+      ]
     },
     "website": {
-      "project": ["**/*.ts"],
+      "project": [
+        "**/*.ts"
+      ],
       "ignoreDependencies": [
         "@braintree/sanitize-url",
         "cytoscape",
@@ -34,162 +134,424 @@
       ]
     },
     "packages/*/*": {
-      "entry": ["tests/**/*.spec.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/bash/bash-sandbox": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/context/time-context": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/lsp/lsp-local": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"],
-      "ignoreDependencies": ["typescript-language-server"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts",
+        "tests/fixture-server.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ],
+      "ignoreDependencies": [
+        "typescript-language-server"
+      ]
     },
     "packages/sandbox/sandbox-local": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/util/brand": {
-      "project": ["src/**/*.ts"]
+      "project": [
+        "src/**/*.ts"
+      ]
     },
     "packages/util/timeout": {
-      "entry": ["tests/**/*.spec.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/util/retention": {
-      "entry": ["tests/**/*.spec.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/support/acp-snapshot": {
-      "entry": ["tests/**/*.spec.ts", "tests/fixtures/fake-acp-agent.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/fixtures/fake-acp-agent.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/support/loader-smoke": {
-      "entry": ["tests/**/*.spec.ts", "tests/fixtures/*.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/fixtures/*.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/core/agent-loop": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/goal/goal": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/goal/goal-session": {
-      "entry": ["tests/**/*.spec.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/goal/tool-goal": {
-      "entry": ["tests/**/*.spec.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/code-runtime/code-runtime-worker": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/llm/llm-deepseek": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/llm/llm-pi-ai": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/session-title/session-title-first-message-llm": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/context/workspace-context": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/util/paths": {
-      "entry": ["tests/**/*.spec.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/web/web-search-exa": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/web/web-search-perplexity": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/workflow/workflow-workerthread": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/web/web-search-deepseek": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/examples/acp-demo": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/ui/jsonrpc": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/ui/commands": {
-      "entry": ["tests/**/*.spec.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/examples/tui-demo": {
-      "entry": ["tests/**/*.spec.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/examples/cli-demo": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/ui/tui": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.snapshot.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.snapshot.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/examples/jsonrpc-demo": {
-      "project": ["src/**/*.ts"]
+      "project": [
+        "src/**/*.ts"
+      ]
     },
     "packages/sdk/create-sdk": {
-      "entry": ["src/bin.ts", "tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/**/*.snapshot.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "src/bin.ts",
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts",
+        "tests/**/*.snapshot.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/sdk/scripts": {
-      "entry": ["src/bin.ts", "tests/**/*.spec.ts", "tests/**/*.snapshot.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"],
-      "ignoreDependencies": ["node-addon-require-builtin"]
+      "entry": [
+        "src/bin.ts",
+        "tests/**/*.spec.ts",
+        "tests/**/*.snapshot.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ],
+      "ignoreDependencies": [
+        "node-addon-require-builtin"
+      ]
     },
     "packages/subagent/subagent-spawn": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/subagent/subagent-acp": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts",
+        "tests/mock-acp-server.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/subagent/subagent-subprocess": {
-      "entry": ["tests/**/*.spec.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/fs/tool-fs": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
     },
     "packages/fs/tool-fs-search": {
-      "entry": ["tests/**/*.spec.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"],
-      "ignoreBinaries": ["rg"]
+      "entry": [
+        "tests/**/*.spec.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ],
+      "ignoreBinaries": [
+        "rg"
+      ]
     },
     "packages/mcp/mcp-client": {
-      "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"],
-      "project": ["src/**/*.ts", "tests/**/*.ts"],
-      "ignoreDependencies": ["@modelcontextprotocol/server-everything", "@modelcontextprotocol/server-filesystem"]
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/**/*.e2e.ts",
+        "tests/fixture-server.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ],
+      "ignoreDependencies": [
+        "@modelcontextprotocol/server-everything",
+        "@modelcontextprotocol/server-filesystem"
+      ]
+    },
+    "packages/client/web": {
+      "project": [
+        "src/**/*.ts",
+        "src/**/*.tsx",
+        "tests/**/*.tsx"
+      ],
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-client-ui-theme",
+        "@deepseek-ai/dsh-client-connection"
+      ]
+    },
+    "apps/web": {
+      "entry": [
+        "tests/**/*.e2e.ts",
+        "tests/support.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ],
+      "ignoreDependencies": [
+        "@deepseek-ai/dsh-client-runtime",
+        "@deepseek-ai/dsh-client-ui-primitives",
+        "@deepseek-ai/dsh-client-ui-slots",
+        "@deepseek-ai/dsh-client-web-react",
+        "@types/react",
+        "@types/react-dom",
+        "react",
+        "react-dom"
+      ]
     }
   }
 }

+ 11 - 2
package.json

@@ -10,12 +10,14 @@
   "workspaces": [
     "vendor/*",
     "packages/*/*",
+    "apps/*",
     "website"
   ],
   "scripts": {
     "build": "tsc -b tsconfig.build.json && tsdown",
+    "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build",
     "clean:build": "rm -rf .typecheck packages/*/*/lib vendor/*/lib *.tsbuildinfo",
-    "typecheck": "tsc -b tsconfig.json",
+    "typecheck": "tsc -b tsconfig.json tsconfig.client.json",
     "lint": "eslint .",
     "lint:fix": "eslint . --fix",
     "duplication": "jscpd --config .jscpd.json packages scripts",
@@ -25,7 +27,9 @@
     "test:snapshot": "vitest run --config vitest.snapshot.config.ts",
     "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",
     "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",
-    "check:all": "tsx scripts/run-gates.ts all",
+    "test:web": "npm run build:web && vitest run --config vitest.web.config.ts",
+    "test:gui": "vitest run packages/client packages/host",
+    "check:all": "tsx scripts/run-gates.ts manual-push",
     "check:ci": "tsx scripts/run-gates.ts ci-primary",
     "check:ci:static": "tsx scripts/run-gates.ts ci-static",
     "check:ci:lint": "tsx scripts/run-gates.ts ci-lint",
@@ -60,6 +64,7 @@
     "verify-node-next-types": "tsx scripts/verify-node-next-types.ts",
     "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts",
     "verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
+    "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts",
     "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts",
     "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check",
     "gen-cordis-api": "tsx scripts/gen-cordis-api.ts",
@@ -85,11 +90,14 @@
     "demo:code-mode": "node scripts/demo-code-mode.mjs",
     "demo:cordis": "node --expose-internals --import tsx packages/examples/tui-demo/src/bin.ts examples/cordis-agent/cordis.yml",
     "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
+    "demo:web": "npm run build:web && node --import tsx apps/cli/src/bin.ts web",
     "postinstall": "node scripts/install-lefthook.mjs"
   },
   "devDependencies": {
     "@agentclientprotocol/sdk": "0.25.1",
     "@stylistic/eslint-plugin": "^5.10.0",
+    "@testing-library/dom": "^10.4.1",
+    "@testing-library/react": "^16.3.2",
     "@types/js-yaml": "^4.0.9",
     "@types/jsdom": "^28.0.3",
     "@types/mdast": "^4.0.4",
@@ -103,6 +111,7 @@
     "jsdom": "29.1.1",
     "knip": "^6.16.1",
     "lefthook": "^2.1.9",
+    "lightningcss": "^1.32.0",
     "mdast-util-from-markdown": "^2.0.3",
     "mdast-util-gfm": "^3.1.0",
     "mermaid": "11.16.0",

+ 71 - 0
packages/client/AGENTS.md

@@ -0,0 +1,71 @@
+# AGENTS.md — Web client stack
+
+Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md); read the two architecture notes linked below before structural changes.
+
+Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<name>`.
+
+## Layering red lines
+
+The stack is three layers with one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
+
+1. **Data object layer** (`web-runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine). Zero React imports — grep-assertable.
+2. **Hooks layer** (`web-ui/src/hooks`, pure data): subscribes to object snapshots via `useSyncExternalStore`, exposes plain-data handles. No JSX, no DOM.
+3. **Presentation components** (`web-ui`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; they receive data and callbacks through props only.
+
+Non-negotiables across the layers:
+
+- **No business objects in the store.** zustand carries cross-view presentation state only (`rpcLog`, `ui`, `connection` slices). Sessions, frames, and connections live in the object layer. View-local facts (selection, expansion) stay in component state, not the store.
+- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
+- **Notifier dual-channel discipline**: `notifyNow` only as the direct echo of a user gesture; frame-driven updates always go through `markDirty` (microtask-batched). See `web-runtime/src/session/notifier.ts`.
+- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
+
+## Directory regime (`web-ui/src`)
+
+> Shell restructure in progress: the tree is converging to this layout (today's `components/{conversation,sessions,panels}` migrate into it); the regime below is the target every new feature follows now.
+
+Two-level feature directories, one contributor per directory — physical conflict avoidance:
+
+```
+web-ui/src/
+  shell/                  # AppShell + the three slot registries + builtins
+  leftmenu/<bar>/         # one directory per left-nav bar (sessions, rpclog, …)
+  sessiontabs/<tab>/      # one directory per session tab (conversation, gantt, …)
+  components/             # shared leaves (MessageText, JsonBlock, …)
+  hooks/ utils/ style/    # cross-cutting; not feature-owned
+```
+
+- `leftmenu/<a>` must not import `leftmenu/<b>` or `sessiontabs/*` (and vice versa). Anything two features need sinks into `components/`.
+- Bars, tabs, and detail blocks register through the `shell/` registries (module-level map, `register*()` returns the disposer — same shape as `toolCardRegistry`). v1 registration is static in `shell/builtins.ts`; plugin-driven registration later calls the same functions.
+- **Claiming a placeholder slot**: pick a `placeholder: true` tab (or add a bar) in `shell/builtins.ts`, create your feature directory, and replace the placeholder component with your container. Don't build features outside this regime.
+
+## Styling
+
+[docs/web-styling.md](../../docs/web-styling.md) is authoritative. In short: design tokens live in `web-ui/src/style/global.css` (`:root` light values, `[data-theme='dark']` overrides); component CSS references tokens only — no literal color values. CSS Modules + `clsx`; no component library, no tailwind ([framework ruling](../../.agents/notes/implemented/process/2026-07-19-web-styling-system.md)). Product copy is Chinese; code comments are English.
+
+## Testing and coverage
+
+The GUI test structure (three tiers, lane map) is settled in the [GUI testing system note](../../.agents/notes/implemented/process/2026-07-20-gui-testing-system.md); repo-wide policy in [docs/testing.md](../../docs/testing.md).
+
+- **Both client packages are inside the per-file 100% coverage gate** (`pnpm run test:coverage`). `web-runtime` is covered by node-env object/protocol suites; `web-ui` rides the jsdom lane. Genuinely unreachable defensive arms take a `/* v8 ignore -- <reason> */` comment with a real reason, never a bare ignore.
+- **web-ui specs are end-to-end behavior checks, not unit tests.** A jsdom spec renders the component with realistic props (or a driven fixture runtime) and asserts what the user would see — never class names, hook internals, or render counts. Components are consumables: behavior-shaped specs survive a rewrite, implementation-shaped specs don't.
+- The jsdom environment comes from a per-file `// @vitest-environment jsdom` pragma on the spec's first line — the shared config stays node-env. Start a new spec from an existing one (`web-ui/tests/tool-card.spec.tsx` is a good template).
+- **Each tier asserts its own layer.** Data-layer semantics (state machines, wire shapes, reference stability) belong to the `web-runtime` and `apiproxy` suites — don't re-assert them from component specs.
+
+## Before you push: the local check ladder
+
+Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
+
+1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
+2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`).
+3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
+
+If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.
+
+## New component checklist
+
+1. Claim the slot (see the directory regime above): one feature, one directory.
+2. Build the container in your feature directory; keep leaves pure-props. Wire data through the hooks layer, not by importing business objects into components.
+3. Copy a neighbouring jsdom spec into `web-ui/tests/`, keep it behavior-shaped: start from the happy path and the edge states, then widen until the component's branches are covered — the coverage gate applies; only the assertion style stays behavior-level.
+4. Tokens only in CSS; Chinese product copy; English comments.
+5. `pnpm run test:gui` green (plus `test:web` if you touched the build surface).
+6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the three GUI notes above are the precedents to extend.

+ 16 - 0
packages/client/connection/README.md

@@ -0,0 +1,16 @@
+# @deepseek-ai/dsh-client-connection
+
+Wire consumer layer (moved verbatim from web-runtime): IApiClient family (WebApiClient/FixtureApiClient), ConnectionController (SSE dual-stream + backoff reconnect), WEB_EVENTS. Contract: api-contracts v3 §3, export inventory in §3.2.
+
+## Model Experience
+
+None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **history's implicit resume is arguable** — opening history on an unattached session pulls an agent up host-side; the pure-persistence-read alternative is recorded in the rt-core reconciliation ledger, unchanged in P-I. This package's consumers see it as latency on first open.
+- **`ToolEventView`/`ToolCallView`/`ToolResultView` re-exports are scheduled for removal** — they fall when the toolview migration deletes the host `viewFor` line (presentation belongs to the client); the fixture keeps a local `viewFor` mirror until then.

+ 53 - 0
packages/client/connection/package.json

@@ -0,0 +1,53 @@
+{
+  "name": "@deepseek-ai/dsh-client-connection",
+  "description": "Wire consumer layer: IApiClient subclasses, ConnectionController (SSE dual-stream + reconnect), fixture api (no cordis)",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dshClient": {
+    "inject": [],
+    "platform": "web",
+    "immediately": true
+  },
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "peerDependencies": {
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 46 - 0
packages/client/connection/src/client/api.ts

@@ -0,0 +1,46 @@
+// Central contract re-export point: every contract import inside
+// web-runtime goes through this single file.
+// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
+// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
+// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
+// The ./api and ./client subpath exports are the browser-safe channels added for this.
+
+export type {
+  ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
+  ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
+} from '@deepseek-ai/dsh-host-apiproxy/api'
+export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
+export type {
+  RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
+  ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
+} from '@deepseek-ai/dsh-host-apiproxy/api'
+export { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
+export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
+export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
+export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
+export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
+
+import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
+
+/**
+ * Unwrap a unary response: RpcResponse<T> -> RpcResult<T> (business code only
+ * cares about the result slot).
+ * @param response - the unary response.
+ * @returns its result slot.
+ */
+export function resultOf<T>(response: RpcResponse<T>): RpcResult<T> {
+  return response.result
+}
+
+/**
+ * Fold a transport exception into the RpcResult error branch (unified error
+ * surface; 'internal' as the catch-all code).
+ * @param error - the thrown value from the carrier.
+ * @returns the error branch of an RpcResult.
+ */
+export function transportError<T>(error: unknown): RpcResult<T> {
+  return {
+    ok: false,
+    error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
+  }
+}

+ 190 - 0
packages/client/connection/src/client/connection.ts

@@ -0,0 +1,190 @@
+import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
+
+/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists
+ *  these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */
+export interface ConnectionConfig {
+  /** First-retry backoff cap in ms (jittered: actual delay is cap/2..cap). */
+  backoffBaseMs?: number
+  /** Exponential growth factor per consecutive failed attempt. */
+  backoffFactor?: number
+  /** Upper bound for the backoff cap in ms. */
+  backoffMaxMs?: number
+  /** Cap on waiting for both streams' onOpen before onConnected, in ms. The strict handshake
+   *  (audit C2) waits for mux+host stream establishment plus describe; a carrier that never
+   *  fires onOpen (misbehaving proxy) must not wedge the connection forever — on timeout the
+   *  generation proceeds as connected and the live-gap repair path (audit S3) covers stragglers. */
+  streamOpenTimeoutMs?: number
+}
+
+const CONNECTION_DEFAULTS: Required<ConnectionConfig> = {
+  backoffBaseMs: 500,
+  backoffFactor: 2,
+  backoffMaxMs: 10_000,
+  streamOpenTimeoutMs: 3_000,
+}
+
+function sleep(ms: number, signal: AbortSignal): Promise<void> {
+  return new Promise((resolve) => {
+    const t = setTimeout(done, ms)
+    signal.addEventListener('abort', done, { once: true })
+    function done(): void {
+      clearTimeout(t)
+      signal.removeEventListener('abort', done)
+      resolve()
+    }
+  })
+}
+
+/** Coarse connection state for the UI (audit C1): 'connected' after each generation's handshake,
+ *  'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */
+export type ConnectionState = 'connected' | 'reconnecting'
+
+/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to
+ *  SessionManager. */
+export interface ConnectionSinks {
+  onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
+  onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
+  /** After each connection generation is established (both streams open + describe succeeded), first connect included. */
+  onConnected?: () => void
+  /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
+   *  span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
+  onStateChange?: (state: ConnectionState) => void
+}
+
+/**
+ * Opens both streams and keeps iterating (pull mode: nothing reads the socket and the tap
+ * never fires unless someone for-awaits), reconnecting with exponential backoff on loss.
+ * State (generation/attempt) is instance-private, never in the store.
+ * The pump body feeds each frame to a sink (sink exceptions must
+ * not kill the pump — a broken business layer must not drag down the connection layer).
+ */
+export class ConnectionController {
+  private generation = 0
+  private attempt = 0
+  private current: AbortController | null = null
+  private running = false
+  private lastState: ConnectionState | null = null
+  private readonly config: Required<ConnectionConfig>
+
+  constructor(
+    private readonly api: IApiClient,
+    private readonly sinks: ConnectionSinks = {},
+    config: ConnectionConfig = {},
+  ) {
+    this.config = { ...CONNECTION_DEFAULTS, ...config }
+  }
+
+  /** Idempotent: begin the connect/pump/reconnect loop. */
+  start(): void {
+    if (this.running) return
+    this.running = true
+    void this.loop()
+  }
+
+  /** Stop the loop and abort the current generation's streams. */
+  stop(): void {
+    this.running = false
+    this.current?.abort()
+    this.current = null
+  }
+
+  private backoffDelay(attempt: number): number {
+    const { backoffBaseMs, backoffFactor, backoffMaxMs } = this.config
+    const cap = Math.min(backoffMaxMs, backoffBaseMs * backoffFactor ** Math.max(0, attempt - 1))
+    return cap / 2 + Math.random() * (cap / 2)
+  }
+
+  /** Read through a method: stop() flips the flag across awaits, so narrowing from the loop condition must not stick. */
+  private isRunning(): boolean {
+    return this.running
+  }
+
+  private async loop(): Promise<void> {
+    while (this.running) {
+      const gen = ++this.generation
+      const ac = new AbortController()
+      this.current = ac
+
+      /* v8 ignore next -- initializer placeholder: the Promise executor
+       * below runs synchronously and replaces it before anyone can call it. */
+      let muxOpened = (): void => {}
+      /* v8 ignore next -- same placeholder pattern as muxOpened. */
+      let hostOpened = (): void => {}
+      const streamsOpen = Promise.all([
+        new Promise<void>((resolve) => { muxOpened = resolve }),
+        new Promise<void>((resolve) => { hostOpened = resolve }),
+      ])
+
+      const failed = new Promise<void>((resolve) => {
+        const settle = (): void => {
+          if (gen === this.generation && !ac.signal.aborted) ac.abort()
+          resolve()
+        }
+        void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle)
+        void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle)
+      })
+
+      try {
+        // Strict readiness handshake (audit C2): describe proves unary reachability, onOpen
+        // proves each SSE transport is established (response headers in, before any frame) —
+        // only then may onConnected fire, so the resync it triggers cannot outrun the
+        // subscribed baseline. The timeout guards against a carrier that never fires onOpen
+        // (see ConnectionConfig.streamOpenTimeoutMs).
+        const timeout = new AbortController()
+        await Promise.all([
+          this.api.host.describe({}),
+          Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
+        ])
+        timeout.abort()
+        if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
+        this.attempt = 0
+        this.emitState('connected')
+        this.callSink(this.sinks.onConnected)
+      } catch {
+        // Transport failure: treat as generation failure, fall through to the shared backoff.
+        if (!ac.signal.aborted) ac.abort()
+      }
+
+      await failed
+      if (!this.isRunning()) return
+      this.emitState('reconnecting')
+      this.attempt += 1
+      console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
+      const idle = new AbortController()
+      await sleep(this.backoffDelay(this.attempt), idle.signal)
+    }
+  }
+
+  /** Deduplicated state emission (sink isolation applies). */
+  private emitState(state: ConnectionState): void {
+    if (this.lastState === state) return
+    this.lastState = state
+    this.callSink(() => this.sinks.onStateChange?.(state))
+  }
+
+  private async pumpStream<F extends { type: string }>(
+    stream: AsyncIterable<RpcRequest<F>>,
+    sink: ((envelope: RpcRequest<F>) => void) | undefined,
+    onEnd: () => void,
+  ): Promise<void> {
+    try {
+      for await (const envelope of stream) {
+        if (envelope.payload.type === 'stream/error') break
+        if (sink !== undefined) this.callSink(() => { sink(envelope) })
+      }
+    } catch {
+      // Stream loss: converge on onEnd, which triggers the shared reconnect.
+    }
+    onEnd()
+  }
+
+  /** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
+  private callSink(fn: (() => void) | undefined): void {
+    if (fn === undefined) return
+    try {
+      fn()
+    } catch (error) {
+      console.error('[web-runtime] connection sink threw:', error)
+    }
+  }
+}

+ 560 - 0
packages/client/connection/src/client/fixture.ts

@@ -0,0 +1,560 @@
+// FixtureApi: standalone UI development without a server. Real contract shape: unary takes
+// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
+// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
+// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
+// prompt triggers a chunked streaming replay; cancel stops the replay; one resident pending
+// approval (placeholder-card material, subscribed-baseline-replay semantics: stable rpcId reuse).
+
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
+import type {
+  ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
+  RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
+  ToolCallView, ToolEventView, ToolResultView,
+} from './api.ts'
+import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
+import { AbstractApiClient, RpcId } from './api.ts'
+
+/** The fake carrier mints like a real one (business code never mints). */
+function rpcRequest<P>(payload: P): RpcRequest<P> {
+  return { rpcId: RpcId(crypto.randomUUID()), payload }
+}
+
+function text(t: string): ContentBlock[] {
+  return [{ type: 'text', text: t }]
+}
+
+function sid(id: string): SessionId {
+  return id as SessionId
+}
+
+/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
+ *  mixing reasoning blocks / tool call+result / steering / context. */
+function buildAlphaLog(): SessionEvent[] {
+  const events: Record<string, unknown>[] = []
+  let time = Date.now() - 3_600_000
+  const push = (e: Record<string, unknown>): number => {
+    const seq = events.length
+    events.push({ seq, time: (time += 800), ...e })
+    return seq
+  }
+  for (let turn = 0; turn < 60; turn++) {
+    push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
+    push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:fixture 历史消息,用于翻页与渲染验收。`), source: { kind: 'user' } } })
+    if (turn % 9 === 4) {
+      push({ type: 'context/message', surfaceOp: 'append', data: { content: text(`[fixture] 上下文注入(turn ${turn})`), source: { kind: 'plugin', plugin: 'fixture' } } })
+    }
+    push({ type: 'step/start', data: { turn, step: 0 } })
+    const withTool = turn % 5 === 2
+    const withReasoning = turn % 3 === 1
+    const blocks: ContentBlock[] = []
+    if (withReasoning) blocks.push({ type: 'reasoning', text: `思考过程 ${turn}:这是一段可折叠的 reasoning 内容。` })
+    blocks.push({ type: 'text', text: `回答 ${turn}:这是 fixture 生成的历史回复正文。` })
+    if (withTool) {
+      const callId = `fx-call-${turn}`
+      blocks.push({ type: 'tool-call', id: callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } as ContentBlock)
+      push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
+      push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'echo', arguments: `{"text":"turn ${turn}"}` } })
+      push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(`ECHO: TURN ${turn}`), isError: turn % 25 === 12 } })
+      push({ type: 'step/end', data: { turn, step: 0 } })
+      push({ type: 'step/start', data: { turn, step: 1 } })
+      push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 1, content: text(`工具结果已消化(turn ${turn})。`), provenance: { provider: 'fixture', model: 'fx-1' } } })
+      push({ type: 'step/end', data: { turn, step: 1 } })
+    } else {
+      push({ type: 'assistant/message', surfaceOp: 'append', data: { turn, step: 0, content: blocks, provenance: { provider: 'fixture', model: 'fx-1' } } })
+      push({ type: 'step/end', data: { turn, step: 0 } })
+    }
+    if (turn % 13 === 6) {
+      push({ type: 'steering/message', surfaceOp: 'append', data: { turn, content: text(`插话 ${turn}:fixture steering 消息。`), source: { kind: 'user' } } })
+    }
+    push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
+  }
+  // Three view-sample turns (60-62) for the tool-card wire acceptance: one per built-in card
+  // type. `echo` above stays presenter-less on purpose — it is the no-view fallback sample.
+  const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
+    const callId = `fx-call-${turn}`
+    push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
+    push({ type: 'user/message', surfaceOp: 'append', data: { content: text(`问题 ${turn}:${name} 样本。`), source: { kind: 'user' } } })
+    push({ type: 'step/start', data: { turn, step: 0 } })
+    push({
+      type: 'assistant/message', surfaceOp: 'append',
+      data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
+    })
+    push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
+    push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, callId, content: text(resultText), isError: false } })
+    push({ type: 'step/end', data: { turn, step: 0 } })
+    push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
+  }
+  toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
+  toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
+  toolTurn(62, 'fx-note', '{"note":"三型卡验收样本"}', '已记录')
+  return events as unknown as SessionEvent[]
+}
+
+/** Narrows a parsed-JSON field to string; fixture args are authored in-file, so non-strings only mean a typo here. */
+/* v8 ignore next -- the fallback arm is the same in-file-typo guard as the JSON.parse catch above. */
+const str = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback
+
+/** Fixture presenter registry (mirrors host viewFor): pure derivation, undefined = no view. */
+function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
+  let args: Record<string, unknown>
+  try {
+    args = JSON.parse(argsRaw) as Record<string, unknown>
+  } catch {
+    /* v8 ignore next 2 -- defensive: fixture args are authored in-file as valid JSON; only an in-file typo could reach the catch. */
+    return undefined
+  }
+  switch (name) {
+    case 'fx-bash':
+      return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
+    case 'fx-write':
+      return {
+        card: 'diff', title: `Write ${str(args.path)}`,
+        diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
+      }
+    case 'fx-note':
+      return { card: 'generic', title: '记录笔记', kind: 'edit', rawInput: args }
+    default:
+      return undefined // echo et al: the documented no-view fallback path
+  }
+}
+
+function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
+  const call = presentCall(name, argsRaw)
+  if (call === undefined) return undefined
+  switch (call.card) {
+    case 'terminal':
+      return { card: 'terminal', output: resultText, exitCode: 0 }
+    case 'diff':
+      return { card: 'diff', diffs: call.diffs }
+    case 'generic':
+      return { card: 'generic', content: text(resultText) }
+  }
+}
+
+/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */
+function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventView | undefined {
+  if (event.type === 'tool/call') {
+    const view = presentCall(event.data.name, event.data.arguments)
+    return view === undefined ? undefined : { for: 'call', view }
+  }
+  if (event.type === 'tool/result') {
+    const callId = String(event.data.callId)
+    for (let i = log.length - 1; i >= 0; i--) {
+      const candidate = log[i]
+      /* v8 ignore next -- dense-array guard: i stays within [0, log.length),
+      so the undefined arm needs a sparse log no code path builds. */
+      if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
+        const resultText = event.data.content.map(b => (b.type === 'text' ? b.text : '')).join('')
+        const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
+        return view === undefined ? undefined : { for: 'result', view }
+      }
+    }
+    return undefined // cross-page unpaired: documented default
+  }
+  return undefined
+}
+
+/**
+ * Message-boundary paging (mirrors the host's paging contract): count
+ * maxMessages messages
+ *  backwards from end, cut at a turn/start boundary.
+ Entries carry pagination-time views
+ *  (the host analogue computes viewFor per entry at page time). */
+function pageOf(
+  log: readonly SessionEvent[],
+  beforeSeq: number | undefined,
+  maxMessages: number,
+): { events: HistoryEntry[]; hasMore: boolean } {
+  const end = beforeSeq === undefined ? log.length : Math.max(0, Math.min(beforeSeq, log.length))
+  let start = 0
+  let messages = 0
+  for (let i = end - 1; i >= 0; i--) {
+    const event = log[i]
+    /* v8 ignore next -- dense-array guard: log seqs are array indexes, i stays within [0, end). */
+    if (event === undefined) break
+    if (event.type === 'user/message' || event.type === 'assistant/message' || event.type === 'steering/message') messages++
+    if (event.type === 'turn/start' && messages >= maxMessages) {
+      start = i
+      break
+    }
+  }
+  const events = log.slice(start, end).map((event): HistoryEntry => {
+    const view = viewFor(event, log)
+    return view === undefined ? { event } : { event, view }
+  })
+  return { events, hasMore: start > 0 }
+}
+
+interface StreamConn<F> {
+  push(envelope: RpcRequest<F>): void
+}
+
+/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
+ *  outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
+ *  piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the
+ *  client's signal (timing hook: simulated connection loss). */
+class FxInbox<F> implements StreamConn<F> {
+  private readonly inbox: RpcRequest<F>[] = []
+  private wake: (() => void) | null = null
+  private broken = false
+
+  push(envelope: RpcRequest<F>): void {
+    this.inbox.push(envelope)
+    this.wake?.()
+  }
+
+  breakNow(): void {
+    this.broken = true
+    this.wake?.()
+  }
+
+  /** Read through a method: breakNow()/abort flip state across yields, so narrowing from the loop condition must not stick. */
+  private isLive(signal: AbortSignal): boolean {
+    return !signal.aborted && !this.broken
+  }
+
+  async *drain(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
+    const onAbort = (): void => this.wake?.()
+    signal.addEventListener('abort', onAbort)
+    try {
+      while (this.isLive(signal)) {
+        while (this.inbox.length > 0) yield this.inbox.shift() as RpcRequest<F>
+        if (!this.isLive(signal)) break
+        await new Promise<void>((resolve) => {
+          this.wake = resolve
+        })
+        this.wake = null
+      }
+    } finally {
+      signal.removeEventListener('abort', onAbort)
+    }
+  }
+}
+
+/**
+ * In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material).
+ * @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
+ */
+export function createFixtureApi(): ApiProxy {
+  const sessions: SessionSummary[] = [
+    { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, cwd: '/tmp/fixture' },
+    { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' },
+    { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
+  ]
+  const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
+  const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
+  let nextSession = 1
+  let nextRpc = 1
+  const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
+  /** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
+  const pendingApprovalRpcId = mint()
+
+  const muxConns = new Set<StreamConn<MuxFrame>>()
+  const hostConns = new Set<StreamConn<HostFrame>>()
+  const emitMux = (frame: MuxFrame): void => {
+    for (const conn of muxConns) conn.push({ rpcId: mint(), payload: frame })
+  }
+  const emitHost = (frame: HostFrame): void => {
+    for (const conn of hostConns) conn.push({ rpcId: mint(), payload: frame })
+  }
+
+  /** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */
+  function ok<P, T>(request: RpcRequest<P>, value: T): Promise<RpcResponse<T>> {
+    return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
+  }
+  function err<P, T>(request: RpcRequest<P>, error: Extract<RpcResult<T>, { ok: false }>['error']): Promise<RpcResponse<T>> {
+    return Promise.resolve({ rpcId: request.rpcId, result: { ok: false, error } })
+  }
+
+  const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id)
+  const setRunning = (id: SessionId, running: boolean): void => {
+    const summary = summaryOf(id)
+    if (summary === undefined || summary.running === running) return
+    summary.running = running
+    emitHost({ type: 'host/session-status', sessionId: id, running })
+  }
+  const logOf = (id: SessionId): SessionEvent[] => {
+    let log = logs.get(id)
+    if (log === undefined) {
+      log = []
+      logs.set(id, log)
+    }
+    return log
+  }
+  const append = (id: SessionId, e: Record<string, unknown>): void => {
+    const log = logOf(id)
+    const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent
+    log.push(event)
+    // Emission-time view derivation (mirrors the host's live path).
+    const view = viewFor(event, log)
+    /* v8 ignore next 3 -- the view-present arm needs a live tool/call emission,
+    but the fixture replay produces text-only turns; view vocabulary is
+    exercised through the history samples (turns 60-62). */
+    emitMux(view === undefined
+      ? { type: 'session/event', sessionId: id, event }
+      : { type: 'session/event', sessionId: id, event, view })
+  }
+
+  /** At most one in-flight replay per session; cancel clears it. */
+  const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
+
+  /** history transit delay (timing hooks below); the page snapshot is taken at request time, like a real host. */
+  let historyDelayMs = 0
+  /** One-shot history failure (timing hook: the doomed in-flight request of the S4 reconnect scenario). */
+  let failNextHistory = false
+  /** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
+  const streamBreakers = new Set<() => void>()
+
+  // Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
+  // is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
+  // browser acceptance runs create slow-history, lost-frame, and reconnect
+  // windows a real host produces naturally.
+  const timingHooks = {
+    setHistoryDelay(ms: number): void {
+      historyDelayMs = ms
+    },
+    /** Fail the NEXT history call (after its transit delay) with a transport-level throw. */
+    failNextHistory(): void {
+      failNextHistory = true
+    },
+    /** Log append + mux emit (the normal live path). */
+    appendUser(id: string, msg: string): void {
+      append(sid(id), { type: 'user/message', surfaceOp: 'append', data: { content: text(msg), source: { kind: 'user' } } })
+    },
+    /** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
+    appendSilent(id: string, msg: string): void {
+      const log = logOf(sid(id))
+      log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: { content: text(msg), source: { kind: 'user' } } } as unknown as SessionEvent)
+    },
+    /** End every open stream generator (client sees both streams close -> reconnect + resync path). */
+    breakStreams(): void {
+      for (const breakNow of [...streamBreakers]) breakNow()
+    },
+  }
+  ;(globalThis as Record<string, unknown>).__fxTiming = timingHooks
+
+  /** Prompt replay: chunk typewriter (80ms/frame) -> assistant/message finalize -> turn/end + running flip. */
+  const startReply = (id: SessionId, turn: number, replyText: string): void => {
+    const step = 0
+    append(id, { type: 'step/start', data: { turn, step } })
+    append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
+    /* v8 ignore next -- the ?? arm needs a null match, but replyText is never empty (prompt always prefixes 回声). */
+    const pieces = replyText.match(/.{1,6}/gu) ?? [replyText]
+    let i = 0
+    const finish = (aborted: boolean): void => {
+      replays.delete(id)
+      const done = pieces.slice(0, i).join('')
+      append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } })
+      append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(aborted ? `${done}(已中断)` : done), provenance: { provider: 'fixture', model: 'fx-1' } } })
+      append(id, { type: 'step/end', data: { turn, step } })
+      append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } })
+      setRunning(id, false)
+    }
+    const tick = (): void => {
+      const piece = pieces[i]
+      if (piece === undefined) {
+        finish(false)
+        return
+      }
+      i++
+      append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index: 0, text: piece } } })
+      replays.set(id, { timer: setTimeout(tick, 80), finish })
+    }
+    replays.set(id, { timer: setTimeout(tick, 80), finish })
+  }
+
+  return {
+    sessions: {
+      list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
+      create: (request) => {
+        const created: SessionSummary = {
+          sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
+        }
+        sessions.push(created)
+        emitHost({ type: 'host/session-added', sessionId: created.sessionId })
+        return ok(request, { sessionId: created.sessionId })
+      },
+      history: async (request) => {
+        const log = logs.get(request.payload.sessionId) ?? []
+        // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency).
+        const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50)
+        const doomed = failNextHistory
+        failNextHistory = false
+        const delay = historyDelayMs
+        if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
+        if (doomed) throw new Error('fixture: simulated history transport failure')
+        return ok(request, page)
+      },
+      prompt: (request) => {
+        const { sessionId: id, mode, content } = request.payload
+        const summary = summaryOf(id)
+        if (summary === undefined) {
+          return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
+        }
+        summary.updatedAt = Date.now()
+        const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
+        if (mode === 'steer' && replays.has(id)) {
+          // Steering: insert a steering message into the current turn; the replay continues.
+          /* v8 ignore next -- the ?? arm needs a missing counter, but a live replay implies a prior prompt already set it. */
+          const turn = (nextTurn.get(id) ?? 1) - 1
+          append(id, { type: 'steering/message', surfaceOp: 'append', data: { turn, content, source: { kind: 'user' } } })
+          return ok(request, { accepted: true as const })
+        }
+        const turn = nextTurn.get(id) ?? 0
+        nextTurn.set(id, turn + 1)
+        setRunning(id, true)
+        append(id, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
+        append(id, { type: 'user/message', surfaceOp: 'append', data: { content, source: { kind: 'user' } } })
+        startReply(id, turn, `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`)
+        return ok(request, { accepted: true as const })
+      },
+      cancel: (request) => {
+        const replay = replays.get(request.payload.sessionId)
+        if (replay !== undefined) {
+          clearTimeout(replay.timer)
+          replay.finish(true)
+        } else {
+          setRunning(request.payload.sessionId, false)
+        }
+        return ok(request, { accepted: true as const })
+      },
+    },
+    host: {
+      describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }),
+    },
+    events: {
+      async *mux(_request, signal) {
+        const conn = new FxInbox<MuxFrame>()
+        muxConns.add(conn)
+        const breakNow = (): void => { conn.breakNow() }
+        streamBreakers.add(breakNow)
+        // Open baseline: subscribed for attached (running) sessions + pending approval replay (stable rpcId).
+        for (const s of sessions) {
+          if (!s.running) continue
+          conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: (logs.get(s.sessionId)?.length ?? 0) - 1 } })
+        }
+        conn.push({
+          rpcId: pendingApprovalRpcId,
+          payload: {
+            type: 'approval/requested', sessionId: sid('fx-alpha'),
+            approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract<MuxFrame, { type: 'approval/requested' }>['approvalId'],
+            toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
+          },
+        })
+        try {
+          yield* conn.drain(signal)
+        } finally {
+          streamBreakers.delete(breakNow)
+          muxConns.delete(conn)
+        }
+      },
+      async *host(_request, signal) {
+        const conn = new FxInbox<HostFrame>()
+        hostConns.add(conn)
+        const breakNow = (): void => { conn.breakNow() }
+        streamBreakers.add(breakNow)
+        // Periodic material (the RPC-panel acceptance's clear-then-new-frames step depends on it): flip fx-gamma every 5s.
+        // fx-gamma only: never touch fx-alpha's running semantics (the conversation replay drives that).
+        const timer = setInterval(() => {
+          const gamma = summaryOf(sid('fx-gamma'))
+          /* v8 ignore next -- the undefined arm needs fx-gamma deleted, but the fixture never removes sessions. */
+          if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running)
+        }, 5000)
+        try {
+          yield* conn.drain(signal)
+        } finally {
+          clearInterval(timer)
+          streamBreakers.delete(breakNow)
+          hostConns.delete(conn)
+        }
+      },
+    },
+    respond(message: ClientResponse): Promise<RpcReceipt> {
+      // The v1 UI never answers (PendingCard is visible but not answerable); implemented for type completeness, always not-pending.
+      void message
+      return Promise.resolve({ accepted: false, reason: 'not-pending' })
+    },
+  }
+}
+
+/**
+ * Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it
+ * overrides the protocol-level virtuals (callUnary/openMux/openHost/respond) to dispatch
+ * straight into the in-memory ApiProxy — while still minting rpcIds, fabricating the four
+ * named full forms, and feeding the same tap as a real carrier. Delete when the fixture moves
+ * to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
+ */
+export class FixtureApiClient extends AbstractApiClient {
+  private readonly api = createFixtureApi()
+
+  protected doFetch(): Promise<Response> {
+    throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable')
+  }
+
+  protected override async callUnary<K extends keyof RpcMethodMap>(
+    method: K,
+    payload: RequestPayload<K>,
+  ): Promise<RpcResponse<ResponseValue<K>>> {
+    const request = rpcRequest(payload)
+    const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
+    this.onEnvelope(full)
+    const response = await this.dispatch(method, request as RpcRequest<never>) as RpcResponse<ResponseValue<K>>
+    const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
+    this.onEnvelope(fullResponse)
+    return response
+  }
+
+  /** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
+  private dispatch(method: keyof RpcMethodMap, request: RpcRequest<never>): Promise<RpcResponse<unknown>> {
+    switch (method) {
+      case 'session.list': return this.api.sessions.list(request)
+      case 'session.create': return this.api.sessions.create(request)
+      case 'session.history': return this.api.sessions.history(request)
+      case 'session.prompt': return this.api.sessions.prompt(request)
+      case 'session.cancel': return this.api.sessions.cancel(request)
+      case 'host.describe': return this.api.host.describe(request)
+    }
+  }
+
+  protected override openMux(
+    payload: { since?: Record<SessionId, number> },
+    signal: AbortSignal,
+    onOpen?: () => void,
+  ): AsyncIterable<RpcRequest<MuxFrame>> {
+    return this.tapStream(this.api.events.mux(rpcRequest(payload), signal), onOpen)
+  }
+
+  protected override openHost(
+    payload: Record<never, never>,
+    signal: AbortSignal,
+    onOpen?: () => void,
+  ): AsyncIterable<RpcRequest<HostFrame>> {
+    return this.tapStream(this.api.events.host(rpcRequest(payload), signal), onOpen)
+  }
+
+  private async *tapStream<F extends MuxFrame | HostFrame>(
+    stream: AsyncIterable<RpcRequest<F>>,
+    onOpen?: () => void,
+  ): AsyncGenerator<RpcRequest<F>> {
+    // No HTTP here: the in-memory stream is established the moment iteration starts (mirrors
+    // readSse firing onOpen after response headers, before any frame).
+    onOpen?.()
+    for await (const envelope of stream) {
+      const full: ServerRequest = { type: 'server-request', rpcId: envelope.rpcId, method: envelope.payload.type, payload: envelope.payload }
+      this.onEnvelope(full)
+      yield envelope
+    }
+  }
+
+  /**
+   * Deliver a client response to the in-memory contract impl (no HTTP POST),
+   * echoing the envelope to the observation tap like every other path.
+   * @param message - the client-response envelope answering a server request.
+   * @returns the carrier receipt from the fixture impl.
+   */
+  override async respond(message: ClientResponse): Promise<RpcReceipt> {
+    this.onEnvelope(message)
+    return this.api.respond(message)
+  }
+}

+ 76 - 0
packages/client/connection/src/client/index.ts

@@ -0,0 +1,76 @@
+/**
+ * Browser half of the wire consumer layer (contract: api-contracts v3
+ * section 3; export inventory = v3 §3.2). The wire is this package's client
+ * half in its entirety — apply mounts ctx.connection: the shared api client
+ * plus the connection controller handle. Mode selection (?fixture) happens
+ * here so the rest of the client tree is mode-blind; the controller's sinks
+ * are wired by the runtime plugin (object layer), which injects this service.
+ */
+import type { Context } from 'cordis'
+import type { IApiClient } from './api.ts'
+import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
+import { FixtureApiClient } from './fixture.ts'
+import { WebApiClient } from './web-api-client.ts'
+
+// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
+export type {
+  ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
+  ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
+  ToolCallView, ToolResultView,
+  RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
+  ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
+  IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
+} from './api.ts'
+export { RpcId, AbstractApiClient, resultOf, transportError } from './api.ts'
+
+// ---- Connection loop ----
+export { ConnectionController } from './connection.ts'
+export type { ConnectionConfig, ConnectionSinks, ConnectionState }
+
+// ---- Platform client subclasses ----
+export { WebApiClient } from './web-api-client.ts'
+export { FixtureApiClient, createFixtureApi } from './fixture.ts'
+
+
+/** Required services (none — this is the wire root). */
+export const inject: string[] = []
+
+/**
+ * The ctx.connection service surface: the api client plus a one-shot
+ * controller starter (the runtime plugin supplies sinks when its object layer
+ * is ready — connection stays consumer-agnostic).
+ */
+export interface ConnectionHandle {
+  /** Shared api client (fixture or real, decided at boot from the page URL). */
+  readonly api: IApiClient
+  /**
+   * Start the connect/pump/reconnect loop with the consumer's frame sinks.
+   * One consumer owns the streams (the runtime object layer); a second call
+   * throws.
+   * @param sinks - frame/state callbacks.
+   * @param config - reconnect/backoff tunables.
+   * @returns stop handle for the loop.
+   */
+  start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void }
+}
+
+/**
+ * Client plugin body: pick the api by page mode and provide ctx.connection.
+ * @param ctx - client cordis context.
+ */
+export function apply(ctx: Context): void {
+  const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture')
+  const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
+  let started = false
+  const handle: ConnectionHandle = {
+    api,
+    start(sinks, config) {
+      if (started) throw new Error('connection: the stream loop is already owned by another consumer')
+      started = true
+      const controller = new ConnectionController(api, sinks, config ?? {})
+      controller.start()
+      return { stop: () => { controller.stop() } }
+    },
+  }
+  ctx.provide('connection', handle)
+}

+ 12 - 0
packages/client/connection/src/client/web-api-client.ts

@@ -0,0 +1,12 @@
+// WebApiClient: the browser platform subclass — transport = global fetch over same-origin
+// /api/* (base resolution handled by AbstractApiClient). Envelope observation comes from the
+// base batching aspect; subscribers attach via subscribeEnvelopes (see boot).
+
+import { AbstractApiClient } from './api.ts'
+
+/** Browser platform subclass: transport = global fetch over same-origin /api/*. */
+export class WebApiClient extends AbstractApiClient {
+  protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
+    return globalThis.fetch(input, init)
+  }
+}

+ 10 - 0
packages/client/connection/src/index.ts

@@ -0,0 +1,10 @@
+/**
+ * Connection plugin, node half. The package IS a dshClient plugin: the wire
+ * consumer layer lives in its client half in full (src/client/ — contract:
+ * api-contracts v3 section 3, inventory §3.2); consumers import the /client
+ * subpath. The empty apply exists so the plugin appears in the host Loader
+ * (lifecycle governance + dshClient discovery).
+ */
+
+/** Host plugin body — no host-side behavior for the connection plugin. */
+export function apply(_ctx: unknown): void {}

+ 32 - 0
packages/client/connection/src/invariant.ts

@@ -0,0 +1,32 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-connection`.
+ * @module @deepseek-ai/dsh-client-connection/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-connection'
+
+/** Cordis companion plugin name. */
+export const name = 'client-connection-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the pure wire layer emits no cordis events and owns no
+ * mutable cross-plugin relation — stream/reconnect sequencing is exercised
+ * directly by its behavior specs, and rpcId round-trip discipline is owned by
+ * the apiproxy contract layer.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 21 - 0
packages/client/connection/tests/api-helpers.spec.ts

@@ -0,0 +1,21 @@
+/**
+ * Contract-layer helpers: transport-error folding and response unwrapping.
+ * (The assistant block classifier half of the legacy spec lives in
+ * runtime/tests — the classifier moved there.)
+ */
+
+import { describe, expect, it } from 'vitest'
+import { RpcId, resultOf, transportError } from '../src/client/api.ts'
+
+describe('transportError', () => {
+  it('folds an Error to internal keeping the message, and stringifies non-Errors', () => {
+    expect(transportError(new Error('线断了'))).toEqual({ ok: false, error: { code: 'internal', message: '线断了', details: {} } })
+    expect(transportError('raw string')).toMatchObject({ ok: false, error: { message: 'raw string' } })
+  })
+})
+
+describe('resultOf', () => {
+  it('unwraps the result slot', () => {
+    expect(resultOf({ rpcId: RpcId('r'), result: { ok: true, value: 7 } })).toEqual({ ok: true, value: 7 })
+  })
+})

+ 65 - 0
packages/client/connection/tests/client-apply.spec.ts

@@ -0,0 +1,65 @@
+/**
+ * Connection plugin browser-half apply: ctx.connection handle mounting, mode
+ * selection off the page URL, and the single-consumer stream-loop ownership.
+ */
+import { Context } from 'cordis'
+import { afterEach, describe, expect, it } from 'vitest'
+import { apply, type ConnectionHandle } from '../src/client/index.ts'
+import { FixtureApiClient } from '../src/client/fixture.ts'
+import { WebApiClient } from '../src/client/web-api-client.ts'
+
+type Win = { location?: { search: string } }
+
+afterEach(() => {
+  delete (globalThis as Win).location
+})
+
+async function mount(): Promise<ConnectionHandle> {
+  const ctx = new Context()
+  await ctx.plugin({ apply, inject: [] })
+  const handle = ctx.get('connection') as ConnectionHandle | undefined
+  if (handle === undefined) throw new Error('ctx.connection not provided')
+  return handle
+}
+
+describe('connection client apply', () => {
+  it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
+    ;(globalThis as Win).location = { search: '' }
+    const handle = await mount()
+    expect(handle.api).toBeInstanceOf(WebApiClient)
+  })
+
+  it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
+    ;(globalThis as Win).location = { search: '?fixture' }
+    expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
+    delete (globalThis as Win).location
+    expect((await mount()).api).toBeInstanceOf(WebApiClient)
+  })
+
+  it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
+    ;(globalThis as Win).location = { search: '?fixture' }
+    const handle = await mount()
+    // config omitted: the `config ?? {}` default arm is part of the surface.
+    const loop = handle.start({})
+    expect(() => handle.start({})).toThrow(/already owned by another consumer/)
+    loop.stop() // teardown must not throw; the fixture streams abort quietly
+  })
+
+  it('WebApiClient carries requests over globalThis.fetch', async () => {
+    ;(globalThis as Win).location = { search: '' }
+    const handle = await mount()
+    const original = globalThis.fetch
+    const seen: string[] = []
+    globalThis.fetch = (input: URL | RequestInfo) => {
+      seen.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url)
+      return Promise.resolve(new Response('{}', { status: 200 }))
+    }
+    try {
+      // Schema rejection is fine — the transport hop is the assertion.
+      await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
+    } finally {
+      globalThis.fetch = original
+    }
+    expect(seen.some(u => u.includes('/api/'))).toBe(true)
+  })
+})

+ 238 - 0
packages/client/connection/tests/connection.spec.ts

@@ -0,0 +1,238 @@
+/**
+ * ConnectionController: stream pumping into sinks, the strict readiness
+ * handshake (describe + both streams' onOpen, timeout-guarded), generation
+ * abort on loss, backoff reconnection, state transitions, and sink-exception
+ * isolation. Real (short) timers — the timeout and backoff are configurable,
+ * so tests run them at millisecond scale.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import type { SessionId } from '../src/client/api.ts'
+import type { ConnectionState } from '../src/client/connection.ts'
+import { ConnectionController } from '../src/client/connection.ts'
+import { FakeApiClient, deferred, ok } from './fake-api.ts'
+
+const SID = 'fk-c1' as SessionId
+const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 }
+
+function subscribedFrame(lastSeq = 0) {
+  return { type: 'session/subscribed', sessionId: SID, lastSeq } as const
+}
+
+describe('connection lifecycle', () => {
+  it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
+    const api = new FakeApiClient()
+    const muxSeen: string[] = []
+    let connected = 0
+    const controller = new ConnectionController(api, {
+      onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
+      onConnected: () => { connected++ },
+    }, FAST)
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(connected).toBe(1) })
+      api.pushMux(subscribedFrame())
+      await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
+      expect(api.callsOf('host.describe')).toHaveLength(1)
+    } finally {
+      controller.stop()
+    }
+  })
+
+  it('reconnects with a fresh generation when a stream fails, and stop() ends the loop', async () => {
+    const api = new FakeApiClient()
+    let connected = 0
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(connected).toBe(1) })
+      api.failStreams(new Error('stream torn'))
+      await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff
+      expect(api.openMuxCount).toBe(1) // the dead generation's stream is gone, exactly one live
+    } finally {
+      controller.stop()
+      warnSpy.mockRestore()
+    }
+    // stop() aborts the live generation (streams tear down) and no reconnect follows.
+    await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
+    await new Promise(resolve => setTimeout(resolve, 40))
+    expect(api.openMuxCount).toBe(0)
+  })
+
+  it('treats describe failure as generation failure and retries', async () => {
+    const api = new FakeApiClient()
+    const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
+    let describeCalls = 0
+    api.onDescribe = () => {
+      describeCalls++
+      return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise
+    }
+    let connected = 0
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
+      expect(connected).toBe(0) // never announced during the failed generation
+      gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
+      await vi.waitFor(() => { expect(connected).toBe(1) })
+    } finally {
+      controller.stop()
+      warnSpy.mockRestore()
+    }
+  })
+
+  it('converges stream/error frames into reconnect instead of dispatching them', async () => {
+    const api = new FakeApiClient()
+    const muxSeen: string[] = []
+    let connected = 0
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const controller = new ConnectionController(api, {
+      onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
+      onConnected: () => { connected++ },
+    }, FAST)
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(connected).toBe(1) })
+      api.pushMux({ type: 'stream/error', error: { code: 'internal', message: 'impl broke', details: {} } })
+      await vi.waitFor(() => { expect(connected).toBe(2) }) // treated as loss → reconnect
+      expect(muxSeen).toEqual([]) // never forwarded to the business sink
+    } finally {
+      controller.stop()
+      warnSpy.mockRestore()
+    }
+  })
+
+  it('isolates sink exceptions from the pump', async () => {
+    const api = new FakeApiClient()
+    const seen: string[] = []
+    let connected = 0
+    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    const controller = new ConnectionController(api, {
+      onMuxEnvelope: (envelope) => {
+        seen.push(envelope.payload.type)
+        throw new Error('business layer bug')
+      },
+      onConnected: () => { connected++ },
+    }, FAST)
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(connected).toBe(1) })
+      api.pushMux(subscribedFrame(1))
+      api.pushMux(subscribedFrame(2))
+      await vi.waitFor(() => { expect(seen).toHaveLength(2) }) // second frame still pumped
+      expect(connected).toBe(1) // no reconnect triggered by the sink throw
+    } finally {
+      controller.stop()
+      errorSpy.mockRestore()
+    }
+  })
+
+  it('holds onConnected until both streams establish even after describe succeeds', async () => {
+    const api = new FakeApiClient()
+    api.holdStreamOpen = true // describe resolves immediately; stream establishment is in the case's hand
+    let connected = 0
+    const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
+      await new Promise(resolve => setTimeout(resolve, 30))
+      expect(connected).toBe(0) // describe alone must not announce
+      api.releaseStreamOpens()
+      await vi.waitFor(() => { expect(connected).toBe(1) })
+    } finally {
+      controller.stop()
+    }
+  })
+
+  it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => {
+    const api = new FakeApiClient()
+    api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires
+    let connected = 0
+    const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, { ...FAST, streamOpenTimeoutMs: 20 })
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(connected).toBe(1) }) // handshake resolved by the guard, not wedged
+    } finally {
+      controller.stop()
+    }
+  })
+
+  it('emits deduplicated connected/reconnecting state transitions', async () => {
+    const api = new FakeApiClient()
+    const states: ConnectionState[] = []
+    let connected = 0
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const controller = new ConnectionController(api, {
+      onConnected: () => { connected++ },
+      onStateChange: state => states.push(state),
+    }, FAST)
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(connected).toBe(1) })
+      expect(states).toEqual(['connected'])
+      api.failStreams(new Error('torn'))
+      await vi.waitFor(() => { expect(connected).toBe(2) })
+      expect(states).toEqual(['connected', 'reconnecting', 'connected'])
+    } finally {
+      controller.stop()
+      warnSpy.mockRestore()
+    }
+  })
+
+  it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
+    const api = new FakeApiClient()
+    const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
+    let describeCalls = 0
+    api.onDescribe = () => {
+      describeCalls++
+      return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
+    }
+    const states: ConnectionState[] = []
+    let connected = 0
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const controller = new ConnectionController(api, {
+      onConnected: () => { connected++ },
+      onStateChange: state => states.push(state),
+    }, FAST)
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(describeCalls).toBe(3) })
+      gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
+      await vi.waitFor(() => { expect(connected).toBe(1) })
+      expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
+    } finally {
+      controller.stop()
+      warnSpy.mockRestore()
+    }
+  })
+
+  it('runs with no sinks at all (every callback slot optional)', async () => {
+    const api = new FakeApiClient()
+    const controller = new ConnectionController(api, {}, FAST)
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) })
+      api.pushMux(subscribedFrame()) // pumped with sink undefined: dropped silently
+      await new Promise(resolve => setTimeout(resolve, 20))
+    } finally {
+      controller.stop()
+    }
+  })
+
+  it('start() is idempotent (one loop, one stream set)', async () => {
+    const api = new FakeApiClient()
+    let connected = 0
+    const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
+    controller.start()
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(connected).toBe(1) })
+      expect(api.openMuxCount).toBe(1)
+      expect(api.callsOf('host.describe')).toHaveLength(1)
+    } finally {
+      controller.stop()
+    }
+  })
+})

+ 160 - 0
packages/client/connection/tests/fake-api.ts

@@ -0,0 +1,160 @@
+// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
+// data source on a real clock; behavior tests need per-case responses and
+// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
+import type {
+  HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId,
+} from '../src/client/api.ts'
+import { RpcId } from '../src/client/api.ts'
+
+export interface Deferred<T> {
+  promise: Promise<T>
+  resolve(value: T): void
+  reject(error: unknown): void
+}
+
+/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
+export function deferred<T>(): Deferred<T> {
+  let resolve!: (value: T) => void
+  let reject!: (error: unknown) => void
+  const promise = new Promise<T>((res, rej) => {
+    resolve = res
+    reject = rej
+  })
+  return { promise, resolve, reject }
+}
+
+let nextRpc = 0
+
+export function ok<T>(value: T): RpcResponse<T> {
+  return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
+}
+
+
+type StreamItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } | { kind: 'fail'; error: unknown }
+
+interface StreamConn<F> {
+  feed(item: StreamItem<F>): void
+}
+
+export class FakeApiClient implements IApiClient {
+  /** Chronological call record: [method, payload]. */
+  readonly calls: { method: string; payload: unknown }[] = []
+
+  // Programmable slots (defaults answer OK-empty); reassign per case.
+  onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
+  onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
+  onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
+  => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
+    () => Promise.resolve(ok({ events: [], hasMore: false }))
+
+  onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
+  onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
+  onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
+    () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
+
+  private readonly muxConns: StreamConn<MuxFrame>[] = []
+  private readonly hostConns: StreamConn<HostFrame>[] = []
+
+  // Parameter annotations below are local structural types on purpose: the CI
+  // lint lane runs without built artifacts, where IApiClient's wire types
+  // (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
+  readonly sessions: IApiClient['sessions'] = {
+    list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
+    create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
+    history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
+      this.record('session.history', payload, this.onHistory(payload)),
+    prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
+    cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
+  }
+
+  readonly host: IApiClient['host'] = {
+    describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
+  }
+
+  /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
+  suppressStreamOpen = false
+
+  /** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them.
+   *  Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */
+  holdStreamOpen = false
+  private heldOpens: (() => void)[] = []
+
+  releaseStreamOpens(): void {
+    const held = this.heldOpens
+    this.heldOpens = []
+    for (const fire of held) fire()
+  }
+
+  readonly events: IApiClient['events'] = {
+    mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) =>
+      this.openStream(this.muxConns, signal, onOpen),
+    host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) =>
+      this.openStream(this.hostConns, signal, onOpen),
+  }
+
+  respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
+    return Promise.resolve({ accepted: false, reason: 'not-pending' })
+  }
+
+  /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */
+  pushMux(frame: MuxFrame, rpcId?: string): void {
+    for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
+  }
+
+  pushHost(frame: HostFrame, rpcId?: string): void {
+    for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
+  }
+
+  /** End (clean close) or fail (throw) every open stream — reconnect-path material. */
+  endStreams(): void {
+    for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' })
+  }
+
+  failStreams(error: unknown): void {
+    for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error })
+  }
+
+  get openMuxCount(): number {
+    return this.muxConns.length
+  }
+
+  callsOf(method: string): unknown[] {
+    return this.calls.filter(c => c.method === method).map(c => c.payload)
+  }
+
+  private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
+    this.calls.push({ method, payload })
+    return response
+  }
+
+  private async *openStream<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
+    const inbox: StreamItem<F>[] = []
+    let wake: (() => void) | null = null
+    const conn: StreamConn<F> = {
+      feed: (item) => {
+        inbox.push(item)
+        wake?.()
+      },
+    }
+    registry.push(conn)
+    if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen)
+    else if (!this.suppressStreamOpen) onOpen?.()
+    try {
+      while (!signal.aborted) {
+        while (inbox.length > 0) {
+          const item = inbox.shift() as StreamItem<F>
+          if (item.kind === 'end') return
+          if (item.kind === 'fail') throw item.error
+          yield item.envelope
+        }
+        await new Promise<void>((resolve) => {
+          wake = resolve
+          signal.addEventListener('abort', () => { resolve() }, { once: true })
+        })
+        wake = null
+      }
+    } finally {
+      registry.splice(registry.indexOf(conn), 1)
+    }
+  }
+}

+ 339 - 0
packages/client/connection/tests/fixture.spec.ts

@@ -0,0 +1,339 @@
+/**
+ * Fixture impl semantics: the demo data source must honor the same contract
+ * shapes as the real host (paging boundaries, rpcId echo, replay lifecycle,
+ * baseline replay, timing hooks) — this is the vitest-side drift detector for
+ * the hand-written fixture/host parallel implementations.
+ */
+
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { SessionId } from '../src/client/api.ts'
+import { RpcId } from '../src/client/api.ts'
+import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts'
+import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts'
+
+const sid = (id: string): SessionId => id as SessionId
+const req = <P>(payload: P): RpcRequest<P> => ({ rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), payload })
+let reqCount = 0
+
+interface TimingHooks {
+  setHistoryDelay(ms: number): void
+  failNextHistory(): void
+  appendUser(id: string, msg: string): void
+  appendSilent(id: string, msg: string): void
+  breakStreams(): void
+}
+const timing = (): TimingHooks => (globalThis as Record<string, unknown>).__fxTiming as TimingHooks
+
+/** Collect stream frames until the predicate or a soft cap; abort ends the stream. */
+async function collect<F>(stream: AsyncIterable<RpcRequest<F>>, abort: AbortController, done: (frames: F[]) => boolean): Promise<F[]> {
+  const frames: F[] = []
+  for await (const envelope of stream) {
+    frames.push(envelope.payload)
+    if (done(frames) || frames.length > 500) {
+      abort.abort()
+      break
+    }
+  }
+  return frames
+}
+
+describe('createFixtureApi', () => {
+  it('serves the session list sorted by updatedAt desc and echoes rpcIds on every unary', async () => {
+    const api = createFixtureApi()
+    const request = req({})
+    const response = await api.sessions.list(request)
+    expect(response.rpcId).toBe(request.rpcId)
+    if (!response.result.ok) throw new Error('list failed')
+    expect(response.result.value.items.map(s => s.sessionId)).toEqual(['fx-alpha', 'fx-beta', 'fx-gamma'])
+    expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
+  })
+
+  it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
+    const api = createFixtureApi()
+    const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
+    if (!tail.result.ok) throw new Error('history failed')
+    const tailPage = tail.result.value
+    expect(tailPage.hasMore).toBe(true)
+    expect(tailPage.events[0]?.event.type).toBe('turn/start') // cut lands on a turn boundary
+    const boundary = tailPage.events[0]?.event.seq ?? 0
+    expect(boundary).toBeGreaterThan(0)
+    const older = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: boundary, maxMessages: 10 }))
+    if (!older.result.ok) throw new Error('older failed')
+    const olderTail = older.result.value.events.at(-1)?.event
+    expect((olderTail?.seq ?? -1) + 1).toBe(boundary) // pages stitch with no hole/overlap
+    // Out-of-range beforeSeq clamps instead of exploding.
+    const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 }))
+    if (!clamped.result.ok) throw new Error('clamped failed')
+    expect(clamped.result.value.events).toEqual([])
+    // Unknown session: empty page, not an error (history of a bare id).
+    const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
+    if (!empty.result.ok) throw new Error('empty failed')
+    expect(empty.result.value).toEqual({ events: [], hasMore: false })
+  })
+
+  it('create adds a session and pushes host/session-added to open host streams', async () => {
+    const api = createFixtureApi()
+    const abort = new AbortController()
+    const seen: HostFrame[] = []
+    const consuming = (async () => {
+      for await (const envelope of api.events.host(req({}), abort.signal)) {
+        seen.push(envelope.payload)
+        if (seen.length >= 1) abort.abort()
+      }
+    })()
+    await new Promise(resolve => setTimeout(resolve, 10)) // let the stream register
+    const created = await api.sessions.create(req({}))
+    if (!created.result.ok) throw new Error('create failed')
+    await consuming
+    if (!created.result.ok) throw new Error('create failed')
+    const createdId = created.result.value.sessionId
+    expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId }])
+    const list = await api.sessions.list(req({}))
+    if (!list.result.ok) throw new Error('list failed')
+    expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true)
+  })
+
+  it('prompt replays a full streamed turn and cancel mid-replay freezes with (已中断)', async () => {
+    const api = createFixtureApi()
+    const created = await api.sessions.create(req({}))
+    if (!created.result.ok) throw new Error('create failed')
+    const id = created.result.value.sessionId
+    const abort = new AbortController()
+    const frames: MuxFrame[] = []
+    const consuming = (async () => {
+      for await (const envelope of api.events.mux(req({}), abort.signal)) {
+        frames.push(envelope.payload)
+        const last = envelope.payload
+        if (last.type === 'session/event' && last.event.type === 'turn/end') {
+          abort.abort()
+        }
+      }
+    })()
+    await new Promise(resolve => setTimeout(resolve, 10))
+    // Unknown session → session-not-found with the id echoed in details.
+    const missing = await api.sessions.prompt(req({ sessionId: sid('ghost'), mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
+    expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'ghost' } } })
+    // Real prompt: replay starts (running flips true), cancel freezes it.
+    const accepted = await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '取消我' }] }))
+    expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
+    await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks
+    await api.sessions.cancel(req({ sessionId: id }))
+    await consuming
+    const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
+    expect(types).toContain('turn/start')
+    expect(types).toContain('user/message')
+    expect(types).toContain('assistant/chunk')
+    expect(types).toContain('assistant/message')
+    expect(types.at(-1)).toBe('turn/end')
+    const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
+    expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
+    // Idle cancel: no replay in flight, must not explode; running flips false.
+    const idleCancel = await api.sessions.cancel(req({ sessionId: id }))
+    expect(idleCancel.result).toMatchObject({ ok: true })
+  })
+
+  it('steer during a replay inserts a steering message and the replay continues to completion', async () => {
+    const api = createFixtureApi()
+    const created = await api.sessions.create(req({}))
+    if (!created.result.ok) throw new Error('create failed')
+    const id = created.result.value.sessionId
+    const abort = new AbortController()
+    const framesPromise = collect<MuxFrame>(api.events.mux(req({}), abort.signal), abort,
+      frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end'))
+    await new Promise(resolve => setTimeout(resolve, 10))
+    await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '短' }] }))
+    await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] }))
+    const frames = await framesPromise
+    const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
+    expect(types).toContain('steering/message')
+    expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn
+  })
+
+  it('mux open replays the baseline: subscribed for running sessions + the resident approval with a stable rpcId', async () => {
+    const api = createFixtureApi()
+    const openOnce = async (): Promise<RpcRequest<MuxFrame>[]> => {
+      const abort = new AbortController()
+      const envelopes: RpcRequest<MuxFrame>[] = []
+      for await (const envelope of api.events.mux(req({}), abort.signal)) {
+        envelopes.push(envelope)
+        if (envelopes.length >= 2) abort.abort()
+      }
+      return envelopes
+    }
+    const first = await openOnce()
+    const second = await openOnce()
+    expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' })
+    expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0)
+    expect(first[1]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
+    expect(second[1]?.rpcId).toBe(first[1]?.rpcId) // stable rpcId across replays (host replay semantics)
+  })
+
+  it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {
+    const api = createFixtureApi()
+    const abort = new AbortController()
+    const framesPromise = collect<MuxFrame>(api.events.mux(req({}), abort.signal), abort,
+      frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end'))
+    await new Promise(resolve => setTimeout(resolve, 10))
+    const created = await api.sessions.create(req({}))
+    if (!created.result.ok) throw new Error('create failed')
+    // steer while idle + a non-text content block (covers the '' arm of the text join).
+    await api.sessions.prompt(req({
+      sessionId: created.result.value.sessionId, mode: 'steer' as const,
+      content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never],
+    }))
+    const frames = await framesPromise
+    const types = frames.filter((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event').map(f => f.event.type)
+    expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not a steering insert
+  })
+
+  it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
+    vi.useFakeTimers()
+    try {
+      const api = createFixtureApi()
+      const abort = new AbortController()
+      const hostSeen: HostFrame[] = []
+      const consuming = (async () => {
+        for await (const envelope of api.events.host(req({}), abort.signal)) hostSeen.push(envelope.payload)
+      })()
+      await vi.advanceTimersByTimeAsync(5001) // interval fires: fx-gamma flips running=true (no log exists)
+      expect(hostSeen).toContainEqual({ type: 'host/session-status', sessionId: sid('fx-gamma'), running: true })
+      // A mux stream opened now sees gamma in the baseline with lastSeq = -1 (empty log arm).
+      const mabort = new AbortController()
+      const baseline: MuxFrame[] = []
+      const muxConsuming = (async () => {
+        for await (const envelope of api.events.mux(req({}), mabort.signal)) {
+          baseline.push(envelope.payload)
+          if (baseline.length >= 3) mabort.abort()
+        }
+      })()
+      await vi.advanceTimersByTimeAsync(10)
+      mabort.abort()
+      await muxConsuming
+      expect(baseline).toContainEqual({ type: 'session/subscribed', sessionId: sid('fx-gamma'), lastSeq: -1 })
+      abort.abort()
+      await vi.advanceTimersByTimeAsync(10)
+      await consuming
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('respond is a typed stub: always not-pending', async () => {
+    const api = createFixtureApi()
+    expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' })
+  })
+
+  it('describe answers the fixture identity', async () => {
+    const api = createFixtureApi()
+    const response = await api.host.describe(req({}))
+    expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } })
+  })
+
+  it('timing hooks: history delay + one-shot failure, silent append, and breakStreams end open generators', async () => {
+    const api = createFixtureApi()
+    const hooks = timing()
+    // One-shot transport failure after transit delay.
+    hooks.setHistoryDelay(5)
+    hooks.failNextHistory()
+    await expect(api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))).rejects.toThrow(/simulated history transport failure/)
+    hooks.setHistoryDelay(0)
+    // The failure was one-shot: the next call succeeds.
+    const ok = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
+    expect(ok.result.ok).toBe(true)
+    // appendUser emits on the mux stream; appendSilent only lands in the log (lost frame).
+    const abort = new AbortController()
+    const seen: MuxFrame[] = []
+    const consuming = (async () => {
+      for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push(envelope.payload)
+    })()
+    await new Promise(resolve => setTimeout(resolve, 10))
+    hooks.appendSilent('fx-alpha', '静默丢帧')
+    hooks.appendUser('fx-alpha', '正常直播')
+    await vi.waitFor(() => {
+      expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
+    })
+    expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
+    // But history serves the silent event (the client's repull finds it).
+    const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 }))
+    if (!repull.result.ok) throw new Error('repull failed')
+    expect(JSON.stringify(repull.result.value.events)).toContain('静默丢帧')
+    // breakStreams force-ends BOTH stream kinds without the client abort.
+    const habort = new AbortController()
+    const hostConsuming = (async () => {
+      for await (const _ of api.events.host(req({}), habort.signal)) { /* drain */ }
+    })()
+    await new Promise(resolve => setTimeout(resolve, 10))
+    hooks.breakStreams()
+    await consuming // returns because the stream broke, not because we aborted
+    await hostConsuming
+    expect(abort.signal.aborted).toBe(false)
+    expect(habort.signal.aborted).toBe(false)
+  })
+})
+
+describe('FixtureApiClient (protocol-level fake carrier)', () => {
+  afterEach(() => {
+    vi.restoreAllMocks()
+  })
+
+  it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => {
+    const client = new FixtureApiClient()
+    // Protected at compile time only; reach it directly to pin the tripwire message.
+    expect(() => (client as unknown as { doFetch(): Promise<Response> }).doFetch()).toThrow(/doFetch must be unreachable/)
+  })
+
+  it('mints request ids, taps all four full forms, and never touches doFetch', async () => {
+    const client = new FixtureApiClient()
+    const tapped: RpcMessage[] = []
+    client.subscribeEnvelopes(batch => tapped.push(...batch))
+    const response = await client.sessions.list({})
+    expect(response.result.ok).toBe(true)
+    await client.respond({ type: 'client-response', rpcId: RpcId('r-x'), result: { ok: true, value: {} } })
+    await vi.waitFor(() => {
+      const kinds = tapped.map(m => m.type)
+      expect(kinds).toContain('client-request')
+      expect(kinds).toContain('server-response')
+      expect(kinds).toContain('client-response')
+    })
+    const request = tapped.find(m => m.type === 'client-request')
+    const reply = tapped.find(m => m.type === 'server-response')
+    expect(request?.rpcId).toBe(reply?.rpcId) // echo discipline holds through the fake carrier
+  })
+
+  it('covers the whole unary dispatch table', async () => {
+    const client = new FixtureApiClient()
+    const created = await client.sessions.create({})
+    if (!created.result.ok) throw new Error('create failed')
+    const id = created.result.value.sessionId
+    expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true)
+    expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
+    expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
+    expect((await client.host.describe({})).result.ok).toBe(true)
+  })
+
+  it('fires onOpen at stream-iteration start and taps server-request full forms', async () => {
+    const client = new FixtureApiClient()
+    const tapped: RpcMessage[] = []
+    client.subscribeEnvelopes(batch => tapped.push(...batch))
+    const order: string[] = []
+    const abort = new AbortController()
+    for await (const envelope of client.events.mux({}, abort.signal, () => order.push('open'))) {
+      order.push(envelope.payload.type)
+      abort.abort()
+    }
+    expect(order[0]).toBe('open')
+    expect(order[1]).toBe('session/subscribed')
+    await vi.waitFor(() => {
+      expect(tapped.some(m => m.type === 'server-request')).toBe(true)
+    })
+    // Host stream side of the pair (same tap path).
+    const habort = new AbortController()
+    const hostOrder: string[] = []
+    const hostIterator = client.events.host({}, habort.signal, () => hostOrder.push('open'))[Symbol.asyncIterator]()
+    const raced = await Promise.race([hostIterator.next(), new Promise<'idle'>(resolve => setTimeout(() => { resolve('idle') }, 50))])
+    expect(hostOrder).toEqual(['open']) // established even though the host stream stays silent
+    habort.abort()
+    if (raced === 'idle') await hostIterator.return?.(undefined)
+  })
+})

+ 10 - 0
packages/client/connection/tests/node-half.spec.ts

@@ -0,0 +1,10 @@
+/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
+import { describe, expect, it } from 'vitest'
+import { apply } from '../src/index.ts'
+
+describe('node half', () => {
+  it('apply is a no-op host placeholder', () => {
+    apply(undefined)
+    expect(true).toBe(true) // reaching here without throw is the contract
+  })
+})

+ 42 - 0
packages/client/connection/tsconfig.json

@@ -0,0 +1,42 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "lib": [
+      "ES2024",
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "types": []
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../host/apiproxy"
+    },
+    {
+      "path": "../../ui/user-approval"
+    },
+    {
+      "path": "../../ui/user-interaction"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ],
+  "exclude": [
+    "**/*.legacy.*"
+  ]
+}

+ 3 - 0
packages/client/connection/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-connection', ['lib/types/index.js', 'lib/types/invariant.js'])

+ 16 - 0
packages/client/i18n/README.md

@@ -0,0 +1,16 @@
+# @deepseek-ai/dsh-client-i18n
+
+i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8.
+
+## Model Experience
+
+None, as the i18n registry serves browser UI copy; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks.
+- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity.

+ 54 - 0
packages/client/i18n/package.json

@@ -0,0 +1,54 @@
+{
+  "name": "@deepseek-ai/dsh-client-i18n",
+  "description": "i18n plugin: I18nService (ns x locale dictionaries, bind(ns) -> t, locale store); zh/en skeleton",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dshClient": {
+    "inject": [],
+    "platform": "web",
+    "immediately": true
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "@deepseek-ai/dsh-client-web-react": "workspace:^"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ]
+}

+ 108 - 0
packages/client/i18n/src/client/index.ts

@@ -0,0 +1,108 @@
+/**
+ * i18n plugin, browser half: namespace x locale dictionary registry with a
+ * bound translate function whose reference is stable (safe for inject
+ * surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
+ * Contract: api-contracts v3 section 8.
+ */
+import type { Context } from 'cordis'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
+import { en } from '../locales/en.ts'
+import { zh } from '../locales/zh.ts'
+
+/** Translate a key with optional params. */
+export type Translate = (key: string, params?: Record<string, unknown>) => string
+
+/** Locale dictionary: flat key to template string ({name} placeholders). */
+export type LocaleDict = Record<string, string>
+
+declare module 'cordis' {
+  interface Context {
+    i18n: I18nService
+  }
+}
+
+/** Fallback locale consulted after the active locale misses. */
+export const FALLBACK_LOCALE = 'zh'
+
+/** Shared namespace for shell-level texts. */
+export const COMMON_NS = 'common'
+
+/**
+ * Dictionary registry plus locale switch. Lookup chain per key: active locale
+ * -> zh fallback -> the key itself (missing text stays visible, fail loud in
+ * the UI rather than blank).
+ */
+export class I18nService {
+  private dicts = new Map<string, Map<string, LocaleDict>>()
+  private bound = new Map<string, Translate>()
+  private localeStore = createSnapshotStore<string>(FALLBACK_LOCALE)
+
+  /**
+   * Register a dictionary for a namespace and locale. Duplicate (ns, locale)
+   * throws (single occupant; a namespace's texts have one owner).
+   * @param ns - namespace.
+   * @param locale - locale tag (zh/en to start).
+   * @param dict - dictionary.
+   * @returns disposer (idempotent).
+   */
+  register(ns: string, locale: string, dict: LocaleDict): () => void {
+    let locales = this.dicts.get(ns)
+    if (!locales) {
+      locales = new Map()
+      this.dicts.set(ns, locales)
+    }
+    if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`)
+    locales.set(locale, dict)
+    return () => {
+      const owner = this.dicts.get(ns)
+      if (owner?.get(locale) === dict) owner.delete(locale)
+    }
+  }
+
+  /**
+   * Bind a namespace to a translate function. The returned reference is
+   * stable per namespace (repeat binds return the same function), so it can
+   * ride inject surfaces without breaking memoization.
+   * @param ns - namespace.
+   * @returns the translate function (reads the locale store at call time).
+   */
+  bind(ns: string): Translate {
+    let t = this.bound.get(ns)
+    if (!t) {
+      t = (key, params) => this.translate(ns, key, params)
+      this.bound.set(ns, t)
+      return t
+    }
+    return t
+  }
+
+  /** Active locale store (switching re-renders the tree; low frequency). */
+  get locale(): SnapshotStore<string> {
+    return this.localeStore
+  }
+
+  private translate(ns: string, key: string, params?: Record<string, unknown>): string {
+    const locales = this.dicts.get(ns)
+    const template = locales?.get(this.localeStore.getSnapshot())?.[key]
+      ?? locales?.get(FALLBACK_LOCALE)?.[key]
+      ?? key
+    if (!params) return template
+    return template.replace(/\{(\w+)\}/g, (match, name: string) =>
+      name in params ? String(params[name]) : match)
+  }
+}
+
+/** Required services (none; the loader passes the export surface as an object plugin). */
+export const inject: string[] = []
+
+/**
+ * Client plugin body: provide the i18n service with base dictionaries.
+ * @param ctx - client cordis context.
+ */
+export function apply(ctx: Context): void {
+  const i18n = new I18nService()
+  i18n.register(COMMON_NS, 'zh', zh)
+  i18n.register(COMMON_NS, 'en', en)
+  ctx.provide('i18n', i18n)
+}

+ 11 - 0
packages/client/i18n/src/index.ts

@@ -0,0 +1,11 @@
+/**
+ * i18n plugin, node half. Pure UI plugin: the empty apply exists so the
+ * plugin appears in the host cordis.yml / Loader (load and lifecycle follow
+ * the host; the browser half ships via exports["./client"], discovered
+ * through the package.json dshClient declaration). Everything else —
+ * I18nService, Translate, LocaleDict — lives in the client half; consumers
+ * import the /client subpath. Contract: api-contracts v3 section 8.
+ */
+
+/** Host plugin body — no host-side behavior for the i18n plugin. */
+export function apply(): void {}

+ 32 - 0
packages/client/i18n/src/invariant.ts

@@ -0,0 +1,32 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-i18n`.
+ * @module @deepseek-ai/dsh-client-i18n/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-i18n'
+
+/** Cordis companion plugin name. */
+export const name = 'client-i18n-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: ns-by-locale dictionary registry with a stable
+ * bind(ns) surface — it emits no cordis events and owns no cross-plugin
+ * mutable relation; fallback-chain resolution and locale-store behavior are
+ * asserted directly by this package's behavior specs.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 2 - 0
packages/client/i18n/src/locales/en.ts

@@ -0,0 +1,2 @@
+/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
+export const en: Record<string, string> = {}

+ 2 - 0
packages/client/i18n/src/locales/zh.ts

@@ -0,0 +1,2 @@
+/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
+export const zh: Record<string, string> = {}

+ 53 - 0
packages/client/i18n/tests/i18n.spec.ts

@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
+
+describe('I18nService', () => {
+  it('translates from the active locale with zh fallback then key passthrough', () => {
+    const i18n = new I18nService()
+    i18n.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
+    i18n.register('ns', 'en', { hello: 'Hello' })
+    const t = i18n.bind('ns')
+    expect(i18n.locale.getSnapshot()).toBe('zh')
+    expect(t('hello')).toBe('你好')
+    i18n.locale.set('en')
+    expect(t('hello')).toBe('Hello')
+    expect(t('onlyZh')).toBe('仅中文')
+    expect(t('missing.key')).toBe('missing.key')
+  })
+
+  it('interpolates {name} params and leaves unknown placeholders intact', () => {
+    const i18n = new I18nService()
+    i18n.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
+    const t = i18n.bind('ns')
+    expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
+    expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
+    expect(t('greet')).toBe('你好,{name}!第 {n} 次')
+  })
+
+  it('bind returns a stable reference per namespace', () => {
+    const i18n = new I18nService()
+    expect(i18n.bind('a')).toBe(i18n.bind('a'))
+    expect(i18n.bind('a')).not.toBe(i18n.bind('b'))
+  })
+
+  it('duplicate (ns, locale) throws; disposer unregisters and is idempotent', () => {
+    const i18n = new I18nService()
+    const dispose = i18n.register('ns', 'zh', { k: 'v1' })
+    expect(() => i18n.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
+    dispose()
+    dispose()
+    const t = i18n.bind('ns')
+    expect(t('k')).toBe('k')
+    i18n.register('ns', 'zh', { k: 'v2' })
+    expect(t('k')).toBe('v2')
+  })
+
+  it('locale store is subscribable (snapshot store contract)', () => {
+    const i18n = new I18nService()
+    let notified = 0
+    i18n.locale.subscribe(() => { notified += 1 })
+    i18n.locale.set('en')
+    expect(i18n.locale.getSnapshot()).toBe('en')
+    expect(notified).toBe(1)
+  })
+})

+ 30 - 0
packages/client/i18n/tests/invariant.spec.ts

@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { apply as nodeApply } from '@deepseek-ai/dsh-client-i18n'
+import { apply as clientApply, COMMON_NS, I18nService, inject } from '@deepseek-ai/dsh-client-i18n/client'
+import * as I18nInvariant from '@deepseek-ai/dsh-client-i18n/invariant'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+
+describe('invariant companion', () => {
+  it('registers under the package name with an empty installer', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantService, { enabled: true })
+    await expect(ctx.plugin(I18nInvariant).await()).resolves.toBeDefined()
+  })
+
+  it('node-half apply is a no-op host placeholder', () => {
+    nodeApply()
+    expect(true).toBe(true) // reaching here without throw is the contract
+  })
+
+  it('client apply provides ctx.i18n seeded with the zh/en common namespace', async () => {
+    expect(inject).toEqual([])
+    const ctx = new Context()
+    await ctx.plugin({ inject, apply: clientApply }).await()
+    const i18n = ctx.get('i18n')
+    expect(i18n).toBeInstanceOf(I18nService)
+    // Seeded dictionaries occupy the (ns, locale) seats even while empty.
+    expect(() => (i18n as I18nService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
+    expect(() => (i18n as I18nService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
+  })
+})

+ 27 - 0
packages/client/i18n/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "lib": [
+      "ES2024",
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "types": []
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../web-react"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 3 - 0
packages/client/i18n/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js'])

+ 18 - 0
packages/client/runtime/README.md

@@ -0,0 +1,18 @@
+# @deepseek-ai/dsh-client-runtime
+
+Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), Session object layer, ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
+
+## Model Experience
+
+None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
+- **Scope teardown is watch-approximated** — the most recently resolved binding stands in for "who is watching"; a removed-while-watched session's scope survives until the watch moves away, not until true observer count reaches zero.
+- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
+- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id.

+ 63 - 0
packages/client/runtime/package.json

@@ -0,0 +1,63 @@
+{
+  "name": "@deepseek-ai/dsh-client-runtime",
+  "description": "Client cordis boot and core services: SlotsService, SessionsService (scope tree + object layer), ClientLoader",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./loader": {
+      "types": "./lib/types/client/loader/index.d.ts",
+      "default": "./lib/loader.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dshClient": {
+    "inject": [
+      "@deepseek-ai/dsh-client-connection"
+    ],
+    "platform": "web",
+    "immediately": true
+  },
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-web-react": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "react": "^18.2.0",
+    "@deepseek-ai/dsh-session": "workspace:^"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@types/react": "~18.3.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/loader.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ]
+}

+ 128 - 0
packages/client/runtime/src/client/index.ts

@@ -0,0 +1,128 @@
+/**
+ * Browser half: the whole runtime contract surface (api-contracts v3 §4) —
+ * SlotsService, SessionsService (list store + scope tree + object layer),
+ * the ClientLoader interface, and the cordis Context/Events merges. apply
+ * mounts ctx.slots + ctx.sessions and wires the connection stream loop into
+ * the object layer. The loader machinery implementation is NOT in the plugin
+ * bundle — it ships via the package's `./loader` subpath, statically held by
+ * the web shell (a loader cannot load itself).
+ */
+import type { Context } from 'cordis'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import type { SessionBinding as GenericSessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SnapshotStore, UseSession } from '@deepseek-ai/dsh-client-web-react'
+import { SlotsService } from './slots.ts'
+import { SessionsService } from './sessions/service.ts'
+import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
+
+export { SlotsService } from './slots.ts'
+export { SessionsService, scopeOf } from './sessions/service.ts'
+export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
+export { SessionManager } from './sessions/manager.ts'
+export type { SessionListSnapshot } from './sessions/manager.ts'
+export { Session, PAGE_MESSAGES } from './sessions/session.ts'
+export type { SessionListEntry } from './sessions/lineage.ts'
+export type {
+  AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
+  OpenState, PartialAssistant, PendingInteraction, PromptError, RunningToolCall, SteeringMessageNode,
+  ToolResultNode, UnknownSurfaceNode, UserMessageNode,
+} from './sessions/conversation.ts'
+export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
+
+// ---- Narrowed aliases (the single narrowing point of the slot type chain:
+// ui-slots/web-react stay generic and dependency-inverted; the client-tree
+// concrete types live here, where their subjects live) ----
+
+/**
+ * The client cordis context face: the base Context plus the service keys
+ * this package's declaration merge contributes (slots/sessions/loader) and
+ * every later plugin's merge. A plain alias — the merges land on Context
+ * itself inside the client program; the name marks intent at consumer seams.
+ */
+export type ClientContext = Context
+
+/** SessionBinding narrowed to the client context (inject factories dot services directly). */
+export type ClientSessionBinding = GenericSessionBinding<ClientContext>
+
+/** The conversation-snapshot selector hook (ConvViewProps/ToolViewProps take this). */
+export type UseConversationSession = UseSession<ConversationSnapshot>
+
+/**
+ * One tool call as the chat flow renders it: still-running (spinner card) or
+ * settled (result node). The fold produces both shapes; toolview components
+ * narrow on the discriminant fields.
+ */
+export type ToolCallBlock = RunningToolCall | ToolResultNode
+
+declare module 'cordis' {
+  interface Events {
+    /**
+     * A slot's definition or registration set changed.
+     * @mode emit
+     * @param key - the mutated SlotMap key.
+     */
+    'slots/changed'(key: string): void
+  }
+  interface Context {
+    slots: import('./slots.ts').SlotsService
+    sessions: import('./sessions/service.ts').SessionsService
+    loader: ClientLoader
+  }
+}
+
+/** One __DSH_BOOT__ manifest row. */
+export interface BootPluginEntry { id: string; url: string; inject: string[]; immediately?: boolean }
+
+/** Per-plugin load status store shape. */
+export type LoaderStatus = Record<string, 'loading' | 'active' | 'failed'>
+
+/**
+ * Client bundle loader. The immediately group loads first (parallel fetch,
+ * apply in inject topology order); remaining plugins follow in inject
+ * topology. Loaded bundle export surfaces are registered back into the
+ * require module table. Implementation lives in the `./loader` subpath
+ * (shell-held machinery).
+ */
+export interface ClientLoader {
+  /** Start loading from window.__DSH_BOOT__ (non-blocking). */
+  start(): void
+  /**
+   * Load one plugin bundle (script inject, factory handoff, ctx.plugin, style registration).
+   * @param id - plugin id (package name).
+   */
+  load(id: string): Promise<void>
+  /**
+   * Unload a plugin. P-I: not implemented (full chain lands with HMR).
+   * @param id - plugin id.
+   */
+  unload(id: string): Promise<void>
+  /** Resolves when every manifest plugin reached active (AppRoot gates the real UI on this). */
+  settled(): Promise<void>
+  /**
+   * Read a loaded module's export surface from the module table (same
+   * implementation the bundle-facing require uses; unknown spec throws).
+   * @param spec - module specifier (package name or seeded library id).
+   */
+  requireModule(spec: string): unknown
+  /** Per-plugin status store. */
+  readonly status: SnapshotStore<LoaderStatus>
+}
+
+/** Required services: the wire handle mounted by the connection plugin. */
+export const inject = ['connection']
+
+/**
+ * Client plugin body: mount slots + sessions, start the stream loop.
+ * @param ctx - client cordis context.
+ */
+export function apply(ctx: Context): void {
+  ctx.plugin(SlotsService)
+  const connection = ctx.get('connection') as ConnectionHandle
+  const sessions = new SessionsService(ctx, connection.api)
+  const loop = connection.start({
+    onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
+    onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
+    onConnected: () => { sessions.manager.handleConnected() },
+  })
+  ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
+}

+ 247 - 0
packages/client/runtime/src/client/loader/index.ts

@@ -0,0 +1,247 @@
+/**
+ * ClientLoader implementation (shell-held machinery — the loader cannot load
+ * itself, so the web shell imports this subpath statically and mounts the
+ * instance as ctx.loader; the runtime package's own client bundle never
+ * includes it).
+ *
+ * Load chain per plugin: fetch bundle text → execute (script injection) → the
+ * bundle calls window.DSHClientProxy.loadPlugin({id, factory}) (single-slot
+ * handoff, id reconciled) → factory(require) with require bound to the module
+ * table → ctx.plugin(exports.apply) → the export surface is registered into
+ * the module table under the plugin id (inject topology guarantees later
+ * loaders can require earlier ones) → <style data-plugin> ownership recorded.
+ *
+ * start(): the `immediately` group is fetched in parallel and executed in
+ * group-internal inject topology (execution is serial — the handoff slot is
+ * single); a full-group barrier precedes the remaining plugins, which then
+ * load one by one in inject topology.
+ */
+import type { Context } from 'cordis'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
+import type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
+
+export type { BootPluginEntry, ClientLoader, LoaderStatus } from '../index.ts'
+
+/** The shape a client bundle hands to window.DSHClientProxy.loadPlugin. */
+export interface ClientPluginHandoff {
+  /** Plugin id (package name) — must match the manifest row being loaded. */
+  id: string
+  /**
+   * Closure factory: receives the DI require and returns the module's export
+   * surface; an `apply` export is applied as a cordis plugin.
+   */
+  factory: (require: (spec: string) => unknown) => Record<string, unknown>
+}
+
+/** Window surface the loader owns (bundle side of the handoff protocol). */
+interface DshWindow {
+  __DSH_BOOT__?: { plugins: BootPluginEntry[] }
+  DSHClientProxy?: { loadPlugin(handoff: ClientPluginHandoff): void }
+}
+
+/** Options for createClientLoader (assembled by the web shell at boot). */
+export interface ClientLoaderOptions {
+  /** Client root context: plugin applies mount under it. */
+  ctx: Context
+  /**
+   * Seeded module table: pure-library entities (react, react-dom, cordis,
+   * ui-slots, web-react, ui-primitives). The loader takes ownership and
+   * registers loaded bundle export surfaces alongside them.
+   */
+  modules: Record<string, unknown>
+  /**
+   * Boot manifest; defaults to window.__DSH_BOOT__. Fixture pages inject the
+   * same protocol shape.
+   */
+  boot?: { plugins: BootPluginEntry[] }
+  /** Bundle fetch seam (parallelizable half). Defaults to same-origin fetch().text(). */
+  fetchBundle?: (url: string) => Promise<string>
+  /**
+   * Bundle execution seam (serial half; execution synchronously performs the
+   * loadPlugin handoff). Defaults to a <script> element carrying the code.
+   */
+  executeBundle?: (code: string, url: string) => void
+}
+
+/** Per-plugin bookkeeping across the load chain. */
+interface PluginRecord {
+  entry: BootPluginEntry
+  state: 'idle' | 'loading' | 'active' | 'failed'
+  fetch?: Promise<string>
+  load?: Promise<void>
+}
+
+const NOT_LOADED = Symbol('dsh.loader.not-loaded')
+
+/**
+ * Build the client bundle loader.
+ * @param options - ctx, seeded module table, boot manifest, fetch/execute seams.
+ * @returns the ClientLoader the shell mounts as ctx.loader.
+ */
+export function createClientLoader(options: ClientLoaderOptions): ClientLoader {
+  const { ctx } = options
+  const win = globalThis as DshWindow
+  const boot = options.boot ?? win.__DSH_BOOT__
+  if (boot === undefined) throw new Error('client-loader: no boot manifest (window.__DSH_BOOT__ missing)')
+
+  const modules = new Map<string, unknown>(Object.entries(options.modules))
+  const records = new Map<string, PluginRecord>()
+  for (const entry of boot.plugins) {
+    if (records.has(entry.id)) throw new Error(`client-loader: duplicate manifest id "${entry.id}"`)
+    records.set(entry.id, { entry, state: 'idle' })
+  }
+
+  const status = createSnapshotStore<LoaderStatus>({})
+  const publish = (id: string, state: 'loading' | 'active' | 'failed'): void => {
+    status.update((draft) => { draft[id] = state })
+  }
+
+  // Single-slot handoff: bundle execution synchronously calls loadPlugin;
+  // doLoad arms the slot before executing and reconciles the id after.
+  let slot: ClientPluginHandoff | typeof NOT_LOADED = NOT_LOADED
+  if (win.DSHClientProxy !== undefined) throw new Error('client-loader: window.DSHClientProxy already installed (double boot?)')
+  win.DSHClientProxy = {
+    loadPlugin: (handoff: ClientPluginHandoff): void => {
+      if (slot !== NOT_LOADED) {
+        throw new Error(`client-loader: overlapping loadPlugin handoff (got "${handoff.id}" while a previous handoff is unclaimed)`)
+      }
+      slot = handoff
+    },
+  }
+
+  const fetchBundle = options.fetchBundle ?? (async (url: string): Promise<string> => {
+    const res = await fetch(url)
+    if (!res.ok) throw new Error(`client-loader: bundle fetch ${url} answered ${String(res.status)}`)
+    return res.text()
+  })
+
+  const executeBundle = options.executeBundle ?? ((code: string, url: string): void => {
+    const el = document.createElement('script')
+    // Inline execution (not src) so the fetch half stays parallelizable; the
+    // sourceURL comment keeps devtools stack frames attributed to the bundle.
+    el.textContent = `${code}\n//# sourceURL=${url}`
+    document.head.appendChild(el)
+  })
+
+  const requireModule = (spec: string): unknown => {
+    if (!modules.has(spec)) {
+      throw new Error(`client-loader: module "${spec}" is not available — not a seeded library and no loaded plugin registered it (check dshClient.inject ordering)`)
+    }
+    return modules.get(spec)
+  }
+
+  /** Tag styles the bundle injected during execution (unload bookkeeping; plugin CSS lands untagged). */
+  const claimStyles = (id: string): void => {
+    if (typeof document === 'undefined') return
+    for (const el of document.querySelectorAll('style:not([data-plugin])')) {
+      el.setAttribute('data-plugin', id)
+    }
+  }
+
+  /** Start (or reuse) the parallelizable fetch half. */
+  const prefetch = (record: PluginRecord): Promise<string> =>
+    (record.fetch ??= fetchBundle(record.entry.url))
+
+  async function doLoad(record: PluginRecord): Promise<void> {
+    const { id } = record.entry
+    record.state = 'loading'
+    publish(id, 'loading')
+    try {
+      // Dependencies must already be active (start() sequences this; direct
+      // load() callers get the same fail-loud check).
+      for (const dep of record.entry.inject) {
+        const depRecord = records.get(dep)
+        if (depRecord === undefined) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
+        if (depRecord.state !== 'active') throw new Error(`client-loader: "${id}" loaded before its dependency "${dep}" is active`)
+      }
+      const code = await prefetch(record)
+      executeBundle(code, record.entry.url)
+      if (slot === NOT_LOADED) throw new Error(`client-loader: bundle ${record.entry.url} executed without calling DSHClientProxy.loadPlugin`)
+      const handoff = slot
+      slot = NOT_LOADED
+      if (handoff.id !== id) throw new Error(`client-loader: bundle id mismatch — manifest "${id}" vs handoff "${handoff.id}"`)
+      const exports = handoff.factory(requireModule)
+      if (typeof exports.apply !== 'function') throw new Error(`client-loader: plugin "${id}" exports no apply function`)
+      // The whole export surface is the plugin: cordis object-plugin form
+      // keeps the bundle's exported `inject`/`name` (an apply-only pass would
+      // silently drop the dependency declaration — postmortem 0001).
+      const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
+      await fiber.await()
+      // Register under both specifier forms bundles emit: the bare package
+      // name (deep-import rewrites) and the /client subpath (CLIENT_EXTERNALS
+      // form) — the loaded surface IS the client half either way.
+      modules.set(id, exports)
+      modules.set(`${id}/client`, exports)
+      claimStyles(id)
+      record.state = 'active'
+      publish(id, 'active')
+    } catch (error) {
+      record.state = 'failed'
+      publish(id, 'failed')
+      throw error
+    }
+  }
+
+  const load = (id: string): Promise<void> => {
+    const record = records.get(id)
+    if (record === undefined) return Promise.reject(new Error(`client-loader: unknown plugin "${id}"`))
+    record.load ??= doLoad(record)
+    return record.load
+  }
+
+  /** Topologically order `ids` by inject (edges inside the set only — an early-group member never waits on a later-group one). */
+  const topo = (ids: string[]): string[] => {
+    const pool = new Set(ids)
+    const ordered: string[] = []
+    const done = new Set<string>()
+    const visiting = new Set<string>()
+    const visit = (id: string): void => {
+      if (done.has(id)) return
+      if (visiting.has(id)) throw new Error(`client-loader: inject cycle through "${id}"`)
+      visiting.add(id)
+      const record = records.get(id)
+      /* v8 ignore next -- ids come from records; unknown ids are caught per-dep below. */
+      if (record === undefined) throw new Error(`client-loader: manifest references unknown plugin "${id}"`)
+      for (const dep of record.entry.inject) {
+        if (!records.has(dep)) throw new Error(`client-loader: "${id}" injects unknown plugin "${dep}"`)
+        if (pool.has(dep)) visit(dep)
+      }
+      visiting.delete(id)
+      done.add(id)
+      ordered.push(id)
+    }
+    for (const id of ids) visit(id)
+    return ordered
+  }
+
+  let settledPromise: Promise<void> | undefined
+
+  async function run(): Promise<void> {
+    const all = [...records.values()]
+    const early = all.filter(r => r.entry.immediately === true)
+    const rest = all.filter(r => r.entry.immediately !== true)
+    // Early group: parallel fetch (all requests in flight at once), serial
+    // inject-topology execution, full-group barrier before anything else.
+    const earlyOrder = topo(early.map(r => r.entry.id))
+    for (const record of early) void prefetch(record).catch(() => {}) // surfaced by the awaited load below
+    for (const id of earlyOrder) await load(id)
+    // Remaining plugins: one by one in inject topology.
+    for (const id of topo(rest.map(r => r.entry.id))) await load(id)
+  }
+
+  return {
+    start: () => {
+      settledPromise ??= run()
+      // Failures surface through settled()/status — start() itself is fire-and-forget.
+      settledPromise.catch(() => {})
+    },
+    load,
+    unload: (id: string) => Promise.reject(new Error(`client-loader: unload("${id}") is not implemented (lands with HMR)`)),
+    settled: () => {
+      if (settledPromise === undefined) throw new Error('client-loader: settled() before start()')
+      return settledPromise
+    },
+    requireModule,
+    status,
+  }
+}

+ 165 - 0
packages/client/runtime/src/client/sessions/conversation.ts

@@ -0,0 +1,165 @@
+// ConversationSnapshot / ConversationNode: the only data shape the logic layer feeds the UI.
+// Immutability contract: every change swaps the top-level object; unchanged
+// substructures keep their references (the React.memo premise). callId/approvalId stay plain
+// string here (narrow to real brands when convenient).
+
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
+
+/** Assistant content blocks sorted by what the UI cares about
+ *  (text body / collapsible reasoning / tool-call card head / other fallback). */
+export type AssistantBlock =
+  | { kind: 'text'; text: string }
+  | { kind: 'reasoning'; text: string }
+  | { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
+  | { kind: 'other'; block: unknown }
+
+/**
+ * core ContentBlock[] -> AssistantBlock[] (classifier shared by finalized messages and partial block-end).
+ * @param content - core content blocks verbatim.
+ * @returns UI-classified blocks in source order.
+ */
+export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] {
+  return content.map(toAssistantBlock)
+}
+
+/**
+ * Classify one block (ToolCallBlock fields are id/arguments, mapped to callId/argsRaw).
+ * @param block - one core content block.
+ * @returns the UI classification.
+ */
+export function toAssistantBlock(block: ContentBlock): AssistantBlock {
+  switch (block.type) {
+    case 'text': return { kind: 'text', text: block.text }
+    case 'reasoning': return { kind: 'reasoning', text: block.text }
+    case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
+    default: return { kind: 'other', block }
+  }
+}
+
+/** A finalized user message. */
+export interface UserMessageNode {
+  kind: 'user'
+  seq: number
+  content: readonly ContentBlock[]
+  source: unknown
+}
+
+/** A finalized (or interruption-frozen) assistant message. */
+export interface AssistantMessageNode {
+  kind: 'assistant'
+  seq: number
+  turn: number
+  step: number
+  blocks: readonly AssistantBlock[]
+  usage?: unknown
+  /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
+   *  Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
+  interrupted?: true
+}
+
+/** A steering message injected mid-turn. */
+export interface SteeringMessageNode {
+  kind: 'steering'
+  seq: number
+  turn: number
+  content: readonly ContentBlock[]
+  source: unknown
+}
+
+/** A context/system injection surfaced in the flow. */
+export interface ContextMessageNode {
+  kind: 'context'
+  seq: number
+  content: readonly ContentBlock[]
+  source: unknown
+  meta?: unknown
+}
+
+/** A tool result paired (when in-window) with its call head. */
+export interface ToolResultNode {
+  kind: 'tool-result'
+  seq: number
+  callId: string
+  /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
+  call: { name: string; argsRaw: string } | null
+  content: readonly ContentBlock[]
+  isError: boolean
+  error?: { name: string; code: string }
+  meta?: unknown
+  /** Host-computed render intent from the paired tool/call's wire view; null = generic JSON card (documented default). */
+  callView: ToolCallView | null
+  /** Host-computed render intent from this tool/result's wire view; null = same default. */
+  resultView: ToolResultView | null
+}
+
+/** Fallback for surface events this UI version does not know. */
+export interface UnknownSurfaceNode {
+  kind: 'unknown'
+  seq: number
+  type: string
+  data: unknown
+}
+
+/** Finalized conversation node union (kind discriminates; seq is the React key). */
+export type ConversationNode =
+  | UserMessageNode
+  | AssistantMessageNode
+  | SteeringMessageNode
+  | ContextMessageNode
+  | ToolResultNode
+  | UnknownSurfaceNode
+
+/** In-flight tool card material: tool/call seen, tool/result not yet. */
+export interface RunningToolCall {
+  callId: string
+  name: string
+  argsRaw: string
+  turn: number
+  step: number
+  /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
+  callView: ToolCallView | null
+}
+
+/** Approval/question placeholder cards (visible, not answerable;
+ *  rpcId = the requested frame's envelope id, the future respond backfill key). */
+export type PendingInteraction =
+  | { kind: 'approval'; rpcId: RpcId; approvalId: string; toolName: string; callId?: string; reason?: string }
+  | { kind: 'question'; rpcId: RpcId; questions: readonly unknown[] }
+
+/** In-progress assistant output (chunk accumulator product). */
+export interface PartialAssistant {
+  turn: number
+  step: number
+  blocks: readonly AssistantBlock[]
+}
+
+/** History-open lifecycle of a Session window. */
+export type OpenState = 'cold' | 'loading' | 'open' | 'error'
+
+/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
+export interface PromptError {
+  op: 'send' | 'stop'
+  error: RpcError
+}
+
+/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
+export interface ConversationSnapshot {
+  sessionId: SessionId
+  /** Surface fold product (finalized conversation nodes in surface order). */
+  nodes: readonly ConversationNode[]
+  /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
+  foldDegraded: boolean
+  partial: PartialAssistant | null
+  runningCalls: readonly RunningToolCall[]
+  pending: readonly PendingInteraction[]
+  running: boolean
+  /** Set after host/session-removed; the UI grays out and disables input. */
+  removed: boolean
+  openState: OpenState
+  openError: RpcError | null
+  hasMore: boolean
+  loadingOlder: boolean
+  promptError: PromptError | null
+  lastAgentError: string | null
+}

+ 194 - 0
packages/client/runtime/src/client/sessions/fold-adapter.ts

@@ -0,0 +1,194 @@
+// FoldAdapter: core SurfaceManager wiring + node materialization cache.
+// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index);
+// a cross-window replace throw degrades to a lenient linear scan (foldDegraded —
+// the degradation lives in one branch function in this file, zero scattered removal points).
+
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+// Subpath export (package.json exports "./surface", alias added for this): all value imports
+// go through it — the package root points at lib/index.js (needs a build) which the vite
+// browser bundle cannot resolve; surface.ts has no Node dependencies.
+import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
+import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
+import type { ConversationNode } from './conversation.ts'
+import { toAssistantBlocks } from './conversation.ts'
+
+/** In-window tool/call index entry (result-card backfill + runningCalls material). */
+export interface CallIndexEntry {
+  name: string
+  argsRaw: string
+  turn: number
+  step: number
+  /** Wire view riding the tool/call (envelope-level; never inside the event). */
+  callView: ToolCallView | null
+}
+
+/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch).
+ *  'noop/padding' is not a real event type on purpose: a genuine type with fake data would
+ *  surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one
+ *  place a synthetic event enters the window). */
+function paddingEvent(seq: number): SessionEvent {
+  return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
+}
+
+/** One event -> UI node (pure function; the six-variant ConversationNode union). */
+function materializeNode(
+  event: SessionEvent,
+  callIndex: ReadonlyMap<string, CallIndexEntry>,
+  resultView: ToolResultView | null,
+): ConversationNode {
+  switch (event.type) {
+    case 'user/message':
+      return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
+    case 'assistant/message':
+      return {
+        kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step,
+        blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
+      }
+    case 'steering/message':
+      return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
+    case 'context/message':
+      return {
+        kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
+        meta: event.data.meta,
+      }
+    case 'tool/result': {
+      const call = callIndex.get(String(event.data.callId))
+      return {
+        kind: 'tool-result', seq: event.seq, callId: String(event.data.callId),
+        call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
+        content: event.data.content, isError: event.data.isError,
+        ...(event.data.error !== undefined ? { error: event.data.error } : {}),
+        meta: event.data.meta,
+        callView: call?.callView ?? null,
+        resultView,
+      }
+    }
+    /* v8 ignore next 2 -- defensive arm: fold output only carries the five
+    surface-eligible types, and each has a case above; reachable only if core
+    adds an eligible type. */
+    default:
+      return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data }
+  }
+}
+
+/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */
+export class FoldAdapter {
+  /** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
+  private padded: SessionEvent[] = []
+  private baseSeq = 0
+  private surface = new SurfaceManager(this.padded)
+  private nodeCache = new Map<number, ConversationNode>()
+  private degraded = false
+  private callIdx = new Map<string, CallIndexEntry>()
+  /** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
+  private resultViews = new Map<number, ToolResultView>()
+  /** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
+   *  window returns the previous ARRAY reference, not just cached elements — the snapshot's
+   *  reference-stability contract (§A.9.4) starts here. */
+  private rev = 0
+  private nodesResult: { rev: number; value: { nodes: ConversationNode[]; degraded: boolean } } | null = null
+
+  /** In-window tool/call index (Session uses it for runningCalls and result-card backfill). */
+  get callIndex(): ReadonlyMap<string, CallIndexEntry> {
+    return this.callIdx
+  }
+
+  /**
+   * Window rebuild (after open/resync/page prepend): new padded array, new
+   * SurfaceManager, cleared cache, rebuilt callIndex.
+   * @param events - the new window contents (seq-ascending).
+   * @param baseSeq - seq of the window head (sentinels pad below it).
+   * @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events).
+   */
+  reset(events: readonly SessionEvent[], baseSeq: number, views?: readonly (ToolEventView | undefined)[]): void {
+    this.rev++
+    this.baseSeq = baseSeq
+    this.padded = []
+    for (let i = 0; i < baseSeq; i++) this.padded.push(paddingEvent(i))
+    for (const event of events) this.padded.push(event)
+    this.surface = new SurfaceManager(this.padded)
+    this.nodeCache.clear()
+    this.degraded = false
+    this.callIdx = new Map()
+    this.resultViews.clear()
+    for (let i = 0; i < events.length; i++) {
+      const event = events[i]
+      /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
+      if (event !== undefined) this.indexCall(event, views?.[i])
+    }
+  }
+
+  /**
+   * Tail append (live session/event): push into the same array (incremental
+   * lazy fold applies) + incremental callIndex upkeep.
+   * @param event - the live event (seq = window tail + 1).
+   * @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering.
+   */
+  append(event: SessionEvent, view?: ToolEventView): void {
+    this.rev++
+    this.padded.push(event)
+    this.indexCall(event, view)
+  }
+
+  /**
+   * Current node array + degradation flag. Same revision -> same array
+   * reference (memo boundary); node object references always come from the per-seq cache.
+   * @returns the fold projection for the current window revision.
+   */
+  nodes(): { nodes: ConversationNode[]; degraded: boolean } {
+    if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value
+    let seqs: readonly number[]
+    if (this.degraded) {
+      seqs = this.degradedSeqs()
+    } else {
+      try {
+        seqs = this.surface.nodes
+      } catch (error) {
+        console.error('[web-runtime] surface fold failed, degrading to linear scan:', error)
+        this.degraded = true
+        seqs = this.degradedSeqs()
+      }
+    }
+    const out: ConversationNode[] = []
+    for (const seq of seqs) {
+      const cached = this.nodeCache.get(seq)
+      if (cached !== undefined) {
+        out.push(cached)
+        continue
+      }
+      const event = this.padded[seq]
+      /* v8 ignore next -- sparse guard: both seq sources (surface fold and degradedSeqs) only emit indexes present in padded. */
+      if (event === undefined) continue
+      const node = materializeNode(event, this.callIdx, this.resultViews.get(seq) ?? null)
+      this.nodeCache.set(seq, node)
+      out.push(node)
+    }
+    const value = { nodes: out, degraded: this.degraded }
+    this.nodesResult = { rev: this.rev, value }
+    return value
+  }
+
+  /** Degradation branch: lenient linear scan ignoring surfaceOp/replace (all surface-eligible events in append order). */
+  private degradedSeqs(): number[] {
+    const seqs: number[] = []
+    for (let i = this.baseSeq; i < this.padded.length; i++) {
+      const event = this.padded[i]
+      if (event !== undefined && isSurfaceEligibleType(event.type)) seqs.push(event.seq)
+    }
+    return seqs
+  }
+
+  private indexCall(event: SessionEvent, view?: ToolEventView): void {
+    if (event.type === 'tool/result') {
+      if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
+      return
+    }
+    if (event.type !== 'tool/call') return
+    this.callIdx.set(String(event.data.callId), {
+      name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
+      callView: view?.for === 'call' ? view.view : null,
+    })
+    // No backfill into already-materialized tool-result nodes for this callId
+    // (window order puts the call before its result; cannot happen on the normal path).
+  }
+}

+ 63 - 0
packages/client/runtime/src/client/sessions/lineage.ts

@@ -0,0 +1,63 @@
+// flattenLineage: summaries -> flat list with lineage indentation (pure function).
+// Roots sort by updatedAt desc, DFS expansion with children in the same order; orphaned lineage
+// degrades to root level; cycles fail soft and emit as roots.
+
+import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
+
+/** One flattened session-list row (summary + lineage indent depth). */
+export interface SessionListEntry {
+  sessionId: SessionId
+  updatedAt: number
+  running: boolean
+  parentSessionId?: SessionId
+  cwd?: string
+  /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
+  depth: number
+}
+
+/**
+ * summaries -> flat list with lineage indentation (pure; roots by updatedAt
+ * desc, DFS children in the same order, orphans degrade to roots).
+ * @param summaries - the host's session.list items.
+ * @returns display rows in render order.
+ */
+export function flattenLineage(summaries: readonly SessionSummary[]): SessionListEntry[] {
+  const byId = new Map<SessionId, SessionSummary>()
+  for (const s of summaries) byId.set(s.sessionId, s)
+
+  const children = new Map<SessionId, SessionSummary[]>()
+  const roots: SessionSummary[] = []
+  for (const s of summaries) {
+    if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) {
+      const list = children.get(s.parentSessionId) ?? []
+      list.push(s)
+      children.set(s.parentSessionId, list)
+    } else {
+      roots.push(s) // root, or an orphan whose parent is absent from summaries (degrade to root, never drop)
+    }
+  }
+
+  const byUpdatedDesc = (a: SessionSummary, b: SessionSummary): number => b.updatedAt - a.updatedAt
+  roots.sort(byUpdatedDesc)
+
+  const out: SessionListEntry[] = []
+  const visited = new Set<SessionId>()
+  const walk = (s: SessionSummary, depth: number): void => {
+    if (visited.has(s.sessionId)) {
+      console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`)
+      return
+    }
+    visited.add(s.sessionId)
+    out.push({ ...s, depth })
+    const kids = children.get(s.sessionId)
+    if (kids === undefined) return
+    kids.sort(byUpdatedDesc)
+    for (const kid of kids) walk(kid, depth + 1)
+  }
+  for (const root of roots) walk(root, 0)
+  // Cycle members (unreachable from any root): emit as roots so no entry is lost.
+  for (const s of summaries) {
+    if (!visited.has(s.sessionId)) walk(s, 0)
+  }
+  return out
+}

+ 250 - 0
packages/client/runtime/src/client/sessions/manager.ts

@@ -0,0 +1,250 @@
+// SessionManager: the instance cluster Map<SessionId, Session> (lazy-built, resident) + the frame
+// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
+// List data never enters zustand; React connects via subscribe/getListSnapshot.
+
+import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
+import { transportError } from '@deepseek-ai/dsh-client-connection/client'
+import type { SessionListEntry } from './lineage.ts'
+import { flattenLineage } from './lineage.ts'
+import { Notifier } from './notifier.ts'
+import { Session } from './session.ts'
+
+/** Immutable session-list snapshot for useSessionList. */
+export interface SessionListSnapshot {
+  items: readonly SessionListEntry[]
+  state: 'idle' | 'loading' | 'error'
+  error: RpcError | null
+}
+
+/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
+const PENDING_BUFFER_CAP = 32
+
+/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
+export class SessionManager {
+  private readonly sessions = new Map<SessionId, Session>()
+  /** Approval/question frame buffer for uninstantiated sessions: pending interactions never hit
+   *  history (cannot be backfilled on open), the one frame class that must not take the
+   *  drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
+   *  frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
+  private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
+  private summaries: SessionSummary[] = []
+  private listState: 'idle' | 'loading' | 'error' = 'idle'
+  private listError: RpcError | null = null
+  private listInflight: Promise<void> | null = null
+
+  private listSnapshotCache: SessionListSnapshot
+  /** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
+   *  object when every field matches — wire refreshes mint all-new summary objects, so identity
+   *  must be recovered by value or every SessionListItem memo misses on every refresh (audit S5). */
+  private entryCache = new Map<SessionId, SessionListEntry>()
+  private itemsCache: readonly SessionListEntry[] = []
+  private readonly notifier = new Notifier(() => {
+    this.listSnapshotCache = this.buildListSnapshot()
+  })
+
+  constructor(private readonly api: IApiClient) {
+    this.listSnapshotCache = this.buildListSnapshot()
+  }
+
+  // ---- Instance management ----
+
+  /**
+   * Lazy build: return the existing instance or construct one (no auto-open —
+   * open is triggered by the container's select callback).
+   * @param sessionId - the session to get.
+   * @returns the resident instance.
+   */
+  get(sessionId: SessionId): Session {
+    let session = this.sessions.get(sessionId)
+    if (session === undefined) {
+      session = new Session(sessionId, this.api)
+      this.sessions.set(sessionId, session)
+      // Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
+      const summary = this.summaries.find(s => s.sessionId === sessionId)
+      if (summary !== undefined) session.handleRunning(summary.running)
+      // Replay approval/question frames buffered before instantiation (rpcId verbatim, same semantics as the subscribed baseline replay).
+      const buffered = this.pendingBuffers.get(sessionId)
+      if (buffered !== undefined) {
+        this.pendingBuffers.delete(sessionId)
+        for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload)
+      }
+    }
+    return session
+  }
+
+  // ---- List surface ----
+
+  /** Full refresh via session.list (single-flight: an in-flight call is reused). */
+  refreshList(): Promise<void> {
+    if (this.listInflight !== null) return this.listInflight
+    this.listState = 'loading'
+    this.listError = null
+    this.notifier.markDirty()
+    this.listInflight = (async () => {
+      try {
+        const { result } = await this.api.sessions.list({})
+        if (result.ok) {
+          this.summaries = result.value.items
+          this.listState = 'idle'
+          // Push running bits down to instantiated Sessions (the list is the authoritative summary source).
+          for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
+        } else {
+          this.listState = 'error'
+          this.listError = result.error
+        }
+      } catch (error) {
+        this.listState = 'error'
+        const folded = transportError<never>(error)
+        /* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
+        this.listError = folded.ok ? null : folded.error
+      } finally {
+        this.listInflight = null
+        this.notifier.markDirty()
+      }
+    })()
+    return this.listInflight
+  }
+
+  /**
+   * Contract session.create; on success merge into summaries immediately (no
+   * wait for the next refresh).
+   * @param cwd - optional working directory for the new session.
+   * @returns the create result.
+   */
+  async create(cwd?: string): Promise<RpcResult<{ sessionId: SessionId }>> {
+    try {
+      const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd })
+      if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) {
+        this.summaries = [
+          { sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) },
+          ...this.summaries,
+        ]
+        this.notifier.markDirty()
+      }
+      return result
+    } catch (error) {
+      return transportError(error)
+    }
+  }
+
+  // ---- Subscription surface (for useSessionList) ----
+
+  /**
+   * uSES subscription entry for useSessionList.
+   * @param listener - change callback.
+   * @returns the unsubscribe function.
+   */
+  subscribe(listener: () => void): () => void {
+    return this.notifier.subscribe(listener)
+  }
+
+  /**
+   * Cached list snapshot (rebuilt lazily when dirty with no listeners).
+   * @returns the cached reference (stable until the next flush).
+   */
+  getListSnapshot(): SessionListSnapshot {
+    this.notifier.ensureFresh()
+    return this.listSnapshotCache
+  }
+
+  // ---- ConnectionController sinks (wired by boot) ----
+
+  /**
+   * Mux frame entry: sessionId-bearing frames go only to instantiated sessions
+   * (no lazy build; non-pending frames for uninstantiated sessions drop —
+   * history backfills them on open).
+   * @param envelope - the frame with its wire rpcId.
+   */
+  handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
+    const frame = envelope.payload
+    if (frame.type === 'stream/error') return // Controller already treats this as stream failure
+    const session = this.sessions.get(frame.sessionId)
+    if (session === undefined) {
+      // Approval/question frames never hit history: buffer for replay on instantiation;
+      // everything else drops (not instantiated — history fully backfills on open).
+      switch (frame.type) {
+        case 'approval/requested':
+        case 'approval/resolved':
+        case 'question/requested':
+        case 'question/resolved': {
+          const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
+          buffer.push(envelope)
+          if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
+          this.pendingBuffers.set(frame.sessionId, buffer)
+          return
+        }
+        default:
+          return
+      }
+    }
+    session.handleMuxEnvelope(envelope.rpcId, frame)
+  }
+
+  /**
+   * Host frame entry: list upkeep + per-instance running/removed/agent-error relay.
+   * @param envelope - the frame with its wire rpcId.
+   */
+  handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
+    const frame = envelope.payload
+    switch (frame.type) {
+      case 'host/session-added': {
+        if (!this.summaries.some(s => s.sessionId === frame.sessionId)) {
+          this.summaries = [
+            {
+              sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
+              ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
+            },
+            ...this.summaries,
+          ]
+          this.notifier.markDirty()
+        }
+        return
+      }
+      case 'host/session-removed': {
+        this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
+        this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
+        this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
+        this.notifier.markDirty()
+        return
+      }
+      case 'host/session-status': {
+        this.summaries = this.summaries.map(s =>
+          s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s)
+        this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
+        this.notifier.markDirty()
+        return
+      }
+      case 'host/agent-error': {
+        this.sessions.get(frame.sessionId)?.handleAgentError(frame.message)
+        return // not reflected in the list
+      }
+      default:
+        return // stream/error ignored; unknown frames ignored (documented default)
+    }
+  }
+
+  /** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
+  handleConnected(): void {
+    void this.refreshList()
+    for (const session of this.sessions.values()) void session.resync()
+  }
+
+  private buildListSnapshot(): SessionListSnapshot {
+    const fresh = flattenLineage(this.summaries)
+    const items = fresh.map((entry) => {
+      const prev = this.entryCache.get(entry.sessionId)
+      if (
+        prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
+        && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.depth === entry.depth
+      ) return prev
+      this.entryCache.set(entry.sessionId, entry)
+      return entry
+    })
+    for (const id of this.entryCache.keys()) {
+      if (!items.some(e => e.sessionId === id)) this.entryCache.delete(id)
+    }
+    const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
+    if (!sameOrder) this.itemsCache = items
+    return { items: this.itemsCache, state: this.listState, error: this.listError }
+  }
+}

+ 61 - 0
packages/client/runtime/src/client/sessions/notifier.ts

@@ -0,0 +1,61 @@
+// Notifier: subscription + microtask-batched notification primitive shared by Session and
+// SessionManager. Semantics: N markDirty calls collapse into one microtask flush;
+// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
+// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
+// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
+
+/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
+export class Notifier {
+  private listeners = new Set<() => void>()
+  private dirty = false
+  private scheduled = false
+
+  /** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
+  constructor(private readonly rebuild: () => void) {}
+
+  /**
+   * uSES subscription entry.
+   * @param listener - change callback.
+   * @returns the unsubscribe function.
+   */
+  subscribe(listener: () => void): () => void {
+    this.listeners.add(listener)
+    return () => {
+      this.listeners.delete(listener)
+    }
+  }
+
+  /** State-change entry: mark dirty and schedule the batched flush. */
+  markDirty(): void {
+    this.dirty = true
+    if (this.scheduled) return
+    this.scheduled = true
+    queueMicrotask(() => {
+      this.scheduled = false
+      if (!this.dirty) return
+      if (this.listeners.size === 0) return // lazy: no subscribers, keep dirty for the next getSnapshot
+      this.dirty = false
+      this.rebuild()
+      for (const listener of this.listeners) listener()
+    })
+  }
+
+  /**
+   * Synchronous flush: controlled-input writes must notify in the same tick as
+   * onChange, or React rolls the DOM back to the stale value and the caret jumps to the end.
+   */
+  notifyNow(): void {
+    this.dirty = true
+    if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
+    this.dirty = false
+    this.rebuild()
+    for (const listener of this.listeners) listener()
+  }
+
+  /** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). */
+  ensureFresh(): void {
+    if (!this.dirty) return
+    this.dirty = false
+    this.rebuild()
+  }
+}

+ 89 - 0
packages/client/runtime/src/client/sessions/partial.ts

@@ -0,0 +1,89 @@
+// PartialAccumulator: assistant/chunk accumulator.
+// Folds the six StreamChunk variants into AssistantBlock[] keyed by block index;
+// block-level immutability (a delta only swaps that block's reference).
+
+import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
+import type { AssistantBlock, PartialAssistant } from './conversation.ts'
+import { toAssistantBlock } from './conversation.ts'
+
+/** assistant/chunk accumulator: folds StreamChunks into AssistantBlock[] with block-level immutability. */
+export class PartialAccumulator {
+  // Sparse on purpose: block-start may arrive out of order, leaving holes until compaction.
+  private blocks: (AssistantBlock | undefined)[] = []
+  private changed = true
+  private snapshot: PartialAssistant
+
+  constructor(readonly turn: number, readonly step: number) {
+    this.snapshot = { turn, step, blocks: [] }
+  }
+
+  /**
+   * Fold one chunk.
+   * @param chunk - the stream chunk.
+   * @returns whether it caused a visible change (usage/finish return false, skipping notification).
+   */
+  push(chunk: StreamChunk): boolean {
+    switch (chunk.type) {
+      case 'block-start': {
+        this.blocks[chunk.index] = emptyBlock(chunk.blockType)
+        this.changed = true
+        return true
+      }
+      case 'text-delta': {
+        const prev = this.blocks[chunk.index]
+        this.blocks[chunk.index] = { kind: 'text', text: (prev?.kind === 'text' ? prev.text : '') + chunk.text }
+        this.changed = true
+        return true
+      }
+      case 'reasoning-delta': {
+        const prev = this.blocks[chunk.index]
+        this.blocks[chunk.index] = { kind: 'reasoning', text: (prev?.kind === 'reasoning' ? prev.text : '') + chunk.text }
+        this.changed = true
+        return true
+      }
+      case 'tool-call-delta': {
+        const prev = this.blocks[chunk.index]
+        const base = prev?.kind === 'tool-call' ? prev : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
+        this.blocks[chunk.index] = {
+          kind: 'tool-call',
+          callId: base.callId || String(chunk.id),
+          name: chunk.name ?? base.name,
+          argsRaw: base.argsRaw + chunk.argumentsDelta,
+        }
+        this.changed = true
+        return true
+      }
+      case 'block-end': {
+        this.blocks[chunk.index] = toAssistantBlock(chunk.block)
+        this.changed = true
+        return true
+      }
+      default:
+        // usage / finish / merge-extensible unknown variants: no visible block change
+        // (finish is immediately followed by the assistant/message that supersedes the partial).
+        return false
+    }
+  }
+
+  /**
+   * Current partial projection.
+   * @returns the cached snapshot (the blocks array reference only changes after a mutation).
+   */
+  toPartial(): PartialAssistant {
+    if (this.changed) {
+      // Compact sparse indexes (out-of-order block-start) into render order.
+      this.snapshot = { turn: this.turn, step: this.step, blocks: this.blocks.filter((b): b is AssistantBlock => b !== undefined) }
+      this.changed = false
+    }
+    return this.snapshot
+  }
+}
+
+function emptyBlock(blockType: string): AssistantBlock {
+  switch (blockType) {
+    case 'text': return { kind: 'text', text: '' }
+    case 'reasoning': return { kind: 'reasoning', text: '' }
+    case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
+    default: return { kind: 'other', block: null }
+  }
+}

+ 227 - 0
packages/client/runtime/src/client/sessions/service.ts

@@ -0,0 +1,227 @@
+/**
+ * SessionsService: root sessions service — list snapshot store (manager
+ * projection), session scope tree (mintScope pattern: no-op plugin Fiber +
+ * ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
+ *
+ * Scope lifecycle is watch-driven: a scope is minted lazily on first
+ * resolution; a session leaving the list tears its scope down only when
+ * nobody is watching it. "Watched" is approximated as the most recently
+ * resolved binding id — SessionProvider re-resolves on every selection
+ * change (keyed remount), so a switch away always re-evaluates the deferred
+ * teardown; a host-side death without list removal keeps the scope (frozen
+ * read-only view).
+ */
+import type { Context, Fiber } from 'cordis'
+import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
+import { SessionManager } from './manager.ts'
+import type { Session } from './session.ts'
+
+/** Session list row projected from the host list RPC plus live stream increments. */
+export interface SessionSummary {
+  id: SessionId
+  title: string
+  cwd?: string
+  parentId?: SessionId
+  running: boolean
+  updatedAt: number
+}
+
+/** Session list store shape. */
+export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary> }
+
+/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
+export interface SessionBinding {
+  readonly sessionId: SessionId
+  readonly session: Session
+  readonly ctx: Context
+}
+
+/** Scope tag key (client counterpart of the host dsh-scope pattern). */
+const kScope = Symbol('dsh.client.scope')
+
+/**
+ * Read the session scope tag off a context.
+ * @param ctx - any client context.
+ * @returns the session id, or undefined on root contexts.
+ */
+export function scopeOf(ctx: Context): SessionId | undefined {
+  return (ctx as Context & { [kScope]?: SessionId })[kScope]
+}
+
+/** Shared no-op plugin backing each session scope fiber. */
+function sessionScope(): void {}
+
+/**
+ * Display title projection. The wire summary carries no title yet (P-I
+ * ledger): the project directory's basename stands in, then the raw id.
+ */
+function titleOf(cwd: string | undefined, id: SessionId): string {
+  if (cwd !== undefined && cwd !== '') {
+    const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
+    if (base !== undefined && base !== '') return base
+  }
+  return id
+}
+
+interface ScopeRecord {
+  fiber: Fiber
+  ctx: Context
+  binding: SessionBinding
+}
+
+/** Root sessions service: list store, object-layer manager, scope tree, bindings, ancestry. */
+export class SessionsService {
+  /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect). */
+  readonly list: SnapshotStore<SessionListState>
+  /** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
+  readonly manager: SessionManager
+
+  private readonly scopes = new Map<SessionId, ScopeRecord>()
+  /** Most recently resolved binding id — the watch approximation for deferred teardown. */
+  private watched: SessionId | undefined
+  /** Removed-while-watched sessions whose teardown waits for the watch to move away. */
+  private readonly deferredRemovals = new Set<SessionId>()
+
+  /**
+   * @param ctx - client root context (scope fibers mount under it).
+   * @param api - wire client shared with every Session.
+   */
+  constructor(private readonly rootCtx: Context, api: IApiClient) {
+    this.manager = new SessionManager(api)
+    this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {} })
+    // The manager owns wire truth; the store is its projection. Manager
+    // notifications are already microtask-batched.
+    this.manager.subscribe(() => { this.projectList() })
+    rootCtx.reflect.provide('sessions', this, undefined)
+  }
+
+  /**
+   * Create a session on the host.
+   * @param opts - creation options (project directory).
+   * @returns the new session id.
+   */
+  async create(opts: { cwd?: string } = {}): Promise<SessionId> {
+    const result = await this.manager.create(opts.cwd)
+    if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`)
+    return result.value.sessionId
+  }
+
+  /**
+   * Resolve a session-scoped context view (use-and-discard).
+   * @param id - session id.
+   * @returns scoped ctx, or undefined for a session neither listed nor already scoped.
+   */
+  scope(id: SessionId): Context | undefined {
+    return this.resolve(id)?.ctx
+  }
+
+  /**
+   * Resolve the stable session binding (SessionProvider's resolveBinding feed).
+   * @param id - session id.
+   * @returns binding, or undefined for a session neither listed nor already scoped.
+   */
+  binding(id: SessionId): SessionBinding | undefined {
+    const record = this.resolve(id)
+    if (record === undefined) return undefined
+    if (this.watched !== id) {
+      this.watched = id
+      this.sweepDeferred()
+    }
+    return record.binding
+  }
+
+  /**
+   * Breadcrumb feed: walk parentId links inside the list store.
+   * @param id - session id.
+   * @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk).
+   */
+  ancestry(id: SessionId): SessionSummary[] {
+    const { byId } = this.list.getSnapshot()
+    const chain: SessionSummary[] = []
+    let cursor: SessionId | undefined = id
+    while (cursor !== undefined) {
+      const summary: SessionSummary | undefined = byId[cursor]
+      if (summary === undefined || chain.includes(summary)) break
+      chain.unshift(summary)
+      cursor = summary.parentId
+    }
+    return chain
+  }
+
+  /** Lazily mint the scope + binding for a listed (or already-scoped) session. */
+  private resolve(id: SessionId): ScopeRecord | undefined {
+    const existing = this.scopes.get(id)
+    if (existing !== undefined) return existing
+    // Frozen scopes outlive the list; new scopes are only minted for listed sessions.
+    if (this.list.getSnapshot().byId[id] === undefined) return undefined
+    const fiber = this.rootCtx.plugin(sessionScope)
+    const ctx = fiber.ctx.extend({ [kScope]: id })
+    const record: ScopeRecord = {
+      fiber,
+      ctx,
+      binding: { sessionId: id, session: this.manager.get(id), ctx },
+    }
+    this.scopes.set(id, record)
+    return record
+  }
+
+  /** Project the manager's list snapshot into the store (title derivation is display-only). */
+  private projectList(): void {
+    const items = this.manager.getListSnapshot().items
+    const ids: SessionId[] = []
+    const byId: Record<SessionId, SessionSummary> = {}
+    for (const entry of items) {
+      ids.push(entry.sessionId)
+      byId[entry.sessionId] = {
+        id: entry.sessionId,
+        title: titleOf(entry.cwd, entry.sessionId),
+        running: entry.running,
+        updatedAt: entry.updatedAt,
+        ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
+        ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
+      }
+    }
+    this.list.set({ ids, byId })
+    this.pruneScopes(byId)
+  }
+
+  /** Tear down scopes for removed sessions nobody watches; the watched one defers until the watch moves. */
+  private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
+    for (const [id, record] of this.scopes) {
+      if (byId[id] !== undefined) continue
+      if (id === this.watched) {
+        this.deferredRemovals.add(id)
+        continue
+      }
+      this.scopes.delete(id)
+      this.deferredRemovals.delete(id)
+      void record.fiber.dispose()
+    }
+  }
+
+  /** Run deferred teardowns whose session is no longer watched (called when the watch moves). */
+  private sweepDeferred(): void {
+    for (const id of [...this.deferredRemovals]) {
+      /* v8 ignore next -- defensive: only the watched id ever defers, and every
+       * watch move sweeps first, so the set cannot contain the id the watch just
+       * moved to; kept as a guard against future extra sweep call sites. */
+      if (id === this.watched) continue
+      // Still absent from the list? (A re-added id cancels the deferred teardown.)
+      if (this.list.getSnapshot().byId[id] !== undefined) {
+        this.deferredRemovals.delete(id)
+        continue
+      }
+      const record = this.scopes.get(id)
+      this.deferredRemovals.delete(id)
+      /* v8 ignore next -- defensive: prune deletes a scope and its deferral
+       * together, so a deferred id always still owns its record; kept so a
+       * future teardown path cannot double-dispose. */
+      if (record !== undefined) {
+        this.scopes.delete(id)
+        void record.fiber.dispose()
+      }
+    }
+  }
+}

+ 527 - 0
packages/client/runtime/src/client/sessions/session.ts

@@ -0,0 +1,527 @@
+// Session: wraps every contract call that needs a sessionId + all conversation state for this
+// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
+// created, they keep consuming mux frames in the background; React connects directly via
+// subscribe/getSnapshot.
+
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client'
+import { transportError } from '@deepseek-ai/dsh-client-connection/client'
+import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
+import type {
+  ConversationNode, ConversationSnapshot, OpenState, PendingInteraction, PromptError, RunningToolCall,
+} from './conversation.ts'
+import { FoldAdapter } from './fold-adapter.ts'
+import { Notifier } from './notifier.ts'
+import { PartialAccumulator } from './partial.ts'
+
+/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
+export const PAGE_MESSAGES = 50
+
+/** Per-session state owner: event window + fold + partial, snapshot out via uSES (see the web client architecture RFC). */
+export class Session implements ObservableSnapshot<ConversationSnapshot> {
+  /** Typed selector hook bound to this instance (the SessionBinding `useSession` source). */
+  readonly useSelector: SnapshotSelectorHook<ConversationSnapshot> = bindSnapshotSelector(this)
+
+  // ---- Window and derived state (all private; the snapshot is the only read surface) ----
+  private events: SessionEvent[] = []
+  /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
+   *  Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
+  private views: (ToolEventView | undefined)[] = []
+  private baseSeq = 0
+  private hasMore = false
+  private openState: OpenState = 'cold'
+  private openError: RpcError | null = null
+  private openPromise: Promise<void> | null = null
+  /** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt
+   *  a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
+   *  passes drop all writes once the generation moves on. */
+  private openGeneration = 0
+  private loadingOlder = false
+  private readonly foldAdapter = new FoldAdapter()
+  private partial: PartialAccumulator | null = null
+  private openCalls = new Map<string, RunningToolCall>()
+  /** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq.
+   *  Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
+  private frozenNodes: ConversationNode[] = []
+  private pending = new Map<string, PendingInteraction>()
+  // Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
+  // audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
+  // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
+  // tool card and pending card). Mutation sites bump the matching revision. partial needs no
+  // counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
+  private callsRev = 0
+  private callsCache: { rev: number; value: RunningToolCall[] } | null = null
+  private pendingRev = 0
+  private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
+  private frozenRev = 0
+  private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
+  private running = false
+  private removed = false
+  private promptError: PromptError | null = null
+  private lastAgentError: string | null = null
+  /** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
+  private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
+  /** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
+  private stitching = false
+  /** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
+  private subscribedLastSeq: number | null = null
+
+  private snapshotCache: ConversationSnapshot
+  private readonly notifier = new Notifier(() => {
+    this.snapshotCache = this.buildSnapshot()
+  })
+
+  constructor(readonly sessionId: SessionId, private readonly api: IApiClient) {
+    this.snapshotCache = this.buildSnapshot()
+  }
+
+  // ---- Operations ----
+
+  /**
+   * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
+   * @param content - core content blocks verbatim.
+   * @param mode - queue appends after the current turn; steer interrupts it.
+   * @returns the prompt result (also mirrored into promptError on failure).
+   */
+  async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
+    this.promptError = null
+    this.lastAgentError = null
+    this.notifier.markDirty()
+    let result: RpcResult<{ accepted: true }>
+    try {
+      result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
+    } catch (error) {
+      result = transportError(error)
+    }
+    if (!result.ok) {
+      this.promptError = { op: 'send', error: result.error }
+      this.notifier.markDirty()
+    }
+    return result
+  }
+
+  /**
+   * Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot).
+   * @returns the cancel result.
+   */
+  async cancel(): Promise<RpcResult<{ accepted: true }>> {
+    let result: RpcResult<{ accepted: true }>
+    try {
+      result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
+    } catch (error) {
+      result = transportError(error)
+    }
+    if (!result.ok) {
+      this.promptError = { op: 'stop', error: result.error }
+      this.notifier.markDirty()
+    }
+    return result
+  }
+
+  /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
+  open(): Promise<void> {
+    if (this.openState === 'open') return Promise.resolve()
+    if (this.openPromise !== null) return this.openPromise
+    const promise = this.doOpen(this.openGeneration).finally(() => {
+      // Identity-guarded: a superseded open must not null out the promise resync just started.
+      if (this.openPromise === promise) this.openPromise = null
+    })
+    this.openPromise = promise
+    return promise
+  }
+
+  /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
+  async loadOlder(): Promise<void> {
+    if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
+    this.loadingOlder = true
+    this.notifier.markDirty()
+    try {
+      const { result } = await this.api.sessions.history({
+        sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
+      })
+      if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
+      const older = result.value.events
+      if (older.length === 0) {
+        this.hasMore = result.value.hasMore
+        return
+      }
+      const tail = older[older.length - 1]
+      if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
+        // §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
+        console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`)
+        this.hasMore = false
+        return
+      }
+      this.events = [...older.map(e => e.event), ...this.events]
+      this.views = [...older.map(e => e.view), ...this.views]
+      /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
+      this.baseSeq = older[0]?.event.seq ?? this.baseSeq
+      this.hasMore = result.value.hasMore
+      this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
+      this.rebuildDerivedFromWindow()
+    } catch (error) {
+      console.error('[web-runtime] loadOlder failed:', error)
+    } finally {
+      this.loadingOlder = false
+      this.notifier.markDirty()
+    }
+  }
+
+  /** Reconnect rebuild (manager calls this on onConnected for instances that were opened):
+   *  reset the window and rerun open; pending waits for the baseline replay. Invalidates any
+   *  in-flight open first — its history request rode the dead connection and must not settle
+   *  the fresh generation into 'error' (audit S4). */
+  async resync(): Promise<void> {
+    if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
+    this.openGeneration++
+    this.openPromise = null
+    this.openState = 'cold'
+    this.openError = null
+    this.events = []
+    this.views = []
+    this.baseSeq = 0
+    this.pending.clear() // the subscribed baseline replay re-sends still-pending requested frames verbatim
+    this.pendingRev++
+    this.subscribedLastSeq = null
+    this.liveBuffer = []
+    this.notifier.markDirty()
+    await this.open()
+  }
+
+  // ---- Subscription surface (useSyncExternalStore direct wiring) ----
+
+  /**
+   * uSES subscription entry.
+   * @param listener - change callback.
+   * @returns the unsubscribe function.
+   */
+  subscribe(listener: () => void): () => void {
+    return this.notifier.subscribe(listener)
+  }
+
+  /**
+   * Cached conversation snapshot (rebuilt lazily when dirty with no listeners).
+   * @returns the cached reference (stable until the next flush).
+   */
+  getSnapshot(): ConversationSnapshot {
+    this.notifier.ensureFresh()
+    return this.snapshotCache
+  }
+
+  // ---- Manager-only entry points (@internal; never called by the UI) ----
+
+  /**
+   * Mux frame arrival (the dispatch switch).
+   * @param rpcId - the frame envelope id (the respond backfill key for requested frames).
+   * @param frame - the routed frame.
+   */
+  handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void {
+    switch (frame.type) {
+      case 'session/event': {
+        this.acceptLiveEvent(frame.event, frame.view)
+        return
+      }
+      case 'session/subscribed': {
+        this.subscribedLastSeq = frame.lastSeq
+        return // pure baseline bookkeeping, no visible change
+      }
+      case 'approval/requested': {
+        this.pending.set(`a:${rpcId}`, {
+          kind: 'approval', rpcId, approvalId: frame.approvalId, toolName: frame.toolName,
+          ...(frame.callId !== undefined ? { callId: frame.callId } : {}),
+          ...(frame.reason !== undefined ? { reason: frame.reason } : {}),
+        })
+        this.pendingRev++
+        this.notifier.markDirty()
+        return
+      }
+      case 'approval/resolved': {
+        for (const [key, item] of this.pending) {
+          if (item.kind === 'approval' && item.approvalId === frame.approvalId) {
+            this.pending.delete(key)
+            this.pendingRev++
+          }
+        }
+        this.notifier.markDirty()
+        return
+      }
+      case 'question/requested': {
+        this.pending.set(`q:${rpcId}`, { kind: 'question', rpcId, questions: frame.questions })
+        this.pendingRev++
+        this.notifier.markDirty()
+        return
+      }
+      case 'question/resolved': {
+        if (this.pending.delete(`q:${frame.questionRpcId}`)) this.pendingRev++
+        this.notifier.markDirty()
+        return
+      }
+      default:
+        return // stream/error never reaches Session (Controller converges it); unknown frames ignored (documented default)
+    }
+  }
+
+  /**
+   * Running-bit relay from the host stream (list entry and snapshot stay consistent).
+   * @param running - the new running state.
+   */
+  handleRunning(running: boolean): void {
+    if (this.running === running) return
+    this.running = running
+    this.notifier.markDirty()
+  }
+
+  /** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
+  handleRemoved(): void {
+    this.removed = true
+    this.notifier.markDirty()
+  }
+
+  /**
+   * host/agent-error relay: the only outlet for live failures with no turn position.
+   * @param message - the stringified error.
+   */
+  handleAgentError(message: string): void {
+    this.lastAgentError = message
+    this.notifier.markDirty()
+  }
+
+  /** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed
+   *  in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
+  dispose(): void {}
+
+  // ---- 私有 ----
+
+  /** @param generation - openGeneration at launch; every await re-checks it and a stale pass
+   *  drops all writes (resync superseded this open — its outcome belongs to a dead connection). */
+  private async doOpen(generation: number): Promise<void> {
+    this.openState = 'loading'
+    this.openError = null
+    this.notifier.markDirty()
+    try {
+      let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
+      if (generation !== this.openGeneration) return
+      if (!result.ok) {
+        this.openState = 'error'
+        this.openError = result.error
+        return
+      }
+      this.installWindow(result.value.events, result.value.hasMore)
+      // Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
+      const tailSeq = this.windowTailSeq()
+      if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
+        result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
+        if (generation !== this.openGeneration) return
+        if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
+      }
+      this.openState = 'open'
+    } catch (error) {
+      if (generation !== this.openGeneration) return
+      this.openState = 'error'
+      const folded = transportError<never>(error)
+      /* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
+      this.openError = folded.ok ? null : folded.error
+    } finally {
+      if (generation === this.openGeneration) this.notifier.markDirty()
+    }
+  }
+
+  /** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
+   *  Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
+   *  (doOpen flips it after install), so recursing would push every buffered event straight
+   *  back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
+  private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
+    this.events = entries.map(e => e.event)
+    this.views = entries.map(e => e.view)
+    this.baseSeq = this.events[0]?.seq ?? 0
+    this.hasMore = hasMore
+    this.foldAdapter.reset(this.events, this.baseSeq, this.views)
+    this.rebuildDerivedFromWindow()
+    const buffered = this.liveBuffer
+    this.liveBuffer = []
+    for (const item of buffered) this.appendLive(item.event, item.view)
+    this.notifier.markDirty()
+  }
+
+  /** Seq-guarded append shared by stitching and the open-state live path. */
+  private appendLive(event: SessionEvent, view?: ToolEventView): void {
+    const tailSeq = this.windowTailSeq()
+    if (tailSeq !== null && event.seq <= tailSeq) return // replay overlap, drop
+    this.events.push(event)
+    this.views.push(view)
+    this.foldAdapter.append(event, view)
+    this.applyEventSideEffects(event, view)
+  }
+
+  /** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
+   *  a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
+   *  expected reconnect-window artifact, repaired by refetch — never fed to the fold to trip
+   *  its continuity assertion into the degraded view). */
+  private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void {
+    if (this.openState === 'loading' || this.stitching) {
+      this.liveBuffer.push({ event, view })
+      return
+    }
+    if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open)
+    const tailSeq = this.windowTailSeq()
+    if (tailSeq !== null && event.seq > tailSeq + 1) {
+      this.liveBuffer.push({ event, view })
+      void this.repairGap()
+      return
+    }
+    this.appendLive(event, view)
+    this.notifier.markDirty()
+  }
+
+  /** Resync-lite (audit S3): repull the tail page and stitch the liveBuffer through the shared
+   *  installWindow path. No openState transition — the UI keeps the current window (no loading
+   *  flash); events arriving meanwhile detour to liveBuffer via the stitching flag. */
+  private async repairGap(): Promise<void> {
+    /* v8 ignore next -- re-entry guard: acceptLiveEvent already detours to liveBuffer while stitching, so no second call reaches here. */
+    if (this.stitching) return
+    this.stitching = true
+    const generation = this.openGeneration
+    try {
+      const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
+      // Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
+      if (result.ok && generation === this.openGeneration && this.openState === 'open') {
+        this.installWindow(result.value.events, result.value.hasMore)
+      }
+    } catch (error) {
+      console.error('[web-runtime] gap repair failed:', error)
+    } finally {
+      this.stitching = false
+    }
+  }
+
+  /** Per-event side effects (right column of the §A.9 dispatch table):
+   *  chunk accumulation / partial clear on finalize / openCalls add-remove. */
+  private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
+    switch (event.type) {
+      case 'assistant/chunk': {
+        const { turn, step, chunk } = event.data
+        if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
+          this.partial = new PartialAccumulator(turn, step)
+        }
+        this.partial.push(chunk)
+        return
+      }
+      case 'assistant/message': {
+        if (this.partial !== null && this.partial.turn === event.data.turn && this.partial.step === event.data.step) {
+          this.partial = null // finalize swaps in place (same notification batch, no flicker)
+        }
+        return
+      }
+      case 'tool/call': {
+        this.openCalls.set(String(event.data.callId), {
+          callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
+          turn: event.data.turn, step: event.data.step,
+          callView: view?.for === 'call' ? view.view : null,
+        })
+        this.callsRev++
+        return
+      }
+      case 'tool/result': {
+        if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
+        return
+      }
+      case 'turn/end': {
+        // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
+        // into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
+        // Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
+        // from the logged chunks. Content-free partials are dropped outright.
+        if (this.partial !== null && this.partial.turn === event.data.turn) {
+          const { blocks } = this.partial.toPartial()
+          const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
+          if (visible) {
+            // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
+            this.frozenNodes.push({
+              kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step,
+              blocks, interrupted: true,
+            })
+            this.frozenRev++
+          }
+          this.partial = null
+        }
+        let callOffset = 0
+        for (const [callId, call] of this.openCalls) {
+          if (call.turn !== event.data.turn) continue
+          this.openCalls.delete(callId)
+          this.callsRev++
+          // The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
+          this.frozenNodes.push({
+            kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId,
+            call: { name: call.name, argsRaw: call.argsRaw },
+            content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
+            callView: call.callView, resultView: null,
+          })
+          this.frozenRev++
+        }
+        return
+      }
+      default:
+        return
+    }
+  }
+
+  /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
+   *  paging/stitching consistent, and makes the live freeze and the history replay converge on the
+   *  same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
+  private rebuildDerivedFromWindow(): void {
+    this.partial = null
+    this.openCalls.clear()
+    this.callsRev++
+    this.frozenNodes = []
+    this.frozenRev++
+    for (let i = 0; i < this.events.length; i++) {
+      const event = this.events[i]
+      /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
+      if (event !== undefined) this.applyEventSideEffects(event, this.views[i])
+    }
+  }
+
+  private windowTailSeq(): number | null {
+    const tail = this.events[this.events.length - 1]
+    return tail === undefined ? null : tail.seq
+  }
+
+  private buildSnapshot(): ConversationSnapshot {
+    const { nodes: folded, degraded } = this.foldAdapter.nodes()
+    // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
+    // The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
+    // reference across snapshot swaps (§A.9.4).
+    let nodes: readonly ConversationNode[]
+    if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) {
+      nodes = this.nodesCache.value
+    } else {
+      nodes = this.frozenNodes.length === 0
+        ? folded
+        : [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq)
+      this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes }
+    }
+    if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
+      this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
+    }
+    if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
+      this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
+    }
+    return {
+      sessionId: this.sessionId,
+      nodes,
+      foldDegraded: degraded,
+      partial: this.partial?.toPartial() ?? null,
+      runningCalls: this.callsCache.value,
+      pending: this.pendingCache.value,
+      running: this.running,
+      removed: this.removed,
+      openState: this.openState,
+      openError: this.openError,
+      hasMore: this.hasMore,
+      loadingOlder: this.loadingOlder,
+      promptError: this.promptError,
+      lastAgentError: this.lastAgentError,
+    }
+  }
+}

+ 107 - 0
packages/client/runtime/src/client/slots.ts

@@ -0,0 +1,107 @@
+/**
+ * SlotsService: cordis Service wrapper over the pure SlotCore (ui-slots).
+ * Every mutation re-emits as the 'slots/changed' cordis event; define/register
+ * run through the caller's ctx.effect so a plugin's registrations are
+ * collected when its fiber unloads (cordis-native cascade).
+ */
+/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
+ * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
+ * in this compilation unit (intersection reads `never`) but consumers merge
+ * keys in; the rule fires on the empty-map view, not on real redundancy. */
+import { Service } from 'cordis'
+import type { Context } from 'cordis'
+import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ComposedProps, RegisterArgs, SlotComponent, SlotEntry, SlotEntryDef, SlotMap, SlotSpec } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ClientContext } from './index.ts'
+
+/** cordis Service wrapper over the pure SlotCore; mutations re-emit as 'slots/changed'. */
+export class SlotsService extends Service {
+  private readonly _core = new SlotCore()
+
+  /**
+   * @param ctx - owning root context.
+   */
+  constructor(ctx: Context) {
+    super(ctx, 'slots')
+    this._core.onMutate((key) => { ctx.emit('slots/changed', key) })
+  }
+
+  /**
+   * Record a slot spec (delegates to SlotCore.define; disposal follows the caller's fiber).
+   * @param key - SlotMap key.
+   * @param spec - kind/scope spec.
+   * @returns disposer.
+   */
+  define<K extends keyof SlotMap & string>(key: K, spec: SlotSpec<SlotMap[K]>): () => void {
+    // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
+    return this.ctx.effect(() => this._core.define(key, spec), 'slots.define()')
+  }
+
+  /**
+   * Contribute a component (delegates to SlotCore.register; disposal follows the caller's fiber).
+   * @param key - SlotMap key.
+   * @param component - contributed component.
+   * @param args - kind-shaped options (mandatory for keyed/list kinds); the
+   * inject factory's binding is pinned to ClientContext.
+   * @returns disposer.
+   */
+  register<K extends keyof SlotMap & string, I extends object = Record<string, unknown>>(
+    // Client-context registrations have exactly one ctx shape: pin Ctx to
+    // ClientContext so inject factories dot services without a cast.
+    key: K, component: SlotComponent<ComposedProps<K, NoInfer<I>>>,
+    ...args: RegisterArgs<SlotMap[K], I, ClientContext>): () => void {
+    // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
+    return this.ctx.effect(() => this._core.register<K, I, ClientContext>(key, component, ...args), 'slots.register()')
+  }
+
+  /**
+   * Snapshot entries for a key.
+   * @param key - SlotMap key.
+   * @returns registered entries (stable reference between mutations).
+   */
+  entries<K extends keyof SlotMap & string>(key: K): readonly SlotEntry<SlotMap[K]>[] {
+    return this._core.entries(key)
+  }
+
+  /**
+   * Look up a defined spec.
+   * @param key - SlotMap key.
+   * @returns spec or undefined.
+   */
+  spec<K extends keyof SlotMap & string>(key: K): SlotSpec<SlotMap[K]> | undefined {
+    return this._core.spec(key)
+  }
+
+  /**
+   * Dynamic-key escape hatch for spec lookup (renderer-side string keys).
+   * @param key - candidate slot key.
+   * @returns wide-typed spec or undefined.
+   */
+  specDynamic(key: string): SlotSpec<SlotEntryDef> | undefined {
+    return this._core.specDynamic(key)
+  }
+
+  /**
+   * Subscribe to a key's registration changes (microtask-batched).
+   * @param key - SlotMap key.
+   * @param fn - change callback.
+   * @returns unsubscribe.
+   */
+  subscribe(key: keyof SlotMap & string, fn: () => void): () => void {
+    return this._core.subscribe(key, fn)
+  }
+
+  /**
+   * Version counter for uSES pairing.
+   * @param key - SlotMap key.
+   * @returns current version.
+   */
+  getVersion(key: keyof SlotMap & string): number {
+    return this._core.getVersion(key)
+  }
+
+  /** The wrapped pure core (web-react's scopedSlots outlet reads through this). */
+  get core(): SlotCore {
+    return this._core
+  }
+}

+ 11 - 0
packages/client/runtime/src/index.ts

@@ -0,0 +1,11 @@
+/**
+ * Runtime plugin, node half. The implementation lives entirely in the client
+ * half (src/client/ — SlotsService, SessionsService + object layer, and the
+ * shell-held ClientLoader under ./loader); consumers import the /client or
+ * /loader subpaths. The empty apply exists so the plugin appears in the host
+ * Loader (lifecycle governance + dshClient discovery). Contract:
+ * api-contracts v3 section 4.
+ */
+
+/** Host plugin body — no host-side behavior for the runtime plugin. */
+export function apply(_ctx: unknown): void {}

+ 52 - 0
packages/client/runtime/src/invariant.ts

@@ -0,0 +1,52 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-runtime`.
+ * @module @deepseek-ai/dsh-client-runtime/invariant
+ */
+
+/* jscpd:ignore-start */
+/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
+ * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
+ * in this compilation unit (intersection reads `never`) but consumers merge
+ * keys in; the rule fires on the empty-map view, not on real redundancy. */
+import type { Context } from 'cordis'
+import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-runtime'
+
+/** Cordis companion plugin name. */
+export const name = 'client-runtime-invariant'
+/** Service required before the companion can register. */
+export const inject = ['invariants']
+
+/**
+ * Owned relation: every 'slots/changed'(key) emission must observe the
+ * mutation already applied — SlotCore bumps the key's version synchronously
+ * before the service re-emits, so a zero version at dispatch time means the
+ * event fired without (or ahead of) its mutation.
+ */
+const install: InvariantInstaller = (ctx, fail) => {
+  ctx.on('internal/dispatch', (_mode, eventName, args) => {
+    if (eventName !== 'slots/changed') return
+    const key: unknown = args[0]
+    if (typeof key !== 'string' || key === '') {
+      fail("'slots/changed' dispatched without a slot key argument")
+      return
+    }
+    const slots = ctx.get('slots')
+    // Event payloads carry keys as plain strings; getVersion is statically
+    // keyed, so restore the SlotMap-key type after the runtime string check.
+    if (slots !== undefined && slots.getVersion(key as keyof SlotMap & string) === 0) {
+      fail(`'slots/changed' fired for "${key}" before any mutation bumped its version — emission must follow the applied mutation`)
+    }
+  }, { global: true })
+}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 64 - 0
packages/client/runtime/tests/client-apply.spec.ts

@@ -0,0 +1,64 @@
+/**
+ * Runtime plugin browser-half apply: slots + sessions mounting over the
+ * connection handle, stream-loop sink wiring into the object layer, and the
+ * fiber-scoped loop teardown.
+ */
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
+import * as RuntimeClient from '../src/client/index.ts'
+import { FakeApiClient } from './fake-api.ts'
+
+interface Bench {
+  ctx: Context
+  api: FakeApiClient
+  sinks: ConnectionSinks | undefined
+  stopped: number
+}
+
+async function mount(): Promise<Bench> {
+  const ctx = new Context()
+  const api = new FakeApiClient()
+  const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
+  const handle: ConnectionHandle = {
+    api,
+    start: (sinks) => {
+      bench.sinks = sinks
+      return { stop: () => { bench.stopped += 1 } }
+    },
+  }
+  ctx.reflect.provide('connection', handle)
+  await ctx.plugin(RuntimeClient).await()
+  return bench
+}
+
+describe('runtime client apply', () => {
+  it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
+    const bench = await mount()
+    expect(bench.ctx.get('slots') !== undefined).toBe(true)
+    const sessions = bench.ctx.get('sessions')
+    expect(sessions !== undefined).toBe(true)
+    expect(bench.sinks).toBeDefined()
+
+    // Frame sinks reach the object layer: a host session-added lands in the list store.
+    bench.sinks?.onHostEnvelope?.({
+      rpcId: 'r1' as never,
+      payload: { type: 'host/session-added', sessionId: 's-new' } as never,
+    })
+    await Promise.resolve()
+    expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
+    // Mux sink and onConnected route without throwing (manager semantics own the behavior).
+    bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
+    bench.sinks?.onConnected?.()
+  })
+
+  it('stops the stream loop when the plugin fiber unloads', async () => {
+    const bench = await mount()
+    const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))
+    // Dispose the whole tree: the ctx.effect teardown must call loop.stop exactly once.
+    await bench.ctx.fiber.dispose()
+    expect(bench.stopped).toBe(1)
+    void fiber
+  })
+})

+ 77 - 0
packages/client/runtime/tests/client-loader-bundle.e2e.ts

@@ -0,0 +1,77 @@
+/**
+ * Real-bundle smoke: the actual tsdown client bundle of ui-layout runs
+ * through the loader chain (execute → handoff → factory(require) → apply →
+ * export re-registration). Skips when the bundle is not built (lib/client.js is a
+ * build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`).
+ */
+import { readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { Context } from 'cordis'
+import { afterEach, describe, expect, it } from 'vitest'
+import * as uiSlots from '@deepseek-ai/dsh-client-ui-slots'
+import * as webReact from '@deepseek-ai/dsh-client-web-react'
+import { createClientLoader } from '../src/client/loader/index.ts'
+import type { ClientPluginHandoff } from '../src/client/loader/index.ts'
+import { SessionsService } from '../src/client/sessions/service.ts'
+import { SlotsService } from '../src/client/slots.ts'
+import { FakeApiClient } from './fake-api.ts'
+
+const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
+
+type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; window?: unknown }
+
+afterEach(() => {
+  delete (globalThis as Win).DSHClientProxy
+  delete (globalThis as Win).window
+})
+
+function readLayoutBundle(): string | undefined {
+  try {
+    const require = createRequire(import.meta.url)
+    return readFileSync(require.resolve(`${LAYOUT_ID}/client`), 'utf8')
+  } catch {
+    return undefined
+  }
+}
+
+describe('real tsdown bundle through the loader', () => {
+  const code = readLayoutBundle()
+
+  it.skipIf(code === undefined)('loads ui-layout lib/client.js: handoff, DI require, apply, export surface', async () => {
+    // The bundle banner addresses window.DSHClientProxy; node has no window —
+    // alias it to globalThis so the loader-installed proxy is reachable.
+    ;(globalThis as Win).window = globalThis
+    const ctx = new Context()
+    // The layout apply consumes the slots + sessions services; the real chain
+    // loads the runtime bundle first — stand both up directly here.
+    ctx.plugin(SlotsService)
+    await ctx.fiber.await()
+    new SessionsService(ctx, new FakeApiClient())
+    const loader = createClientLoader({
+      ctx,
+      // The real bundle externals resolved from the seeded table. React is a
+      // type-only import in the layout bundle today, but jsx-runtime is real.
+      modules: {
+        'react': await import('react'),
+        'react/jsx-runtime': await import('react/jsx-runtime'),
+        '@deepseek-ai/dsh-client-ui-slots': uiSlots,
+        '@deepseek-ai/dsh-client-web-react': webReact,
+      },
+      boot: { plugins: [{ id: LAYOUT_ID, url: `/plugins/${LAYOUT_ID}/client.js`, inject: [] }] },
+      fetchBundle: () => Promise.resolve(code as string),
+      // node has no DOM: evaluate the bundle body directly (same synchronous
+      // handoff contract as the <script> path).
+      executeBundle: (bundleCode) => {
+        // Node has no <script>: Function-evaluating the built bundle IS the
+        // system under test (same synchronous handoff as the browser path).
+        // eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
+        new Function(bundleCode)()
+      },
+    })
+    loader.start()
+    await loader.settled()
+    expect(loader.status.getSnapshot()[LAYOUT_ID]).toBe('active')
+    const surface = loader.requireModule(LAYOUT_ID) as Record<string, unknown>
+    expect(typeof surface.apply).toBe('function')
+  })
+})

+ 289 - 0
packages/client/runtime/tests/client-loader.spec.ts

@@ -0,0 +1,289 @@
+/**
+ * ClientLoader: handoff protocol (single slot, id reconciliation), DI require
+ * with export-surface re-registration, immediately-group barrier (parallel
+ * fetch / topology execution / full-group barrier), status store, settled,
+ * failure modes (missing handoff, unknown dep, cycle, unload stub).
+ */
+import { Context } from 'cordis'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createClientLoader } from '../src/client/loader/index.ts'
+import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
+
+type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
+const win = globalThis as Win
+
+afterEach(() => {
+  delete win.DSHClientProxy
+  delete win.__DSH_BOOT__
+})
+
+interface FakeBundle {
+  handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
+}
+
+interface Bench {
+  loader: ReturnType<typeof createClientLoader>
+  fetched: string[]
+  executed: string[]
+  fetchGate: Map<string, () => void>
+}
+
+/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
+function bench(
+  plugins: BootPluginEntry[],
+  bundles: Record<string, FakeBundle>,
+  opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
+): Bench {
+  const ctx = new Context()
+  const fetched: string[] = []
+  const executed: string[] = []
+  const fetchGate = new Map<string, () => void>()
+  const loader = createClientLoader({
+    ctx,
+    modules: opts.modules ?? { react: { marker: 'react' } },
+    boot: { plugins },
+    fetchBundle: (url) => {
+      fetched.push(url)
+      if (opts.gated?.includes(url) === true) {
+        return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
+      }
+      return Promise.resolve(url)
+    },
+    executeBundle: (code) => {
+      executed.push(code)
+      const bundle = bundles[code]
+      if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
+      if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
+      if (typeof bundle.handoff === 'function') {
+        win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
+        return
+      }
+      win.DSHClientProxy?.loadPlugin(bundle.handoff)
+    },
+  })
+  return { loader, fetched, executed, fetchGate }
+}
+
+const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
+  ({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
+
+const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
+  handoff: require => ({
+    apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
+    require,
+    ...exports,
+  }),
+})
+
+describe('load chain', () => {
+  it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
+    const applied: string[] = []
+    const b = bench(
+      [entry('fake-base', [], true), entry('feature', ['fake-base'])],
+      {
+        '/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
+        '/plugins/feature/client.js': {
+          handoff: (require) => {
+            // Later loader requires the earlier one's export surface (inject topology guarantee).
+            const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
+            const base = require(fakeBase) as { helper: string }
+            expect(base.helper).toBe('base-helper')
+            expect((require('react') as { marker: string }).marker).toBe('react')
+            return { apply: () => { applied.push('feature') } }
+          },
+        },
+      },
+    )
+    b.loader.start()
+    await b.loader.settled()
+    expect(applied).toEqual(['fake-base', 'feature'])
+    expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
+    expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
+    expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
+  })
+
+  it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
+    const b = bench(
+      [entry('a', [], true), entry('b', ['a'], true), entry('later')],
+      {
+        '/plugins/a/client.js': okBundle(),
+        '/plugins/b/client.js': okBundle(),
+        '/plugins/later/client.js': okBundle(),
+      },
+      { gated: ['/plugins/a/client.js'] },
+    )
+    b.loader.start()
+    await Promise.resolve()
+    // Both early fetches are in flight before any execution; the late plugin is not fetched yet.
+    expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
+    expect(b.executed).toEqual([])
+    b.fetchGate.get('/plugins/a/client.js')?.()
+    await b.loader.settled()
+    expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
+  })
+
+  it('orders execution by inject topology within each group', async () => {
+    const b = bench(
+      [entry('z-ui', ['a-base']), entry('a-base')],
+      { '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
+    )
+    b.loader.start()
+    await b.loader.settled()
+    expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
+  })
+})
+
+describe('failure modes (fail loud)', () => {
+  it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
+    const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
+    b.loader.start()
+    await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
+    expect(b.loader.status.getSnapshot().silent).toBe('failed')
+  })
+
+  it('rejects on manifest/handoff id mismatch', async () => {
+    const b = bench([entry('expected')], {
+      '/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
+    })
+    b.loader.start()
+    await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
+  })
+
+  it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
+    // Sequential benches: each loader owns the window proxy, so release it between them.
+    const fresh = <T>(build: () => T): T => {
+      delete win.DSHClientProxy
+      return build()
+    }
+
+    const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
+    missing.loader.start()
+    await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
+
+    const cyclic = fresh(() => bench(
+      [entry('p', ['q']), entry('q', ['p'])],
+      { '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
+    ))
+    cyclic.loader.start()
+    await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
+
+    const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
+    applyless.loader.start()
+    await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
+
+    const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
+    await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
+
+    expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
+  })
+
+  it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
+    expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
+    const b = bench([], {})
+    expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
+    // First bench installed the proxy; a second loader must refuse.
+    expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
+  })
+
+  it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
+    const b = bench(
+      [entry('dep', [], true), entry('needy', ['dep'])],
+      { '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
+    )
+    await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
+  })
+
+  it('direct load() naming an unknown inject target fails loud', async () => {
+    const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
+    await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
+  })
+
+  it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
+    // The fire-and-forget prefetch swallow arm must absorb the early
+    // rejection; the awaited load surfaces the same failure via settled().
+    const ctx = new Context()
+    delete win.DSHClientProxy
+    const loader = createClientLoader({
+      ctx,
+      modules: {},
+      boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
+      fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
+      executeBundle: () => {},
+    })
+    loader.start()
+    await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
+  })
+
+  it('unload is the P-I stub', async () => {
+    const b = bench([], {})
+    await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
+  })
+})
+
+describe('DOM default seams (stubbed globals)', () => {
+  it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
+    const origFetch = globalThis.fetch
+    const appended: { textContent?: string | null }[] = []
+    const styleTag = {
+      attrs: {} as Record<string, string>,
+      setAttribute(k: string, v: string) { this.attrs[k] = v },
+    }
+    const fakeDoc = {
+      createElement: () => {
+        const el = { textContent: null as string | null }
+        return el
+      },
+      head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
+      querySelectorAll: () => [styleTag],
+    }
+    const g = globalThis as { document?: unknown; fetch: typeof fetch }
+    g.document = fakeDoc
+    g.fetch = (url: URL | RequestInfo) => Promise.resolve(
+      (typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
+        ? new Response('x', { status: 500 })
+        : new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
+    )
+    try {
+      delete win.DSHClientProxy
+      const ctx = new Context()
+      const loader = createClientLoader({
+        ctx,
+        modules: {},
+        boot: { plugins: [
+          { id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
+          { id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
+        ] },
+        // NO seams injected (keys omitted, not undefined — exactOptional):
+        // the DOM defaults are under test.
+      })
+      const seamHandoff: ClientPluginHandoff = {
+        id: 'seam-ok',
+        factory: () => ({ apply: () => {} }),
+      }
+      // Default executeBundle only APPENDS the script element (no execution in
+      // our fake DOM), so drive the handoff manually before load resolves it.
+      const loadOk = loader.load('seam-ok')
+      await Promise.resolve()
+      ;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
+      await loadOk
+      expect(appended).toHaveLength(1)
+      expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
+      expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
+      await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
+    } finally {
+      g.fetch = origFetch
+      delete (globalThis as { document?: unknown }).document
+    }
+  })
+})
+
+describe('handoff slot protocol', () => {
+  it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
+    delete win.DSHClientProxy
+    createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
+    const proxy = (globalThis as Win).DSHClientProxy
+    proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
+    expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
+      .toThrow(/overlapping loadPlugin handoff/)
+  })
+})

+ 23 - 0
packages/client/runtime/tests/conversation.spec.ts

@@ -0,0 +1,23 @@
+/** Assistant block classifier (moved here with sessions/conversation.ts). */
+
+import { describe, expect, it } from 'vitest'
+import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
+import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
+
+describe('toAssistantBlock', () => {
+  it('classifies the four block shapes', () => {
+    const blocks: ContentBlock[] = [
+      { type: 'text', text: '正文' },
+      { type: 'reasoning', text: '思考' },
+      { type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock,
+      { type: 'image', data: 'x' } as unknown as ContentBlock,
+    ]
+    expect(toAssistantBlocks(blocks)).toEqual([
+      { kind: 'text', text: '正文' },
+      { kind: 'reasoning', text: '思考' },
+      { kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' },
+      { kind: 'other', block: blocks[3] },
+    ])
+    expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
+  })
+})

+ 50 - 0
packages/client/runtime/tests/event-script.ts

@@ -0,0 +1,50 @@
+// Minimal SessionEvent builders for orchestration tests (shape mirrors what the
+// host emits; only the fields the object layer reads).
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+
+/** One text content block (local helper). */
+const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
+
+const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
+  ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
+
+export const ev = {
+  turnStart: (seq: number, turn: number): SessionEvent =>
+    at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
+  user: (seq: number, body: string): SessionEvent =>
+    at(seq, { type: 'user/message', surfaceOp: 'append', data: { content: text(body), source: { kind: 'user' } } }),
+  stepStart: (seq: number, turn: number, step = 0): SessionEvent =>
+    at(seq, { type: 'step/start', data: { turn, step } }),
+  chunkStart: (seq: number, turn: number, step = 0, index = 0): SessionEvent =>
+    at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index, blockType: 'text' } } }),
+  chunkText: (seq: number, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
+    at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
+  assistant: (seq: number, turn: number, body: string, step = 0): SessionEvent =>
+    at(seq, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(body), provenance: { provider: 'fake', model: 'fk-1' } } }),
+  toolCall: (seq: number, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent =>
+    at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
+  toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
+    at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
+  stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
+    at(seq, { type: 'step/end', data: { turn, step } }),
+  turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
+    at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
+}
+
+/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
+export function plainTurn(startSeq: number, turn: number, ask: string, answer: string): SessionEvent[] {
+  return [
+    ev.turnStart(startSeq, turn),
+    ev.user(startSeq + 1, ask),
+    ev.stepStart(startSeq + 2, turn),
+    ev.assistant(startSeq + 3, turn, answer),
+    ev.stepEnd(startSeq + 4, turn),
+    ev.turnEnd(startSeq + 5, turn),
+  ]
+}
+
+/** Wrap raw events as view-less history entries (the wire shape history now returns). */
+export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
+  return events.map(event => ({ event }))
+}

+ 161 - 0
packages/client/runtime/tests/fake-api.ts

@@ -0,0 +1,161 @@
+// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
+// data source on a real clock; behavior tests need per-case responses and
+// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
+import type {
+  HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
+} from '@deepseek-ai/dsh-client-connection/client'
+import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
+
+export interface Deferred<T> {
+  promise: Promise<T>
+  resolve(value: T): void
+  reject(error: unknown): void
+}
+
+/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
+export function deferred<T>(): Deferred<T> {
+  let resolve!: (value: T) => void
+  let reject!: (error: unknown) => void
+  const promise = new Promise<T>((res, rej) => {
+    resolve = res
+    reject = rej
+  })
+  return { promise, resolve, reject }
+}
+
+let nextRpc = 0
+
+export function ok<T>(value: T): RpcResponse<T> {
+  return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
+}
+
+export function err<T>(error: RpcError): RpcResponse<T> {
+  return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } }
+}
+
+type StreamItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } | { kind: 'fail'; error: unknown }
+
+interface StreamConn<F> {
+  feed(item: StreamItem<F>): void
+}
+
+export class FakeApiClient implements IApiClient {
+  /** Chronological call record: [method, payload]. */
+  readonly calls: { method: string; payload: unknown }[] = []
+
+  // Programmable slots (defaults answer OK-empty); reassign per case.
+  onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
+  onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
+  onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
+  => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
+    () => Promise.resolve(ok({ events: [], hasMore: false }))
+
+  onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
+  onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
+  onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
+    () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
+
+  private readonly muxConns: StreamConn<MuxFrame>[] = []
+  private readonly hostConns: StreamConn<HostFrame>[] = []
+
+  // Parameters carry local structural annotations: the CI lint lane runs
+  // without built lib/, so IApiClient's indexed-access types collapse to any
+  // and inferred parameters would trip no-unsafe-argument.
+  readonly sessions: IApiClient['sessions'] = {
+    list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
+    create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
+    history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
+      this.record('session.history', payload, this.onHistory(payload)),
+    prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
+    cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
+  }
+
+  readonly host: IApiClient['host'] = {
+    describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
+  }
+
+  /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
+  suppressStreamOpen = false
+
+  /** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them.
+   *  Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */
+  holdStreamOpen = false
+  private heldOpens: (() => void)[] = []
+
+  releaseStreamOpens(): void {
+    const held = this.heldOpens
+    this.heldOpens = []
+    for (const fire of held) fire()
+  }
+
+  readonly events: IApiClient['events'] = {
+    mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.muxConns, signal, onOpen),
+    host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
+  }
+
+  respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
+    return Promise.resolve({ accepted: false, reason: 'not-pending' })
+  }
+
+  /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */
+  pushMux(frame: MuxFrame, rpcId?: string): void {
+    for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
+  }
+
+  pushHost(frame: HostFrame, rpcId?: string): void {
+    for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } })
+  }
+
+  /** End (clean close) or fail (throw) every open stream — reconnect-path material. */
+  endStreams(): void {
+    for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' })
+  }
+
+  failStreams(error: unknown): void {
+    for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error })
+  }
+
+  get openMuxCount(): number {
+    return this.muxConns.length
+  }
+
+  callsOf(method: string): unknown[] {
+    return this.calls.filter(c => c.method === method).map(c => c.payload)
+  }
+
+  private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
+    this.calls.push({ method, payload })
+    return response
+  }
+
+  private async *openStream<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
+    const inbox: StreamItem<F>[] = []
+    let wake: (() => void) | null = null
+    const conn: StreamConn<F> = {
+      feed: (item) => {
+        inbox.push(item)
+        wake?.()
+      },
+    }
+    registry.push(conn)
+    if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen)
+    else if (!this.suppressStreamOpen) onOpen?.()
+    try {
+      while (!signal.aborted) {
+        while (inbox.length > 0) {
+          const item = inbox.shift() as StreamItem<F>
+          if (item.kind === 'end') return
+          if (item.kind === 'fail') throw item.error
+          yield item.envelope
+        }
+        await new Promise<void>((resolve) => {
+          wake = resolve
+          signal.addEventListener('abort', () => { resolve() }, { once: true })
+        })
+        wake = null
+      }
+    } finally {
+      registry.splice(registry.indexOf(conn), 1)
+    }
+  }
+}

+ 145 - 0
packages/client/runtime/tests/fold-adapter.spec.ts

@@ -0,0 +1,145 @@
+/**
+ * FoldAdapter over the real core SurfaceManager: padding sentinels for paged
+ * windows, incremental append with node-cache identity, six-variant
+ * materialization, call-index backfill, and the degraded linear-scan branch.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import { FoldAdapter } from '../src/client/sessions/fold-adapter.ts'
+import { ev, plainTurn } from './event-script.ts'
+
+const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
+  ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
+
+describe('FoldAdapter', () => {
+  it('folds a baseSeq>0 window through padding sentinels with correct seqs', () => {
+    const adapter = new FoldAdapter()
+    const window = plainTurn(100, 5, '偏移问', '偏移答')
+    adapter.reset(window, 100)
+    const { nodes, degraded } = adapter.nodes()
+    expect(degraded).toBe(false)
+    expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]])
+  })
+
+  it('appends incrementally keeping old node references (cache identity)', () => {
+    const adapter = new FoldAdapter()
+    adapter.reset(plainTurn(0, 0, 'a', 'b'), 0)
+    const first = adapter.nodes()
+    adapter.append(ev.user(6, '追加'))
+    const second = adapter.nodes()
+    expect(second.nodes).toHaveLength(3)
+    expect(second.nodes[0]).toBe(first.nodes[0])
+    expect(second.nodes[1]).toBe(first.nodes[1])
+    expect(second.nodes).not.toBe(first.nodes) // array itself fresh per call
+  })
+
+  it('materializes all six node variants with field mapping', () => {
+    const adapter = new FoldAdapter()
+    const events = [
+      ev.user(0, '用户'),
+      ev.assistant(1, 0, '助手'),
+      at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
+      at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
+      ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
+      ev.toolResult(5, 0, 'c1', '结果'),
+    ]
+    adapter.reset(events, 0)
+    const { nodes } = adapter.nodes()
+    const kinds = nodes.map(n => n.kind)
+    expect(kinds).toContain('user')
+    expect(kinds).toContain('assistant')
+    expect(kinds).toContain('steering')
+    expect(kinds).toContain('context')
+    const result = nodes.find(n => n.kind === 'tool-result')
+    expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false })
+  })
+
+  it('returns call:null for a tool-result whose call fell outside the window', () => {
+    const adapter = new FoldAdapter()
+    adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50)
+    const { nodes } = adapter.nodes()
+    expect(nodes[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null })
+  })
+
+  it('materializes surface-eligible types it does not know as unknown nodes', () => {
+    const adapter = new FoldAdapter()
+    adapter.reset([at(0, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } })], 0)
+    const { nodes } = adapter.nodes()
+    // Either the fold surfaces it (unknown node) or skips it as non-eligible — both are valid
+    // shapes; what matters is no throw and no misclassification into a known kind.
+    for (const node of nodes) expect(node.kind).toBe('unknown')
+  })
+
+  it('degrades to the lenient linear scan when the fold throws, and stays degraded', () => {
+    const adapter = new FoldAdapter()
+    // An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
+    const window = [
+      ev.user(10, '正常'),
+      at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
+    ]
+    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      adapter.reset(window, 10)
+      const first = adapter.nodes()
+      expect(first.degraded).toBe(true)
+      expect(errorSpy).toHaveBeenCalled()
+      expect(first.nodes.map(n => n.seq)).toEqual([10, 11]) // linear scan: append order, bad op ignored
+      adapter.append(ev.user(12, '降级后追加')) // bump rev so the cached result is not reused
+      const second = adapter.nodes()
+      expect(second.degraded).toBe(true) // sticky: no re-throw loop, straight to the linear scan
+      expect(second.nodes[0]).toBe(first.nodes[0]) // cache still serves node identity
+      expect(second.nodes.map(n => n.seq)).toEqual([10, 11, 12])
+    } finally {
+      errorSpy.mockRestore()
+    }
+  })
+
+  it('materializes a tool-result error field when present', () => {
+    const adapter = new FoldAdapter()
+    adapter.reset([
+      at(0, { type: 'tool/result', surfaceOp: 'append', data: { turn: 0, step: 0, callId: 'c1', content: [], isError: true, error: { name: 'Boom', code: 'boom' } } }),
+    ], 0)
+    expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
+  })
+
+  it('exposes the in-window call index for runningCalls material', () => {
+    const adapter = new FoldAdapter()
+    adapter.reset([ev.toolCall(0, 1, 'c9', 'slow', '{}')], 0)
+    expect(adapter.callIndex.get('c9')).toMatchObject({ name: 'slow', turn: 1 })
+    adapter.append(ev.toolCall(1, 1, 'c10', 'fast', '{}'))
+    expect(adapter.callIndex.size).toBe(2)
+  })
+
+  it('attaches wire views: callView into the call index, resultView onto the node by seq', () => {
+    const adapter = new FoldAdapter()
+    const events = [
+      ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'),
+      ev.toolResult(1, 1, 'c1', 'listing'),
+    ]
+    const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } }
+    const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } }
+    adapter.reset(events, 0, [callView, resultView] as never)
+    expect(adapter.callIndex.get('c1')).toMatchObject({ callView: { card: 'terminal' } })
+    const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
+    expect(node).toMatchObject({ callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' } })
+  })
+
+  it('attaches views on the live append path and defaults to null without views', () => {
+    const adapter = new FoldAdapter()
+    adapter.reset(plainTurn(0, 0, 'a', 'b'), 0) // no views argument: legacy-shaped call
+    adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never)
+    adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result
+    expect(adapter.callIndex.get('c2')).toMatchObject({ callView: { title: '回声' } })
+    const node = adapter.nodes().nodes.find(n => n.kind === 'tool-result')
+    expect(node).toMatchObject({ callView: { title: '回声' }, resultView: null })
+  })
+
+  it('leaves callView null when the paired call fell outside the window (cross-page break)', () => {
+    const adapter = new FoldAdapter()
+    const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } }
+    adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], 50, [resultView] as never)
+    const node = adapter.nodes().nodes[0]
+    expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
+  })
+})

+ 47 - 0
packages/client/runtime/tests/invariant.spec.ts

@@ -0,0 +1,47 @@
+/**
+ * Runtime invariant companion: the 'slots/changed' emission-order audit —
+ * a fired key must already carry a bumped version (emission follows the
+ * applied mutation), bogus payloads fail loud, foreign events pass.
+ */
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as RuntimeInvariant from '../src/invariant.ts'
+import { SlotsService } from '../src/client/slots.ts'
+
+async function setup(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(InvariantService, { enabled: true })
+  await ctx.plugin(RuntimeInvariant).await()
+  return ctx
+}
+
+const emit = (ctx: Context, event: string, ...args: unknown[]): void => {
+  ;(ctx.emit as (event: string, ...args: unknown[]) => void)(event, ...args)
+}
+
+describe('runtime slots/changed invariant', () => {
+  it('passes foreign events and a legitimate mutation-then-emission sequence', async () => {
+    const ctx = await setup()
+    expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()
+    await ctx.plugin(SlotsService).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
+    // A real define bumps the version first and re-emits through onMutate —
+    // the audit sees version > 0 and stays quiet.
+    expect(() => ctx.slots.define('t-single', { kind: 'single', scope: 'root' })).not.toThrow()
+  })
+
+  it('fails loud on a missing key and on an emission with no applied mutation', async () => {
+    const ctx = await setup()
+    expect(() => { emit(ctx, 'slots/changed', '') }).toThrow(/without a slot key/)
+    expect(() => { emit(ctx, 'slots/changed', 42) }).toThrow(/without a slot key/)
+    await ctx.plugin(SlotsService).await()
+    // Hand-emitted key that never saw a mutation: version 0 → violation.
+    expect(() => { emit(ctx, 'slots/changed', 'never-mutated') })
+      .toThrow(/before any mutation bumped its version/)
+  })
+
+  it('stays quiet when no slots service is mounted (nothing to audit against)', async () => {
+    const ctx = await setup()
+    expect(() => { emit(ctx, 'slots/changed', 'any-key') }).not.toThrow()
+  })
+})

+ 55 - 0
packages/client/runtime/tests/lineage.spec.ts

@@ -0,0 +1,55 @@
+/**
+ * flattenLineage: root ordering, DFS child expansion, orphan degradation, and
+ * cycle fail-soft (every entry always emitted, no infinite walk).
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
+import { flattenLineage } from '../src/client/sessions/lineage.ts'
+
+const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
+  sessionId: id as SessionId, updatedAt, running: false,
+  ...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}),
+})
+
+describe('flattenLineage', () => {
+  it('sorts roots by updatedAt desc and expands children DFS with depth, children sorted too', () => {
+    const out = flattenLineage([
+      s('old-root', 10),
+      s('new-root', 30),
+      s('kid-old', 11, 'new-root'),
+      s('kid-new', 12, 'new-root'),
+      s('grandkid', 5, 'kid-new'),
+    ])
+    expect(out.map(e => [e.sessionId, e.depth])).toEqual([
+      ['new-root', 0], ['kid-new', 1], ['grandkid', 2], ['kid-old', 1], ['old-root', 0],
+    ])
+  })
+
+  it('degrades an orphan (absent parent) to root level without dropping it', () => {
+    const out = flattenLineage([s('orphan', 20, 'ghost-parent'), s('root', 10)])
+    expect(out.map(e => [e.sessionId, e.depth])).toEqual([['orphan', 0], ['root', 0]])
+  })
+
+  it('fails soft on a two-node cycle: all entries emitted, warn fired, no hang', () => {
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    try {
+      const out = flattenLineage([s('a', 20, 'b'), s('b', 10, 'a'), s('root', 30)])
+      expect(out.map(e => e.sessionId).sort()).toEqual(['a', 'b', 'root'])
+      expect(warnSpy).toHaveBeenCalled()
+    } finally {
+      warnSpy.mockRestore()
+    }
+  })
+
+  it('handles a self-referencing entry as a cycle member', () => {
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    try {
+      const out = flattenLineage([s('self', 10, 'self')])
+      expect(out.map(e => e.sessionId)).toEqual(['self'])
+      expect(out[0]?.depth).toBe(0)
+    } finally {
+      warnSpy.mockRestore()
+    }
+  })
+})

+ 223 - 0
packages/client/runtime/tests/manager.spec.ts

@@ -0,0 +1,223 @@
+/**
+ * SessionManager orchestration: lazy resident instances, list lifecycle, host
+ * frame routing, and the pending-frame buffer for uninstantiated sessions.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import { SessionManager } from '../src/client/sessions/manager.ts'
+import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
+import { entries, plainTurn } from './event-script.ts'
+
+const S1 = 'fk-m1' as SessionId
+const S2 = 'fk-m2' as SessionId
+
+function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; parentSessionId: SessionId }> = {}) {
+  return { sessionId, updatedAt: 100, running: false, ...over }
+}
+
+describe('instances', () => {
+  it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
+    const api = new FakeApiClient()
+    api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
+    const manager = new SessionManager(api)
+    await manager.refreshList()
+    const session = manager.get(S1)
+    expect(manager.get(S1)).toBe(session) // resident: same instance forever
+    expect(session.getSnapshot().running).toBe(true) // list preceded instantiation
+  })
+
+  it('replays buffered approval frames on instantiation and drops ordinary frames for uninstantiated sessions', () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    // Uninstantiated: approval buffers, plain session/event drops.
+    manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
+    manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
+    const session = manager.get(S1)
+    expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', approvalId: 'ap1' }])
+    // Buffer cleared: a second instantiation of another id gets nothing.
+    expect(manager.get(S2).getSnapshot().pending).toEqual([])
+  })
+
+  it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    // 40 distinct question frames for an uninstantiated session: only the newest 32 survive.
+    for (let i = 0; i < 40; i++) {
+      manager.handleMuxEnvelope({ rpcId: `q${i}` as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
+    }
+    const pending = manager.get(S1).getSnapshot().pending
+    expect(pending).toHaveLength(32)
+    expect(pending.map(p => p.rpcId)).toEqual(Array.from({ length: 32 }, (_, i) => `q${i + 8}`)) // oldest 8 dropped
+    // Removed session: buffered frames must not replay on a future instantiation.
+    manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
+    manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
+    expect(manager.get(S2).getSnapshot().pending).toEqual([])
+  })
+})
+
+describe('list lifecycle', () => {
+  it('single-flights refreshList and lands items sorted through lineage flattening', async () => {
+    const api = new FakeApiClient()
+    const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
+    api.onList = () => gate.promise
+    const manager = new SessionManager(api)
+    const first = manager.refreshList()
+    const second = manager.refreshList()
+    expect(manager.getListSnapshot().state).toBe('loading')
+    gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
+    await Promise.all([first, second])
+    expect(api.callsOf('session.list')).toHaveLength(1)
+    const snapshot = manager.getListSnapshot()
+    expect(snapshot.state).toBe('idle')
+    expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) // updatedAt desc
+  })
+
+  it('keeps the error in the list snapshot on failure', async () => {
+    const api = new FakeApiClient()
+    api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
+    const manager = new SessionManager(api)
+    await manager.refreshList()
+    expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
+  })
+
+  it('merges create into the list immediately without waiting for a refresh', async () => {
+    const api = new FakeApiClient()
+    api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
+    const manager = new SessionManager(api)
+    const result = await manager.create()
+    expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } })
+    expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
+  })
+})
+
+describe('host frame routing', () => {
+  it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
+    manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S1 } }) // dup: ignored
+    expect(manager.getListSnapshot().items).toHaveLength(1)
+
+    const session = manager.get(S1)
+    manager.handleHostEnvelope({ rpcId: 'h3' as never, payload: { type: 'host/session-status', sessionId: S1, running: true } })
+    expect(session.getSnapshot().running).toBe(true)
+    expect(manager.getListSnapshot().items[0]?.running).toBe(true)
+
+    manager.handleHostEnvelope({ rpcId: 'h4' as never, payload: { type: 'host/agent-error', sessionId: S1, message: '炸了' } })
+    expect(session.getSnapshot().lastAgentError).toBe('炸了')
+
+    manager.handleHostEnvelope({ rpcId: 'h5' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
+    expect(manager.getListSnapshot().items).toHaveLength(0)
+    expect(session.getSnapshot().removed).toBe(true)
+    expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal
+  })
+})
+
+describe('remaining branches', () => {
+  it('refreshList folds a transport throw into the error state', async () => {
+    const api = new FakeApiClient()
+    api.onList = () => Promise.reject(new Error('list wire down'))
+    const manager = new SessionManager(api)
+    await manager.refreshList()
+    expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
+  })
+
+  it('refreshList pushes running bits down to already-instantiated sessions', async () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    const session = manager.get(S1)
+    api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
+    await manager.refreshList()
+    expect(session.getSnapshot().running).toBe(true)
+  })
+
+  it('create passes cwd through, folds transport throws, and skips the merge when already listed', async () => {
+    const api = new FakeApiClient()
+    api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
+    const manager = new SessionManager(api)
+    await manager.create('/tmp/w')
+    expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w' }])
+    expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
+    await manager.create('/tmp/w') // same id returned: no duplicate row
+    expect(manager.getListSnapshot().items).toHaveLength(1)
+    api.onCreate = () => Promise.reject(new Error('create wire down'))
+    expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
+    // Business error passes through untouched.
+    api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} }))
+    expect(await manager.create()).toMatchObject({ ok: false })
+  })
+
+  it('subscribe notifies on list changes and stops after unsubscribe', async () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    let notified = 0
+    const unsubscribe = manager.subscribe(() => { notified++ })
+    await manager.refreshList()
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(notified).toBeGreaterThan(0)
+    const seen = notified
+    unsubscribe()
+    manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', sessionId: S1 } })
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(notified).toBe(seen)
+  })
+
+  it('routes stream/error and unknown frames to the documented drops, and dispatches to instantiated sessions', () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    manager.handleMuxEnvelope({ rpcId: 'e' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
+    manager.handleHostEnvelope({ rpcId: 'e2' as never, payload: { type: 'stream/error', error: { code: 'internal', message: 'x', details: {} } } })
+    manager.handleHostEnvelope({ rpcId: 'e3' as never, payload: { type: 'future/host-frame' } as never })
+    const session = manager.get(S1)
+    manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
+    expect(session.getSnapshot().pending).toMatchObject([{ kind: 'question' }])
+    // status flip for an unknown session only touches summaries (no crash).
+    manager.handleHostEnvelope({ rpcId: 'h9' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
+    manager.handleHostEnvelope({ rpcId: 'ha' as never, payload: { type: 'host/agent-error', sessionId: S2, message: '无实例' } })
+  })
+
+  it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
+    const api = new FakeApiClient()
+    api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
+    const manager = new SessionManager(api)
+    await manager.refreshList()
+    const before = manager.getListSnapshot()
+    manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-status', sessionId: S2, running: true } })
+    const after = manager.getListSnapshot()
+    expect(after.items).not.toBe(before.items)
+    const beforeS1 = before.items.find(e => e.sessionId === S1)
+    const afterS1 = after.items.find(e => e.sessionId === S1)
+    expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache)
+    // Same-order same-entries snapshot reuses the items array.
+    manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/agent-error', sessionId: S1, message: 'x' } })
+    expect(manager.getListSnapshot().items).toBe(after.items)
+  })
+
+  it('carries parentSessionId from host/session-added into the lineage row', () => {
+    const api = new FakeApiClient()
+    const manager = new SessionManager(api)
+    manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
+    manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: S1 } })
+    const items = manager.getListSnapshot().items
+    expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
+  })
+})
+
+describe('connected generation', () => {
+  it('refreshes the list and resyncs only opened instances', async () => {
+    const api = new FakeApiClient()
+    api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
+    const manager = new SessionManager(api)
+    const openedSession = manager.get(S1)
+    await openedSession.open()
+    manager.get(S2) // instantiated but never opened
+    const historyCallsBefore = api.callsOf('session.history').length
+    manager.handleConnected()
+    await vi.waitFor(() => {
+      expect(api.callsOf('session.list').length).toBe(1)
+      // Only the opened instance repulls history; the cold one stays silent.
+      expect(api.callsOf('session.history').length).toBe(historyCallsBefore + 1)
+    })
+  })
+})

+ 10 - 0
packages/client/runtime/tests/node-half.spec.ts

@@ -0,0 +1,10 @@
+/** Node half: the empty host apply (Loader governance + dshClient discovery placeholder). */
+import { describe, expect, it } from 'vitest'
+import { apply } from '../src/index.ts'
+
+describe('node half', () => {
+  it('apply is a no-op host placeholder', () => {
+    apply(undefined)
+    expect(true).toBe(true) // reaching here without throw is the contract
+  })
+})

+ 75 - 0
packages/client/runtime/tests/notifier.spec.ts

@@ -0,0 +1,75 @@
+/**
+ * Notifier: microtask batching, rebuild-before-notify ordering, no-listener
+ * laziness, synchronous notifyNow, and unsubscribe.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { Notifier } from '../src/client/sessions/notifier.ts'
+
+const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
+
+describe('Notifier', () => {
+  it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
+    const order: string[] = []
+    const notifier = new Notifier(() => order.push('rebuild'))
+    notifier.subscribe(() => order.push('notify'))
+    notifier.markDirty()
+    notifier.markDirty()
+    notifier.markDirty()
+    expect(order).toEqual([]) // nothing until the microtask boundary
+    await microtask()
+    expect(order).toEqual(['rebuild', 'notify'])
+  })
+
+  it('skips rebuild with zero listeners and ensureFresh rebuilds lazily exactly once', async () => {
+    let rebuilds = 0
+    const notifier = new Notifier(() => { rebuilds++ })
+    notifier.markDirty()
+    await microtask()
+    expect(rebuilds).toBe(0) // lazy: kept dirty
+    notifier.ensureFresh()
+    expect(rebuilds).toBe(1)
+    notifier.ensureFresh()
+    expect(rebuilds).toBe(1) // clean: no second rebuild
+  })
+
+  it('notifyNow runs listeners synchronously (controlled-input contract)', () => {
+    const order: string[] = []
+    const notifier = new Notifier(() => order.push('rebuild'))
+    notifier.subscribe(() => order.push('notify'))
+    notifier.notifyNow()
+    expect(order).toEqual(['rebuild', 'notify']) // before returning, no microtask needed
+  })
+
+  it('notifyNow with zero listeners stays lazy like markDirty', () => {
+    let rebuilds = 0
+    const notifier = new Notifier(() => { rebuilds++ })
+    notifier.notifyNow()
+    expect(rebuilds).toBe(0)
+    notifier.ensureFresh()
+    expect(rebuilds).toBe(1)
+  })
+
+  it('a scheduled flush after notifyNow already flushed is a no-op', async () => {
+    let rebuilds = 0
+    const notifier = new Notifier(() => { rebuilds++ })
+    notifier.subscribe(() => undefined)
+    notifier.markDirty() // schedules the microtask flush
+    notifier.notifyNow() // flushes synchronously, clears dirty
+    await microtask() // the scheduled flush finds dirty=false
+    expect(rebuilds).toBe(1)
+  })
+
+  it('unsubscribed listeners stop receiving notifications', async () => {
+    let calls = 0
+    const notifier = new Notifier(() => undefined)
+    const unsubscribe = notifier.subscribe(() => { calls++ })
+    notifier.notifyNow()
+    expect(calls).toBe(1)
+    unsubscribe()
+    notifier.markDirty()
+    await microtask()
+    notifier.notifyNow()
+    expect(calls).toBe(1)
+  })
+})

+ 91 - 0
packages/client/runtime/tests/partial.spec.ts

@@ -0,0 +1,91 @@
+/**
+ * PartialAccumulator: six-variant chunk folding, sparse-index compaction, and
+ * the block/snapshot reference discipline (a delta swaps only that block).
+ */
+
+import { describe, expect, it } from 'vitest'
+import type { StreamChunk } from '@deepseek-ai/dsh-client-connection/client'
+import { PartialAccumulator } from '../src/client/sessions/partial.ts'
+
+const chunk = (c: Record<string, unknown>): StreamChunk => c as unknown as StreamChunk
+
+describe('PartialAccumulator', () => {
+  it('builds empty blocks per block-start type, unknown type falls to other', () => {
+    const acc = new PartialAccumulator(1, 0)
+    acc.push(chunk({ type: 'block-start', index: 0, blockType: 'text' }))
+    acc.push(chunk({ type: 'block-start', index: 1, blockType: 'reasoning' }))
+    acc.push(chunk({ type: 'block-start', index: 2, blockType: 'tool-call' }))
+    acc.push(chunk({ type: 'block-start', index: 3, blockType: 'no-such' }))
+    expect(acc.toPartial().blocks).toEqual([
+      { kind: 'text', text: '' },
+      { kind: 'reasoning', text: '' },
+      { kind: 'tool-call', callId: '', name: '', argsRaw: '' },
+      { kind: 'other', block: null },
+    ])
+  })
+
+  it('accumulates text deltas, starting from empty when prev is missing or another kind', () => {
+    const acc = new PartialAccumulator(1, 0)
+    acc.push(chunk({ type: 'text-delta', index: 0, text: '无 start ' })) // prev missing
+    acc.push(chunk({ type: 'text-delta', index: 0, text: '也累积' }))
+    expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '无 start 也累积' }])
+    acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '换型重起' })) // prev is text → restart
+    expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '换型重起' }])
+  })
+
+  it('accumulates reasoning deltas on the reasoning lane', () => {
+    const acc = new PartialAccumulator(1, 0)
+    acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
+    acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '思' }))
+    acc.push(chunk({ type: 'reasoning-delta', index: 0, text: '考' }))
+    expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
+  })
+
+  it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
+    const acc = new PartialAccumulator(1, 0)
+    acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))
+    acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c2-late', name: 'echo', argumentsDelta: ':1}' }))
+    expect(acc.toPartial().blocks).toEqual([
+      { kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{"a":1}' },
+    ])
+  })
+
+  it('replaces the accumulated block wholesale on block-end', () => {
+    const acc = new PartialAccumulator(1, 0)
+    acc.push(chunk({ type: 'text-delta', index: 0, text: '中间态' }))
+    acc.push(chunk({ type: 'block-end', index: 0, block: { type: 'text', text: '定稿全文' } }))
+    expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '定稿全文' }])
+  })
+
+  it('returns false (no notification) for usage/finish/unknown variants and keeps blocks', () => {
+    const acc = new PartialAccumulator(1, 0)
+    acc.push(chunk({ type: 'text-delta', index: 0, text: 'x' }))
+    const before = acc.toPartial()
+    expect(acc.push(chunk({ type: 'usage', usage: {} }))).toBe(false)
+    expect(acc.push(chunk({ type: 'finish', reason: 'stop' }))).toBe(false)
+    expect(acc.push(chunk({ type: 'future-variant' }))).toBe(false)
+    expect(acc.toPartial()).toBe(before) // unchanged: same snapshot reference
+  })
+
+  it('compacts sparse indexes into a dense render-order array', () => {
+    const acc = new PartialAccumulator(1, 0)
+    acc.push(chunk({ type: 'block-start', index: 2, blockType: 'text' }))
+    acc.push(chunk({ type: 'text-delta', index: 2, text: '先到的高位' }))
+    acc.push(chunk({ type: 'block-start', index: 0, blockType: 'reasoning' }))
+    const { blocks } = acc.toPartial()
+    expect(blocks).toHaveLength(2) // no undefined holes
+    expect(blocks[0]).toEqual({ kind: 'reasoning', text: '' })
+    expect(blocks[1]).toEqual({ kind: 'text', text: '先到的高位' })
+  })
+
+  it('keeps the snapshot reference stable without changes and swaps it once per mutation', () => {
+    const acc = new PartialAccumulator(3, 1)
+    const first = acc.toPartial()
+    expect(first).toMatchObject({ turn: 3, step: 1, blocks: [] })
+    expect(acc.toPartial()).toBe(first)
+    acc.push(chunk({ type: 'text-delta', index: 0, text: 'a' }))
+    const second = acc.toPartial()
+    expect(second).not.toBe(first)
+    expect(acc.toPartial()).toBe(second)
+  })
+})

+ 625 - 0
packages/client/runtime/tests/session.spec.ts

@@ -0,0 +1,625 @@
+/**
+ * Session orchestration: drive the object through contract calls and injected
+ * frames (open → prompt → stream → finalize → cancel → resync) and assert the
+ * ConversationSnapshot it settles into. Reference stability is asserted with
+ * toBe/not.toBe — it is the React.memo/uSES contract, equal-value output is not
+ * enough.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import { Session } from '../src/client/sessions/session.ts'
+import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
+import { entries, ev, plainTurn } from './event-script.ts'
+
+const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
+  ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
+
+const SID = 'fk-s1' as SessionId
+
+function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
+  return { api, session: new Session(SID, api) }
+}
+
+function histResponse(events: SessionEvent[], hasMore = false) {
+  // history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
+  return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
+}
+
+describe('open', () => {
+  it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
+    const { api, session } = makeSession()
+    const page = plainTurn(10, 3, '问', '答')
+    api.onHistory = () => histResponse(page, true)
+    expect(session.getSnapshot().openState).toBe('cold')
+    const opening = session.open()
+    expect(session.getSnapshot().openState).toBe('loading')
+    await opening
+    const snapshot = session.getSnapshot()
+    expect(snapshot.openState).toBe('open')
+    expect(snapshot.hasMore).toBe(true)
+    expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
+  })
+
+  it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
+    const { api, session } = makeSession()
+    await Promise.all([session.open(), session.open()])
+    await session.open()
+    expect(api.callsOf('session.history')).toHaveLength(1)
+  })
+
+  it('lands an error result in openState=error with the RpcError kept', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } }))
+    await session.open()
+    const snapshot = session.getSnapshot()
+    expect(snapshot.openState).toBe('error')
+    expect(snapshot.openError?.code).toBe('session-not-found')
+  })
+
+  it('folds a transport throw into openState=error / internal', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => Promise.reject(new Error('socket died'))
+    await session.open()
+    expect(session.getSnapshot().openState).toBe('error')
+    expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' })
+  })
+
+  it('stitches live frames arriving while history is pending, dropping the page overlap', async () => {
+    const { api, session } = makeSession()
+    const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = () => gate.promise
+    const opening = session.open()
+    // Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
+    const page = plainTurn(10, 0, '早', '安')
+    session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
+    session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
+    gate.resolve(ok({ events: entries(page) as never[], hasMore: false }))
+    await opening
+    const seqs = session.getSnapshot().nodes.map(n => n.seq)
+    // Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once.
+    expect(seqs).toEqual([11, 13, 16])
+  })
+})
+
+describe('live event path', () => {
+  async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(events)
+    await session.open()
+    return { api, session }
+  }
+
+  it('drops replayed frames at or below the window tail', async () => {
+    const { session } = await opened()
+    const before = session.getSnapshot()
+    session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(3, '重放') })
+    await Promise.resolve()
+    expect(session.getSnapshot().nodes).toEqual(before.nodes)
+  })
+
+  it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
+    const { session } = await opened()
+    const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+    feed(ev.turnStart(6, 1))
+    feed(ev.user(7, '流式问'))
+    feed(ev.chunkStart(8, 1))
+    feed(ev.chunkText(9, 1, '半截'))
+    let snapshot = session.getSnapshot()
+    expect(snapshot.partial).toMatchObject({ turn: 1, blocks: [{ kind: 'text', text: '半截' }] })
+    feed(ev.chunkText(10, 1, '回复'))
+    expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '半截回复' }])
+    feed(ev.assistant(11, 1, '半截回复'))
+    feed(ev.turnEnd(12, 1))
+    snapshot = session.getSnapshot()
+    expect(snapshot.partial).toBeNull()
+    const last = snapshot.nodes.at(-1)
+    expect(last).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '半截回复' }] })
+    expect((last as { interrupted?: true }).interrupted).toBeUndefined()
+  })
+
+  it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
+    const { session } = await opened()
+    const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+    feed(ev.turnStart(6, 1))
+    feed(ev.user(7, '要被打断的'))
+    feed(ev.chunkStart(8, 1))
+    feed(ev.chunkText(9, 1, '说到一半'))
+    feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives
+    const snapshot = session.getSnapshot()
+    expect(snapshot.partial).toBeNull()
+    const frozen = snapshot.nodes.at(-1)
+    expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] })
+    // Ordered inside the flow: after the user message (seq 7), before any later turn.
+    expect((frozen as { seq: number }).seq).toBeGreaterThan(7)
+  })
+
+  it('tracks tool calls in runningCalls and converts orphans to interrupted tool-result cards on turn/end', async () => {
+    const { session } = await opened()
+    const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+    feed(ev.turnStart(6, 1))
+    feed(ev.toolCall(7, 1, 'c1', 'echo', '{"a":1}'))
+    expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'c1', name: 'echo' }])
+    feed(ev.toolResult(8, 1, 'c1', 'ECHO'))
+    expect(session.getSnapshot().runningCalls).toEqual([])
+    // Second call never resolves: turn/end freezes it as an error card.
+    feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}'))
+    feed(ev.turnEnd(10, 1, 'cancelled'))
+    const snapshot = session.getSnapshot()
+    expect(snapshot.runningCalls).toEqual([])
+    expect(snapshot.nodes.at(-1)).toMatchObject({
+      kind: 'tool-result', callId: 'c2', isError: true, error: { code: 'interrupted' },
+    })
+  })
+
+  it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
+    const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
+    const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
+    api.onHistory = () => histResponse(repaired)
+    // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires.
+    session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(9, 1, 'd') })
+    await vi.waitFor(() => {
+      expect(api.callsOf('session.history').length).toBe(2)
+    })
+    await Promise.resolve()
+    const seqs = session.getSnapshot().nodes.map(n => n.seq)
+    expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
+  })
+})
+
+describe('paging', () => {
+  it('prepends an older page and keeps seq continuity', async () => {
+    const older = plainTurn(0, 0, '旧问', '旧答')
+    const newer = plainTurn(6, 1, '新问', '新答')
+    const { api, session } = makeSession()
+    api.onHistory = payload => payload.beforeSeq === undefined
+      ? histResponse(newer, true)
+      : histResponse(older, false)
+    await session.open()
+    await session.loadOlder()
+    const snapshot = session.getSnapshot()
+    expect(api.callsOf('session.history')).toMatchObject([{}, { beforeSeq: 6 }].map(p => ({ sessionId: SID, ...p })))
+    expect(snapshot.hasMore).toBe(false)
+    expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
+  })
+
+  it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = payload => payload.beforeSeq === undefined
+      ? histResponse(plainTurn(10, 1, '新', '页'), true)
+      : histResponse(plainTurn(0, 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole
+    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      await session.open()
+      const nodesBefore = session.getSnapshot().nodes
+      await session.loadOlder()
+      const snapshot = session.getSnapshot()
+      expect(snapshot.nodes).toEqual(nodesBefore)
+      expect(snapshot.hasMore).toBe(false)
+    } finally {
+      errorSpy.mockRestore()
+    }
+  })
+
+  it('ignores loadOlder while one is in flight (single request)', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
+    await session.open()
+    const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = () => gate.promise
+    const first = session.loadOlder()
+    const second = session.loadOlder()
+    gate.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
+    await Promise.all([first, second])
+    expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
+  })
+})
+
+describe('prompt and cancel errors', () => {
+  it('sends content through session.prompt with the mode passed through', async () => {
+    const { api, session } = makeSession()
+    const result = await session.prompt([{ type: 'text', text: '要发的' }], 'queue')
+    expect(result.ok).toBe(true)
+    expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
+  })
+
+  it('business failure lands in promptError with op=send', async () => {
+    const { api, session } = makeSession()
+    api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } }))
+    const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
+    expect(result.ok).toBe(false)
+    expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
+  })
+
+  it('lands cancel failures in promptError with op=stop', async () => {
+    const { api, session } = makeSession()
+    api.onCancel = () => Promise.reject(new Error('cancel transport down'))
+    const result = await session.cancel()
+    expect(result.ok).toBe(false)
+    expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
+  })
+})
+
+describe('pending interactions', () => {
+  it('adds approval/question on requested and removes them on resolved', async () => {
+    const { session } = makeSession()
+    session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
+    session.handleMuxEnvelope('rq' as never, { type: 'question/requested', sessionId: SID, questions: [] })
+    expect(session.getSnapshot().pending.map(p => p.kind).sort()).toEqual(['approval', 'question'])
+    session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap1' as never, outcome: 'approved' as never })
+    session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
+    expect(session.getSnapshot().pending).toEqual([])
+  })
+})
+
+describe('remaining branches', () => {
+  it('prompt transport throw folds to internal promptError', async () => {
+    const { api, session } = makeSession()
+    api.onPrompt = () => Promise.reject(new Error('prompt wire down'))
+    const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue')
+    expect(result.ok).toBe(false)
+    expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } })
+  })
+
+  it('cancel business error also lands op=stop promptError', async () => {
+    const { api, session } = makeSession()
+    api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } }))
+    await session.cancel()
+    expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } })
+  })
+
+  it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => {
+    const { api, session } = makeSession()
+    await session.loadOlder() // cold: no-op, zero calls
+    expect(api.calls).toEqual([])
+    api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
+    await session.open()
+    // err result: window unchanged
+    api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
+    await session.loadOlder()
+    expect(session.getSnapshot().nodes).toHaveLength(2)
+    expect(session.getSnapshot().hasMore).toBe(true)
+    // empty page: hasMore adopts the response
+    api.onHistory = () => histResponse([], false)
+    await session.loadOlder()
+    expect(session.getSnapshot().hasMore).toBe(false)
+    // hasMore false now: further loadOlder is a guard no-op
+    const calls = api.calls.length
+    await session.loadOlder()
+    expect(api.calls.length).toBe(calls)
+    // throw path: fail-soft with console.error
+    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      await session.resync()
+      api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
+      await session.resync()
+      api.onHistory = () => Promise.reject(new Error('page wire down'))
+      await session.loadOlder()
+      expect(errorSpy).toHaveBeenCalled()
+      expect(session.getSnapshot().loadingOlder).toBe(false)
+    } finally {
+      errorSpy.mockRestore()
+    }
+  })
+
+  it('subscribe delivers snapshot-change notifications and unsubscribes', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    let notified = 0
+    const unsubscribe = session.subscribe(() => { notified++ })
+    await session.open()
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(notified).toBeGreaterThan(0)
+    const seen = notified
+    unsubscribe()
+    session.handleRunning(true) // any snapshot mutation; the listener must stay silent
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(notified).toBe(seen)
+  })
+
+  it('subscribed baseline past the window tail triggers the second stitch pull in doOpen', async () => {
+    const { api, session } = makeSession()
+    const full = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
+    let call = 0
+    api.onHistory = () => {
+      call++
+      return histResponse(call === 1 ? plainTurn(0, 0, 'a', 'b') : full)
+    }
+    // Baseline arrives before open: lastSeq 11 > first page tail 5 → doOpen repulls once.
+    session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
+    await session.open()
+    expect(call).toBe(2)
+    expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([1, 3, 7, 9])
+  })
+
+  it('a failed second stitch pull keeps the first window and still opens', async () => {
+    const { api, session } = makeSession()
+    let call = 0
+    api.onHistory = () => {
+      call++
+      return call === 1
+        ? histResponse(plainTurn(0, 0, 'a', 'b'))
+        : Promise.resolve(err({ code: 'internal', message: 'stitch pull down', details: {} }))
+    }
+    session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
+    await session.open()
+    expect(call).toBe(2)
+    const snapshot = session.getSnapshot()
+    expect(snapshot.openState).toBe('open') // stitch-pull failure is not an open failure
+    expect(snapshot.nodes.map(n => n.seq)).toEqual([1, 3]) // first window kept
+  })
+
+  it('approval frame with callId/reason keeps the optional fields; duplicate resolved is a no-op', () => {
+    const { session } = makeSession()
+    session.handleMuxEnvelope('ra' as never, {
+      type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险',
+    })
+    expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', callId: 'c1', reason: '危险' })
+    session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
+    session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
+    session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' })
+    expect(session.getSnapshot().pending).toEqual([])
+  })
+
+  it('ignores unknown mux frame types and repeated running flips (documented defaults)', () => {
+    const { session } = makeSession()
+    const before = session.getSnapshot()
+    session.handleMuxEnvelope('rz' as never, { type: 'future/frame' } as never)
+    session.handleRunning(false) // already false: dedup branch
+    expect(session.getSnapshot()).toBe(before)
+    session.handleRemoved()
+    expect(session.getSnapshot().removed).toBe(true)
+  })
+
+  it('drops live events while cold/error (no window upkeep)', async () => {
+    const { api, session } = makeSession()
+    session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(0, '冷态帧') })
+    expect(session.getSnapshot().nodes).toEqual([])
+    api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
+    await session.open()
+    session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(0, '错态帧') })
+    expect(session.getSnapshot().nodes).toEqual([])
+  })
+
+  it('repairGap failure logs and clears stitching; concurrent gaps coalesce into one repair', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    await session.open()
+    const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    let repairs = 0
+    api.onHistory = () => {
+      repairs++
+      return gate.promise
+    }
+    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞一') })
+      session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(10, '洞二') }) // stitching: detours, no second repair
+      expect(repairs).toBe(1)
+      gate.reject(new Error('repair wire down'))
+      await vi.waitFor(() => { expect(errorSpy).toHaveBeenCalled() })
+      // Window unchanged; a later successful repull still lands the buffered frames.
+      expect(session.getSnapshot().nodes).toHaveLength(2)
+    } finally {
+      errorSpy.mockRestore()
+    }
+  })
+
+  it('freezes only content-bearing partials; a content-free partial is dropped outright', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    await session.open()
+    const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+    feed(ev.turnStart(6, 1))
+    feed(ev.chunkStart(7, 1)) // empty text block only, no delta
+    feed(ev.turnEnd(8, 1, 'cancelled'))
+    const snapshot = session.getSnapshot()
+    expect(snapshot.partial).toBeNull()
+    expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([])
+  })
+
+  it('turn/end sweeps only same-turn open calls; other turns keep running', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    await session.open()
+    const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+    feed(ev.turnStart(6, 1))
+    feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}'))
+    feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn
+    feed(ev.turnEnd(9, 1, 'cancelled'))
+    const snapshot = session.getSnapshot()
+    expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call'])
+    expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true })
+  })
+
+  it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => {
+    const { api, session } = makeSession()
+    const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = () => stale.promise
+    const opening = session.open()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    const resynced = session.resync()
+    stale.reject(new Error('stale wire'))
+    await Promise.all([opening, resynced])
+    expect(session.getSnapshot().openState).toBe('open') // stale catch did not write error
+  })
+
+  it('drops a stale doOpen whose history resolved successfully after resync superseded it', async () => {
+    const { api, session } = makeSession()
+    const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = () => stale.promise
+    const opening = session.open()
+    api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
+    const resynced = session.resync()
+    stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '代')) as never[], hasMore: false })) // success, but its generation is gone
+    await Promise.all([opening, resynced])
+    expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
+  })
+
+  it('drops a stale stitch pull (second doOpen fetch) superseded mid-flight by resync', async () => {
+    const { api, session } = makeSession()
+    const secondPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    let call = 0
+    api.onHistory = () => {
+      call++
+      if (call === 1) return histResponse(plainTurn(0, 0, 'a', 'b')) // first page: tail 5
+      if (call === 2) return secondPull.promise // gap-stitch pull: held
+      return histResponse(plainTurn(6, 1, 'c', 'd'))
+    }
+    session.handleMuxEnvelope('rs' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 11 })
+    const opening = session.open() // triggers the second pull, which parks
+    await vi.waitFor(() => { expect(call).toBe(2) })
+    const resynced = session.resync()
+    secondPull.resolve(ok({ events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], hasMore: false }))
+    await Promise.all([opening, resynced])
+    expect(session.getSnapshot().openState).toBe('open')
+  })
+
+  it('drops a gap repair superseded by a full resync while its pull was in flight', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    await session.open()
+    const repairPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = () => repairPull.promise
+    session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap
+    api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
+    const resynced = session.resync() // bumps the generation
+    repairPull.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false })) // repair result: stale, dropped
+    await resynced
+    expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
+  })
+
+  it('successful cancel leaves no promptError; tool/result for an unknown callId is a no-op', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    await session.open()
+    const result = await session.cancel()
+    expect(result.ok).toBe(true)
+    expect(session.getSnapshot().promptError).toBeNull()
+    const callsBefore = session.getSnapshot().runningCalls
+    session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.toolResult(6, 0, 'never-called', 'x') })
+    expect(session.getSnapshot().runningCalls).toBe(callsBefore) // callsRev untouched: same reference
+  })
+
+  it('freezes a tool-call-only partial (visible through the non-text arm)', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    await session.open()
+    const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+    feed(ev.turnStart(6, 1))
+    feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } }))
+    feed(ev.turnEnd(8, 1, 'cancelled'))
+    const frozen = session.getSnapshot().nodes.at(-1)
+    expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] })
+  })
+
+  it('dispose is a reserved no-op on resident instances', () => {
+    const { session } = makeSession()
+    expect(() => { session.dispose() }).not.toThrow()
+  })
+
+  it('carries mux-frame views into runningCalls and tool-result nodes, and history-entry views through open', async () => {
+    const { api, session } = makeSession()
+    const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
+    api.onHistory = () => Promise.resolve(ok({
+      events: [
+        ...entries(plainTurn(0, 0, 'a', 'b')),
+        { event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView },
+        { event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
+      ] as never[],
+      hasMore: false,
+    }))
+    await session.open()
+    expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+      kind: 'tool-result', callView: { title: '历史卡' }, resultView: { title: '历史果' },
+    })
+    // Live path: the frame's view slot reaches runningCalls, then the result node.
+    session.handleMuxEnvelope('rv1' as never, {
+      type: 'session/event', sessionId: SID, event: ev.toolCall(8, 2, 'l1', 'write', '{}'),
+      view: { for: 'call', view: { card: 'generic', title: '直播卡' } },
+    } as never)
+    expect(session.getSnapshot().runningCalls).toMatchObject([{ callId: 'l1', callView: { title: '直播卡' } }])
+    session.handleMuxEnvelope('rv2' as never, {
+      type: 'session/event', sessionId: SID, event: ev.toolResult(9, 2, 'l1', 'ok'),
+      view: { for: 'result', view: { card: 'generic', title: '直播果' } },
+    } as never)
+    expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+      kind: 'tool-result', callView: { title: '直播卡' }, resultView: { title: '直播果' },
+    })
+  })
+})
+
+describe('resync', () => {
+  it('rebuilds the window and clears pending; cold instances no-op', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    await session.open()
+    session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
+    api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')])
+    await session.resync()
+    const snapshot = session.getSnapshot()
+    expect(snapshot.openState).toBe('open')
+    expect(snapshot.pending).toEqual([]) // baseline replay re-sends still-pending frames
+    expect(snapshot.nodes).toHaveLength(4)
+
+    const cold = makeSession()
+    await cold.session.resync()
+    expect(cold.api.calls).toEqual([]) // never opened: no traffic
+  })
+
+  it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
+    const { api, session } = makeSession()
+    const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = () => stale.promise
+    const firstOpen = session.open()
+    api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
+    const resynced = session.resync()
+    stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late
+    await firstOpen
+    await resynced
+    const snapshot = session.getSnapshot()
+    expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
+    expect(snapshot.nodes.map(n => n.seq)).toEqual([7, 9])
+  })
+})
+
+describe('reference stability (the memo contract)', () => {
+  it('keeps unchanged node references across an append and swaps the snapshot object', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
+    await session.open()
+    const before = session.getSnapshot()
+    session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(6, '追加') })
+    const after = session.getSnapshot()
+    expect(after).not.toBe(before) // top-level swap on change
+    expect(after.nodes[0]).toBe(before.nodes[0]) // untouched nodes keep identity
+    expect(after.nodes[1]).toBe(before.nodes[1])
+    expect(after.nodes).toHaveLength(3)
+    // No change → same snapshot reference.
+    expect(session.getSnapshot()).toBe(after)
+  })
+
+  it('keeps untouched substructure arrays identical across unrelated changes (revision counters)', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, '底', '座'))
+    await session.open()
+    const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+    feed(ev.turnStart(6, 1))
+    feed(ev.toolCall(7, 1, 'c1', 'echo', '{}'))
+    session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
+    const before = session.getSnapshot()
+    // A chunk storm touches partial/nodes only: runningCalls and pending must keep identity.
+    feed(ev.chunkStart(8, 1))
+    feed(ev.chunkText(9, 1, '与工具无关的流式'))
+    const after = session.getSnapshot()
+    expect(after).not.toBe(before)
+    expect(after.runningCalls).toBe(before.runningCalls)
+    expect(after.pending).toBe(before.pending)
+    // And a mutation on the tracked domain swaps that array.
+    feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
+    const resolved = session.getSnapshot()
+    expect(resolved.runningCalls).not.toBe(after.runningCalls)
+    expect(resolved.pending).toBe(after.pending)
+  })
+})

+ 193 - 0
packages/client/runtime/tests/sessions-service.spec.ts

@@ -0,0 +1,193 @@
+/**
+ * SessionsService: list store projection (manager → {ids, byId} with derived
+ * titles), scope-tree lifecycle (lazy mint / frozen survival / removed
+ * teardown with watch deferral), binding identity, ancestry walk, create.
+ */
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
+import { FakeApiClient, ok } from './fake-api.ts'
+
+const sid = (s: string): SessionId => s as SessionId
+
+interface Bench {
+  ctx: Context
+  api: FakeApiClient
+  svc: SessionsService
+}
+
+function bench(): Bench {
+  const ctx = new Context()
+  const api = new FakeApiClient()
+  const svc = new SessionsService(ctx, api)
+  return { ctx, api, svc }
+}
+
+/** Refresh the manager list from programmable rows and flush the microtask batch. */
+async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean }[]): Promise<void> {
+  b.api.onList = () => Promise.resolve(ok({
+    items: rows.map(r => ({
+      sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
+      ...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
+      ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
+    })),
+  }) as never)
+  await b.svc.manager.refreshList()
+  await Promise.resolve() // manager notifier flush
+}
+
+describe('list store projection', () => {
+  it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => {
+    const b = bench()
+    await feedList(b, [
+      { id: 's1', cwd: '/home/u/proj-a/' },
+      { id: 's2', parentId: 's1', running: true },
+    ])
+    const state = b.svc.list.getSnapshot()
+    expect(state.ids).toEqual(['s1', 's2'])
+    expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' })
+    expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true })
+  })
+
+  it('reflects live increments (host stream via manager) into the store', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }])
+    b.svc.manager.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
+    await Promise.resolve()
+    expect(b.svc.list.getSnapshot().ids).toContain('s2')
+  })
+})
+
+describe('scope tree', () => {
+  it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }])
+    expect(b.svc.scope(sid('unknown'))).toBeUndefined()
+    const scoped = b.svc.scope(sid('s1'))
+    expect(scoped).toBeDefined()
+    expect(scopeOf(scoped as Context)).toBe('s1')
+    expect(scopeOf(b.ctx)).toBeUndefined()
+    const binding = b.svc.binding(sid('s1'))
+    expect(binding?.session).toBe(b.svc.manager.get(sid('s1')))
+    expect(b.svc.binding(sid('s1'))).toBe(binding)
+    expect(binding?.ctx).toBe(scoped)
+  })
+
+  it('tears down an unwatched removed session but defers the watched one until the watch moves', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }, { id: 's2' }])
+    const ctx1 = b.svc.scope(sid('s1'))
+    b.svc.binding(sid('s1')) // s1 is watched
+    b.svc.scope(sid('s2')) // s2 scoped but not watched
+
+    await feedList(b, [{ id: 's1' }]) // s2 removed, unwatched: torn down
+    expect(b.svc.scope(sid('s2'))).toBeUndefined()
+
+    await feedList(b, []) // s1 removed while watched: deferred, scope survives
+    expect(b.svc.scope(sid('s1'))).toBe(ctx1)
+
+    await feedList(b, [{ id: 's3' }])
+    b.svc.binding(sid('s3')) // watch moves: deferred teardown sweeps s1
+    expect(b.svc.scope(sid('s1'))).toBeUndefined()
+  })
+
+  it('keeps the scope when the session merely stops running (frozen ≠ removed)', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1', running: true }])
+    const scoped = b.svc.scope(sid('s1'))
+    await feedList(b, [{ id: 's1', running: false }])
+    expect(b.svc.scope(sid('s1'))).toBe(scoped)
+  })
+
+  it('cancels a deferred teardown when the id reappears in the list', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }])
+    const scoped = b.svc.scope(sid('s1'))
+    b.svc.binding(sid('s1'))
+    await feedList(b, []) // removed while watched → deferred
+    await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears
+    b.svc.binding(sid('s2')) // watch moves; sweep must NOT tear down the re-listed s1
+    expect(b.svc.scope(sid('s1'))).toBe(scoped)
+  })
+})
+
+describe('ancestry', () => {
+  it('walks parentId links root-first including self; broken links stop the walk', async () => {
+    const b = bench()
+    await feedList(b, [
+      { id: 'root', cwd: '/w/app' },
+      { id: 'mid', parentId: 'root' },
+      { id: 'leaf', parentId: 'mid' },
+      { id: 'orphan', parentId: 'ghost' },
+    ])
+    expect(b.svc.ancestry(sid('leaf')).map(s => s.id)).toEqual(['root', 'mid', 'leaf'])
+    expect(b.svc.ancestry(sid('orphan')).map(s => s.id)).toEqual(['orphan'])
+    expect(b.svc.ancestry(sid('ghost'))).toEqual([])
+  })
+})
+
+describe('create', () => {
+  it('returns the new id on ok and throws a coded error on failure', async () => {
+    const b = bench()
+    b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
+    await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh')
+    b.api.onCreate = () => Promise.resolve({
+      rpcId: 'e' as never,
+      result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
+    } as never)
+    await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/)
+  })
+})
+
+describe('coverage tails (branch duals)', () => {
+  it('titleOf falls back to the id for empty and separator-only cwd', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
+    const { byId } = b.svc.list.getSnapshot()
+    expect(byId[sid('no-base')]?.title).toBe('no-base')
+    expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
+  })
+
+  it('binding for an unknown session returns undefined without moving the watch', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }])
+    b.svc.binding(sid('s1'))
+    expect(b.svc.binding(sid('ghost'))).toBeUndefined()
+    // Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch.
+    await feedList(b, [])
+    expect(b.svc.scope(sid('s1'))).toBeDefined()
+  })
+
+  it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }])
+    b.svc.binding(sid('s1'))
+    await feedList(b, []) // deferred removal of the watched id
+    // Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch).
+    expect(b.svc.binding(sid('s1'))).toBeDefined()
+    expect(b.svc.scope(sid('s1'))).toBeDefined()
+  })
+
+  it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 'a' }, { id: 'b' }])
+    b.svc.binding(sid('a'))
+    b.svc.binding(sid('b')) // watch: b; both scoped
+    await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred
+    // Move the watch to a THIRD id while b stays deferred: sweep now walks a
+    // set containing b (torn) — and the watched-continue branch fires when the
+    // deferral set still holds the current watch target.
+    await feedList(b, [{ id: 'c' }])
+    b.svc.binding(sid('c'))
+    expect(b.svc.scope(sid('b'))).toBeUndefined()
+    // Deferral for an id whose record was never minted: force-add via removed
+    // list state (scope teardown raced) — sweep must tolerate the missing record.
+    await feedList(b, [])
+    b.svc.binding(sid('c')) // c now watched+removed → deferred
+    await feedList(b, [{ id: 'd' }])
+    b.svc.binding(sid('d')) // sweep tears c
+    expect(b.svc.scope(sid('c'))).toBeUndefined()
+  })
+
+})

+ 80 - 0
packages/client/runtime/tests/slots-service.spec.ts

@@ -0,0 +1,80 @@
+/**
+ * SlotsService: cordis Service wrapper semantics — core delegation, the
+ * 'slots/changed' event bridge, and fiber-scoped registration disposal.
+ */
+import { Context } from 'cordis'
+import { describe, expect, it } from 'vitest'
+import type { FC } from 'react'
+import { SlotsService } from '../src/client/slots.ts'
+
+// Test-only slot keys (SlotMap is empty in this package; the service is generic over it).
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    't-single': { kind: 'single'; scope: 'root'; props: object }
+    't-list': { kind: 'list'; scope: 'root'; props: object }
+  }
+}
+
+const C: FC<object> = () => null
+
+async function boot(): Promise<Context> {
+  const ctx = new Context()
+  ctx.plugin(SlotsService)
+  await ctx.fiber.await()
+  return ctx
+}
+
+describe('SlotsService', () => {
+  it('proxies define/register/entries/spec/getVersion to the core', async () => {
+    const ctx = await boot()
+    ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
+    expect(ctx.slots.spec('t-single')).toEqual({ kind: 'single', scope: 'root' })
+    const v0 = ctx.slots.getVersion('t-single')
+    ctx.slots.register('t-single', C)
+    expect(ctx.slots.entries('t-single')).toHaveLength(1)
+    expect(ctx.slots.getVersion('t-single')).toBeGreaterThan(v0)
+    expect(ctx.slots.core.spec('t-single')).toBeDefined()
+  })
+
+  it("re-emits every mutation as 'slots/changed' with the key", async () => {
+    const ctx = await boot()
+    const seen: string[] = []
+    ctx.on('slots/changed', (key) => { seen.push(key) })
+    ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
+    ctx.slots.register('t-list', C, { id: 'a' })
+    expect(seen).toEqual(['t-list', 't-list'])
+  })
+
+  it('collects a plugin fiber\'s registrations when the fiber unloads (cascade)', async () => {
+    const ctx = await boot()
+    ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
+    const fiber = ctx.plugin({
+      name: 'occupant',
+      inject: ['slots'],
+      apply: (pluginCtx: Context) => {
+        pluginCtx.slots.register('t-single', C)
+      },
+    })
+    await fiber.await()
+    expect(ctx.slots.entries('t-single')).toHaveLength(1)
+    await fiber.dispose()
+    expect(ctx.slots.entries('t-single')).toHaveLength(0)
+    // The slot definition (registered from root) survives; a new occupant may register.
+    expect(() => ctx.slots.register('t-single', C)).not.toThrow()
+  })
+
+  it('proxies specDynamic/subscribe/getVersion through the core', async () => {
+    const ctx = await boot()
+    ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
+    expect(ctx.slots.specDynamic('t-list')).toEqual({ kind: 'list', scope: 'root' })
+    expect(ctx.slots.specDynamic('never-defined')).toBeUndefined()
+    let notified = 0
+    const unsubscribe = ctx.slots.subscribe('t-list', () => { notified += 1 })
+    ctx.slots.register('t-list', C, { id: 'row' })
+    await new Promise(resolve => setTimeout(resolve, 0)) // microtask-batched flush
+    expect(notified).toBeGreaterThan(0)
+    expect(ctx.slots.getVersion('t-list')).toBeGreaterThan(0)
+    unsubscribe()
+  })
+
+})

+ 39 - 0
packages/client/runtime/tsconfig.json

@@ -0,0 +1,39 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "lib": [
+      "ES2024",
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "types": []
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../web-react"
+    },
+    {
+      "path": "../connection"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ],
+  "exclude": [
+    "**/*.legacy.*"
+  ]
+}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio