Преглед изворни кода

refactor(client): replace legacy Host event carriers

imccyu пре 3 недеља
родитељ
комит
dcddaa1a6e
100 измењених фајлова са 4673 додато и 5599 уклоњено
  1. 5 4
      apps/web/tests/agent-preset-authoring.e2e.ts
  2. 3 2
      apps/web/tests/agent-preset-selection.e2e.ts
  3. 5 4
      apps/web/tests/chat-scroll-contract.e2e.ts
  4. 11 20
      apps/web/tests/default-model.e2e.ts
  5. 1 1
      apps/web/tests/lifecycle-chrome.e2e.ts
  6. 1 1
      apps/web/tests/replay-round-trip.e2e.ts
  7. 7 5
      apps/web/tests/seeded-history.e2e.ts
  8. 33 19
      apps/web/tests/smoke-real.e2e.ts
  9. 4 4
      apps/web/tests/startup-auto-selection.e2e.ts
  10. 1 1
      apps/web/tests/steering.e2e.ts
  11. 4 4
      apps/web/tests/subagent-conversation.e2e.ts
  12. 2 2
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  13. 19 2
      apps/web/tests/subagent-interrupt.e2e.ts
  14. 5 4
      apps/web/tests/trajectory-virtualization.e2e.ts
  15. 30 10
      packages/api/remotes/src/client/index.ts
  16. 146 26
      packages/api/remotes/src/index.ts
  17. 20 18
      packages/api/remotes/src/remote-events.ts
  18. 1 1
      packages/api/remotes/src/types.ts
  19. 216 0
      packages/api/remotes/tests/remote-events.host.spec.ts
  20. 1 8
      packages/client/connection/src/api-path.ts
  21. 3 8
      packages/client/connection/src/api-request-trust.ts
  22. 6 9
      packages/client/connection/src/client/api.ts
  23. 95 56
      packages/client/connection/src/client/connection.ts
  24. 985 382
      packages/client/connection/src/client/fixture.ts
  25. 62 23
      packages/client/connection/src/client/index.ts
  26. 56 4
      packages/client/connection/src/client/rpc.ts
  27. 2 83
      packages/client/connection/src/client/web-api-client.ts
  28. 4 4
      packages/client/connection/src/http-bridge.ts
  29. 7 32
      packages/client/connection/src/index.ts
  30. 16 3
      packages/client/connection/src/rpc-host.ts
  31. 44 4
      packages/client/connection/src/rpc.ts
  32. 0 153
      packages/client/connection/src/websocket-downlink.ts
  33. 151 141
      packages/client/connection/tests/client-apply.client.spec.ts
  34. 75 73
      packages/client/connection/tests/connection.client.spec.ts
  35. 26 125
      packages/client/connection/tests/fake-api.client.ts
  36. 17 11
      packages/client/connection/tests/fixture-commands.client.spec.ts
  37. 668 176
      packages/client/connection/tests/fixture.client.spec.ts
  38. 23 31
      packages/client/connection/tests/node-half.host.spec.ts
  39. 0 308
      packages/client/connection/tests/websocket-downlink.host.spec.ts
  40. 5 6
      packages/client/locale/tests/apply.client.spec.ts
  41. 3 2
      packages/client/runtime/src/client/agents/scope.ts
  42. 2 2
      packages/client/runtime/src/client/contract/conversation.ts
  43. 6 6
      packages/client/runtime/src/client/contract/session.ts
  44. 4 4
      packages/client/runtime/src/client/contract/sessions.ts
  45. 56 38
      packages/client/runtime/src/client/index.ts
  46. 8 8
      packages/client/runtime/src/client/sessions/conversation.ts
  47. 2 2
      packages/client/runtime/src/client/sessions/lineage.ts
  48. 215 243
      packages/client/runtime/src/client/sessions/manager.ts
  49. 35 23
      packages/client/runtime/src/client/sessions/pending.ts
  50. 11 13
      packages/client/runtime/src/client/sessions/projection-store.ts
  51. 13 20
      packages/client/runtime/src/client/sessions/queue-mirror.ts
  52. 2 2
      packages/client/runtime/src/client/sessions/remotes.ts
  53. 48 23
      packages/client/runtime/src/client/sessions/service.ts
  54. 212 219
      packages/client/runtime/src/client/sessions/session.ts
  55. 79 58
      packages/client/runtime/tests/client-apply.client.spec.ts
  56. 1 1
      packages/client/runtime/tests/conversation-registry.client.spec.ts
  57. 309 132
      packages/client/runtime/tests/fake-api.client.ts
  58. 256 322
      packages/client/runtime/tests/manager.client.spec.ts
  59. 28 35
      packages/client/runtime/tests/projection-store.client.spec.ts
  60. 80 54
      packages/client/runtime/tests/queue-store.client.spec.ts
  61. 191 112
      packages/client/runtime/tests/session.client.spec.ts
  62. 17 26
      packages/client/runtime/tests/sessions-service.client.spec.ts
  63. 0 135
      packages/client/runtime/tests/wire-events.client.spec.ts
  64. 12 14
      packages/client/ui-agent-preset/tests/apply.client.spec.ts
  65. 7 18
      packages/client/ui-commands/tests/service.client.spec.ts
  66. 1 1
      packages/client/ui-conversation/src/client/contract/slots.ts
  67. 1 1
      packages/client/ui-conversation/src/client/input/contract.ts
  68. 3 3
      packages/client/ui-conversation/tests/chat-view.client.spec.tsx
  69. 6 4
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  70. 4 6
      packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
  71. 8 10
      packages/client/ui-settings-models/tests/apply.client.spec.ts
  72. 8 11
      packages/client/ui-settings-plugins/tests/apply.client.spec.ts
  73. 6 6
      packages/client/ui-settings/tests/plugin.client.spec.ts
  74. 4 4
      packages/client/ui-skill/tests/browser-plugin.client.spec.ts
  75. 7 8
      packages/client/ui-theme/tests/apply.client.spec.ts
  76. 2 2
      packages/client/ui-user-questions/src/client/contract/slots.ts
  77. 15 8
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  78. 23 16
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  79. 1 8
      packages/client/web/src/boot.ts
  80. 69 0
      packages/client/web/tests/boot.client.spec.ts
  81. 14 20
      packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts
  82. 44 1455
      packages/host/apiproxy/src/api-proxy.ts
  83. 1 1
      packages/host/apiproxy/src/api/agent-presets.schema.ts
  84. 2 3
      packages/host/apiproxy/src/api/downloads.schema.ts
  85. 2 3
      packages/host/apiproxy/src/api/downloads.ts
  86. 0 93
      packages/host/apiproxy/src/api/events.schema.ts
  87. 0 155
      packages/host/apiproxy/src/api/events.ts
  88. 4 5
      packages/host/apiproxy/src/api/goals.ts
  89. 6 36
      packages/host/apiproxy/src/api/index.ts
  90. 42 1
      packages/host/apiproxy/src/api/llm.schema.ts
  91. 5 3
      packages/host/apiproxy/src/api/llm.ts
  92. 3 26
      packages/host/apiproxy/src/api/rpc-map.ts
  93. 6 44
      packages/host/apiproxy/src/api/rpc.schema.ts
  94. 7 54
      packages/host/apiproxy/src/api/rpc.ts
  95. 1 1
      packages/host/apiproxy/src/api/skills.schema.ts
  96. 4 4
      packages/host/apiproxy/src/api/skills.ts
  97. 3 19
      packages/host/apiproxy/src/api/subagents.schema.ts
  98. 1 20
      packages/host/apiproxy/src/api/subagents.ts
  99. 8 182
      packages/host/apiproxy/src/fetch/client.ts
  100. 5 106
      packages/host/apiproxy/src/fetch/handler.ts

+ 5 - 4
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -259,17 +259,18 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     await dialog.waitFor({ state: 'detached', timeout: 10_000 })
     await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 })
     await expect.poll(async () => {
-      const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
+      const response = await fetch(`${scaffold.baseUrl}/api/session/list`, {
         method: 'POST',
         headers: { 'content-type': 'application/json' },
         body: JSON.stringify({
-          type: 'client-request', rpcId: 'creator-draft-stage', method: 'session.list', payload: {},
+          type: 'client-request', rpcId: 'creator-draft-stage', method: 'session/list',
+          payload: { args: { _request: {} } },
         }),
       })
       const body = await response.json() as {
-        result: { value?: { sessions: unknown[] } }
+        result: { value?: { items: unknown[] } }
       }
-      return JSON.stringify(body.result.value?.sessions ?? body.result)
+      return JSON.stringify(body.result.value?.items ?? body.result)
     }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"')
   }, 60_000)
 

+ 3 - 2
apps/web/tests/agent-preset-selection.e2e.ts

@@ -145,11 +145,12 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
  * @returns the live session's preset, or undefined before it is listed.
  */
 async function livePreset(baseUrl: string): Promise<string | undefined> {
-  const response = await fetch(`${baseUrl}/api/session.list`, {
+  const response = await fetch(`${baseUrl}/api/session/list`, {
     method: 'POST',
     headers: { 'content-type': 'application/json' },
     body: JSON.stringify({
-      type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {},
+      type: 'client-request', rpcId: 'agent-preset-live', method: 'session/list',
+      payload: { args: { _request: {} } },
     }),
   })
   const body = await response.json() as {

+ 5 - 4
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -482,12 +482,13 @@ describe('web e2e: long Chat scroll contract', () => {
       let releaseGate: (() => void) | undefined
       const gate = new Promise<void>((resolve) => { releaseGate = resolve })
       releaseHistory = () => { releaseGate?.() }
-      await world.page.route('**/api/session.history', async (route) => {
+      await world.page.route('**/api/session/page', async (route) => {
         const request = route.request().postDataJSON() as {
           method?: string
-          payload?: { beforeSeq?: number }
+          payload?: { args?: { request?: { beforeSeq?: number } } }
         }
-        if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
+        if (!held && request.method === 'session/page'
+          && request.payload?.args?.request?.beforeSeq !== undefined) {
           held = true
           await gate
         }
@@ -524,7 +525,7 @@ describe('web e2e: long Chat scroll contract', () => {
       await settled
       await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
       await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
-      await world.page.unroute('**/api/session.history')
+      await world.page.unroute('**/api/session/page')
 
       let additionalPages = 0
       while (additionalPages < 8) {

+ 11 - 20
apps/web/tests/default-model.e2e.ts

@@ -39,22 +39,16 @@ describe('web e2e: the composer model switch is the default for later sessions',
 
   /** Create one session and its agent through the same wire face the browser uses. */
   const createSession = async (sessionId: string): Promise<string> => {
-    const response = await scaffold.ctx.apiProxy.sessions.create({
-      rpcId: `default-model-create-${sessionId}` as never,
-      payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd },
+    const response = await scaffold.ctx.sessionController.create({
+      sessionId: SessionId(sessionId),
+      cwd: scaffold.workspaceCwd,
     })
-    if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`)
-    return response.result.value.sessionId
+    return response.sessionId
   }
 
   /** The route the gateway reports for one session, through the real wire face. */
   const currentOf = async (sessionId: string): Promise<unknown> => {
-    const response = await scaffold.ctx.apiProxy.sessions.models({
-      rpcId: `default-model-${sessionId}` as never,
-      payload: { sessionId: SessionId(sessionId) },
-    })
-    if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`)
-    return response.result.value.current
+    return (await scaffold.ctx.sessionController.models({ sessionId: SessionId(sessionId) })).current
   }
 
   beforeAll(async () => {
@@ -144,15 +138,12 @@ describe('web e2e: the composer model switch is the default for later sessions',
 
     // The block is an affordance; the refusal is the Host's. A client that
     // never disabled anything still cannot start a turn on a dead route.
-    const refused = await scaffold.ctx.apiProxy.sessions.prompt({
-      rpcId: 'default-model-refused' as never,
-      payload: {
-        sessionId: SessionId(await createSession('default-model-refusal')),
-        mode: 'queue' as const,
-        content: [{ type: 'text' as const, text: 'hi' }],
-      },
-    })
-    expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
+    await expect(scaffold.ctx.sessionController.prompt({
+      requestId: 'default-model-refused' as never,
+      sessionId: SessionId(await createSession('default-model-refusal')),
+      mode: 'queue',
+      content: [{ type: 'text', text: 'hi' }],
+    }, new AbortController().signal)).rejects.toMatchObject({ failure: { code: 'model-unavailable' } })
 
     // The way out stays open. Locking the model seat with everything else
     // would leave the composer asking for the one thing it prevents.

+ 1 - 1
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -228,7 +228,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     // Selection persisted (dsh.sessions.current) and history replayed: the
-    // recorded turn re-renders from session.history with zero model calls —
+    // recorded turn re-renders from a Session Controller page with zero model calls —
     // the replay cursor was fully consumed before the reload, so any stray
     // request would fail the scenario loudly at close().
     await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)

+ 1 - 1
apps/web/tests/replay-round-trip.e2e.ts

@@ -153,7 +153,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think'))
     // Interaction over the REAL wire-delivered transcript (the fixture-client
     // tier pins the same gesture against FixtureApiClient; this one runs on
-    // mux-frame-fed state). Runs after the golden capture so the committed
+    // follow-stream-fed state). Runs after the golden capture so the committed
     // aria surface stays the untouched settled state.
     const think = page.getByRole('button', { name: /^Think/ }).first()
     expect(await think.getAttribute('aria-expanded')).toBe('false')

+ 7 - 5
apps/web/tests/seeded-history.e2e.ts

@@ -1,7 +1,7 @@
 // Web e2e scenario: seeded history. A recorded session seeded cold through
 // the REAL persistence API renders purely from the log — the surface nothing
-// else covers: sidebar cold listing, the implicit resume/attach inside the
-// history RPC, history-page tool views, and the client's log-ordered transcript
+// else covers: sidebar cold listing, cold history paging without Agent
+// activation, history-page tool views, and the client's log-ordered transcript
 // events — with ZERO model calls in replay (no replay fixture; a stray stream
 // fails loud on the open llm seam). The cold session also carries keyless
 // command-row surfaces: the seeded manual `/compact` lifecycle folds into its
@@ -232,12 +232,14 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // injection stays silent and this block disappears (no titles/todos on
     // the web), while fixture-level suites stay green. Assert through the
     // real HTTP wire against the booted real host.
-    const response = await fetch(`${scaffold.baseUrl}/api/session.history`, {
+    const response = await fetch(`${scaffold.baseUrl}/api/session/page`, {
       method: 'POST',
       headers: { 'content-type': 'application/json' },
       body: JSON.stringify({
-        type: 'client-request', rpcId: 'seeded-projections', method: 'session.history',
-        payload: { sessionId: SEED_ID },
+        type: 'client-request', rpcId: 'seeded-projections', method: 'session/page',
+        payload: {
+          args: { request: { address: { kind: 'session', sessionId: SEED_ID } } },
+        },
       }),
     })
     expect(response.ok).toBe(true)

+ 33 - 19
apps/web/tests/smoke-real.e2e.ts

@@ -16,6 +16,7 @@
 // sequentially in-file.
 import type { ChildProcess } from 'node:child_process'
 import { spawn } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { createServer } from 'node:http'
 import { createRequire } from 'node:module'
@@ -50,22 +51,22 @@ function waitForReadyLine(child: ChildProcess): Promise<string> {
   })
 }
 
-async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<T> {
-  const response = await fetch(`${baseUrl}/api/${method}`, {
+async function remoteRpc<T>(baseUrl: string, endpoint: string, args: object): Promise<T> {
+  const response = await fetch(`${baseUrl}/api/${endpoint}`, {
     method: 'POST',
     headers: { 'content-type': 'application/json' },
     body: JSON.stringify({
       type: 'client-request',
-      rpcId: `smoke-${method}`,
-      method,
-      payload,
+      rpcId: `smoke-${endpoint}`,
+      method: endpoint,
+      payload: { args },
     }),
   })
-  if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`)
+  if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`)
   const body = await response.json() as {
     result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
   }
-  if (!body.result.ok) throw new Error(`${method} failed: ${body.result.error.code}: ${body.result.error.message}`)
+  if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.code}: ${body.result.error.message}`)
   return body.result.value
 }
 
@@ -101,7 +102,9 @@ function hasAssistantMarker(page: HistoryPage, marker: string): boolean {
 }
 
 async function history(baseUrl: string, sessionId: string): Promise<HistoryPage> {
-  return rpc<HistoryPage>(baseUrl, 'session.history', { sessionId, maxMessages: 10 })
+  return remoteRpc<HistoryPage>(baseUrl, 'session/page', {
+    request: { address: { kind: 'session', sessionId }, maxMessages: 10 },
+  })
 }
 
 async function waitForProviderTitle(baseUrl: string, sessionId: string): Promise<string> {
@@ -242,12 +245,13 @@ describe('dsh web keyless CLI smoke', () => {
     )
     try {
       const baseUrl = await waitForReadyLine(child)
-      const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
-      await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
+      const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} })
+      await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: {
+        requestId: randomUUID(),
         sessionId: created.sessionId,
         mode: 'queue',
         content: [{ type: 'text', text: 'go' }],
-      })
+      } })
       const capturedRequests = await Promise.race([
         providerRequests,
         new Promise<never>((_resolve, reject) => {
@@ -354,12 +358,13 @@ describe('dsh web keyless CLI smoke', () => {
     )
     try {
       const baseUrl = await waitForReadyLine(child)
-      const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
-      await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
+      const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} })
+      await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: {
+        requestId: randomUUID(),
         sessionId: created.sessionId,
         mode: 'queue',
         content: [{ type: 'text', text: promptMarker }],
-      })
+      } })
       let page: HistoryPage | undefined
       await expect.poll(async () => {
         page = await history(baseUrl, created.sessionId)
@@ -438,12 +443,13 @@ describe('dsh web keyless CLI smoke', () => {
     )
     try {
       const baseUrl = await waitForReadyLine(child)
-      const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
-      await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
+      const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} })
+      await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: {
+        requestId: randomUUID(),
         sessionId: created.sessionId,
         mode: 'queue',
         content: [{ type: 'text', text: 'go' }],
-      })
+      } })
       const captured = await Promise.race([
         providerRequest,
         new Promise<never>((_resolve, reject) => {
@@ -555,10 +561,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
       productTitle,
       { timeout: 15_000 },
     )
-    await expect.poll(async () => (await rpc<{ items: { sessionId: string }[] }>(baseUrl, 'session.list', {})).items.length, {
+    await expect.poll(async () => (await remoteRpc<{ items: { sessionId: string }[] }>(
+      baseUrl,
+      'session/list',
+      { _request: {} },
+    )).items.length, {
       timeout: 15_000,
     }).toBe(1)
-    const sessions = await rpc<{ items: { sessionId: string }[] }>(baseUrl, 'session.list', {})
+    const sessions = await remoteRpc<{ items: { sessionId: string }[] }>(
+      baseUrl,
+      'session/list',
+      { _request: {} },
+    )
     const sessionId = sessions.items[0]?.sessionId
     if (sessionId === undefined) throw new Error('created Web session was not listed')
     const durableTitle = await waitForProviderTitle(baseUrl, sessionId)

+ 4 - 4
apps/web/tests/startup-auto-selection.e2e.ts

@@ -5,7 +5,7 @@
 // workspace and opens its blank session. `openState` flips to `loading` the
 // moment `open()` lands; driving `data-phase=settling` on the conversation
 // root from that flip would hide the composer seat and the header
-// (`visibility:hidden`) for the whole `session.history` round-trip — the
+// (`visibility:hidden`) for the whole `session.page` round-trip — the
 // center column blanks and repaints like a full-page refresh on every launch.
 //
 // The unit spec pins the phase condition over hand-built stores. What only the
@@ -17,7 +17,7 @@
 // replacing those nodes.
 //
 // The round-trip against a loopback host is far too fast to observe, so this
-// scenario HOLDS the `session.history` response open in the browser's network
+// scenario HOLDS the `session.page` response open in the browser's network
 // handler and asserts the visible frame while it is in flight. That wait is
 // what makes the assertions non-vacuous: without the phase exemption, the held
 // window is exactly when `settling` would be painted and the composer hidden.
@@ -31,8 +31,8 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 
-/** Wire path of the history round-trip the conversation root waits out (POST /api/session.history). */
-const HISTORY_ROUTE = '**/api/session.history'
+/** Wire path of the history round-trip the conversation root waits out. */
+const HISTORY_ROUTE = '**/api/session/page'
 
 /**
  * The conversation root's own phase attribute. `div` disambiguates it from the

+ 1 - 1
apps/web/tests/steering.e2e.ts

@@ -19,7 +19,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 // Two goldens pin the transient Host projection and its durable handoff: the
-// mid-turn state renders accepted steering from session/queue while the
+// mid-turn state renders accepted steering from the Session control queue while the
 // question blocks admission, then the settled state renders the same message
 // from user/message beside the reply that obeys it.
 const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')

+ 4 - 4
apps/web/tests/subagent-conversation.e2e.ts

@@ -219,7 +219,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       },
     ])
     // These two cold fixtures were authored after the page's initial
-    // session.list and intentionally emitted no session-added frame. Reload
+    // session.list and intentionally emitted no api-session/added event. Reload
     // to exercise the restart baseline that discovers their full lineage.
     const warningStart = tripwire.warnings.length
     await page.reload({ waitUntil: 'load' })
@@ -352,7 +352,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
   })
 
-  it('continues through FIFO follow-up admission and receives the child mux events', async () => {
+  it('continues through FIFO follow-up admission and receives the child follow events', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
     const ended = new Promise<void>((resolveEnded, reject) => {
       const timer = setTimeout(() => {
@@ -451,7 +451,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
     await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
     const forkResponse = page.waitForResponse(response =>
-      new URL(response.url()).pathname === '/api/session.fork')
+      new URL(response.url()).pathname === '/api/session/fork')
     await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
     const forkReceipt = await (await forkResponse).json() as { result: { ok: boolean } }
     expect(forkReceipt.result).toMatchObject({ ok: true })
@@ -479,7 +479,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
 
     const forkResponse = page.waitForResponse(response =>
-      new URL(response.url()).pathname === '/api/session.fork')
+      new URL(response.url()).pathname === '/api/session/fork')
     await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
     const forkReceipt = await (await forkResponse).json() as {
       result: { ok: true; value: { sessionId: string } } | { ok: false }

+ 2 - 2
apps/web/tests/subagent-interrupt-ui.e2e.ts

@@ -232,7 +232,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       expect(((await (await interruptResponse).json()) as {
         result: { ok: boolean; value?: { accepted: boolean } }
       }).result).toMatchObject({ ok: true, value: { accepted: true } })
-      expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
+      expect(apiCalls.filter(path => path === '/api/session/cancel')).toEqual([])
       await aborted
       await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
 
@@ -280,7 +280,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       result: { ok: boolean; value?: { accepted: boolean } }
     }).result).toMatchObject({ ok: true, value: { accepted: true } })
     // The addressed child stops through its own RPC, never the generic one.
-    expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([])
+    expect(apiCalls.filter(path => path === '/api/session/cancel')).toEqual([])
     await aborted
 
     // Parked: the Activation stays resident and idle with the retained

+ 19 - 2
apps/web/tests/subagent-interrupt.e2e.ts

@@ -22,7 +22,7 @@ const WAKING = 'And add one concrete example.'
 
 type RpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
 
-/** POST one unary RPC through the real HTTP carrier and unwrap its result. */
+/** POST one API Proxy unary RPC through the real HTTP carrier and unwrap its result. */
 async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<RpcResult<T>> {
   const response = await fetch(`${baseUrl}/api/${method}`, {
     method: 'POST',
@@ -38,6 +38,23 @@ async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promis
   return (await response.json() as { result: RpcResult<T> }).result
 }
 
+/** POST one generated Session Remote unary through the API Gateway carrier. */
+async function sessionRemote<T>(baseUrl: string, method: string, request: unknown): Promise<RpcResult<T>> {
+  const endpoint = `session/${method}`
+  const response = await fetch(`${baseUrl}/api/${endpoint}`, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({
+      type: 'client-request',
+      rpcId: `interrupt-e2e-${endpoint}-${randomUUID()}`,
+      method: endpoint,
+      payload: { args: { request } },
+    }),
+  })
+  if (!response.ok) throw new Error(`${endpoint} failed over HTTP ${response.status}: ${await response.text()}`)
+  return (await response.json() as { result: RpcResult<T> }).result
+}
+
 /** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
 async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise<void> {
   const deadline = Date.now() + timeoutMs
@@ -91,7 +108,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real co
     })
 
     // A live parent Agent through the real API; no workspace or browser.
-    const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', {
+    const created = await sessionRemote<{ sessionId: string }>(scaffold.baseUrl, 'create', {
       cwd: scaffold.workspaceCwd,
     })
     if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`)

+ 5 - 4
apps/web/tests/trajectory-virtualization.e2e.ts

@@ -218,12 +218,13 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
     let finishHeldRequest: () => void = () => {}
     const gate = new Promise<void>((resolve) => { releaseHistory = resolve })
     const heldRequestFinished = new Promise<void>((resolve) => { finishHeldRequest = resolve })
-    await page.route('**/api/session.history', async (route) => {
+    await page.route('**/api/session/page', async (route) => {
       const request = route.request().postDataJSON() as {
         method?: string
-        payload?: { beforeSeq?: number }
+        payload?: { args?: { request?: { beforeSeq?: number } } }
       }
-      if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
+      if (!held && request.method === 'session/page'
+        && request.payload?.args?.request?.beforeSeq !== undefined) {
         held = true
         await gate
         try {
@@ -340,7 +341,7 @@ describe('web e2e: Trajectory virtualization over tail-paged history', () => {
     } finally {
       releaseHistory()
       if (held) await heldRequestFinished
-      await page.unroute('**/api/session.history')
+      await page.unroute('**/api/session/page')
     }
   }, 180_000)
 })

+ 30 - 10
packages/api/remotes/src/client/index.ts

@@ -8,9 +8,11 @@ import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote'
 import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
 import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
-import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
+import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote'
+import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote'
+import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
 
-export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol'
+export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
 export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
 export type {} from '@deepseek-ai/dsh-commands/remote'
 export type {} from '@deepseek-ai/dsh-file-reference/remote'
@@ -18,6 +20,11 @@ export type {} from '@deepseek-ai/dsh-goal/remote'
 export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 export type {} from '@deepseek-ai/dsh-message-feedback/remote'
 export type {} from '@deepseek-ai/dsh-session-reference/remote'
+export type {} from '@deepseek-ai/dsh-api-session-controller/remote'
+export type * from '@deepseek-ai/dsh-api-session-controller/types'
+export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
+export type * from '@deepseek-ai/dsh-api-workspace-controller/types'
+export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types'
 // The forwarded-event allowlist's selection seat: without it in the consumer's
 // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails.
 export type { ApiRemoteForwardedEvent } from '../types.ts'
@@ -30,6 +37,9 @@ export type {} from '@deepseek-ai/dsh-credentials/types'
 export type {} from '@deepseek-ai/dsh-llm/types'
 export type {} from '@deepseek-ai/dsh-agent-presets/types'
 export type {} from '@deepseek-ai/dsh-settings/types'
+export type {} from '@deepseek-ai/dsh-user-approval/types'
+export type {} from '@deepseek-ai/dsh-user-questions/types'
+export type {} from '@deepseek-ai/dsh-api-session-controller/types'
 
 /**
  * The carrier's Client-facing types, re-exported so a business package names one
@@ -37,14 +47,12 @@ export type {} from '@deepseek-ai/dsh-settings/types'
  * the carrier's runtime values stay behind their own module edge.
  */
 export type {
-  ClientResponse, ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
-  CredentialView, DirectoryListing, DiscoveredModelView, HistoryEntry, HostFrame, IApiClient,
+  ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock,
+  CredentialView, DirectoryListing, DiscoveredModelView, IApiClient,
   MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
-  MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt,
-  RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem,
-  SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
-  SubagentAddress, SubagentCatalog, JobView, ToolCallView, ToolEventView, ToolResultView,
-  WorkspaceId, WorkspaceView,
+  RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
+  SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
+  SubagentAddress, SubagentCatalog, ToolCallView, ToolResultView,
 } from '@deepseek-ai/dsh-client-connection/client'
 export type {} from '@deepseek-ai/dsh-api-gateway/client'
 export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote'
@@ -95,10 +103,21 @@ export type { JsonValue } from '@deepseek-ai/dsh-session/types'
 export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types'
 export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types'
 
+/** Failure vocabulary exposed by the assembled Client data layer. */
+export type ClientFailure =
+  | import('@deepseek-ai/dsh-client-connection/client').RpcError
+  | import('@deepseek-ai/dsh-api-session-controller/types').SessionError
+  | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError
+
+/** Success or failure returned by Client operations spanning both API families. */
+export type ClientResult<T> =
+  | { readonly ok: true; readonly value: T }
+  | { readonly ok: false; readonly error: ClientFailure }
+
 declare module '@deepseek-ai/cordis' {
   interface Context {
     /** Generated Remote namespaces selected by this Client assembly. */
-    remote: TypertClientRemote
+    remote: ClientRemote
   }
 }
 
@@ -116,6 +135,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
     for (const contribution of [
       commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
       pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
+      sessionRemote, workspaceRemote,
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))
     }

+ 146 - 26
packages/api/remotes/src/index.ts

@@ -1,6 +1,15 @@
 /** Host BFF entry and Loader shell for the Remote contribution assembly. */
 
-import type { TypertForwardableEvent } from '@deepseek-ai/dsh-typert-protocol'
+import type { Context } from '@deepseek-ai/cordis'
+import type {
+  TypertRemoteEventDispatch,
+  TypertRemoteEventInvocation,
+  TypertRemoteEventOutcome,
+  TypertRemoteEventSource,
+} from '@deepseek-ai/dsh-api-gateway'
+import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
+import { isJsonValue } from '@deepseek-ai/dsh-session'
+import type { JsonValue } from '@deepseek-ai/dsh-session'
 import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
 
 // The owner packages' client-safe `./types` exports carry the cordis `Events`
@@ -13,32 +22,143 @@ import type {} from '@deepseek-ai/dsh-credentials/types'
 import type {} from '@deepseek-ai/dsh-llm/types'
 import type {} from '@deepseek-ai/dsh-agent-presets/types'
 import type {} from '@deepseek-ai/dsh-settings/types'
+import type {} from '@deepseek-ai/dsh-user-approval'
+import type {} from '@deepseek-ai/dsh-user-questions'
+export type {} from '@deepseek-ai/dsh-api-session-controller/types'
 
-export {
-  ApiRemoteSessionNotFound,
-  ApiRemoteSubagentSessionOwnership,
-  apiRemoteSubagentOwnershipError,
-  createApiRemoteAgentResolver,
-  hasApiRemoteSubagentOwner,
-  inspectApiRemoteSession,
-} from './agent-lookup.ts'
-export type {
-  ApiRemoteAgentOptions,
-  ApiRemoteAgentResult,
-  ApiRemoteLookupError,
-} from './agent-lookup.ts'
 export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
 export type { ApiRemoteForwardedEvent } from './types.ts'
 
-// Shape gate over the allowlist, kept in the Host face because the Host's event
-// vocabulary is the authoritative one. It pins three things at compile time:
-// every entry NAMES a declared event (the predicate is keyed on `keyof
-// Events`), no entry BINDS a Scope (a scoped event's `ThisParameterType` is not
-// `unknown`, which is how "must not depend on AgentScope" is stated statically),
-// and every entry is ONE-WAY (a waterfall or bail shape returns something other
-// than void and is excluded). Widening the array to an event that fails any of
-// these fails here, not on the wire.
-API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[]
-
-/** Host plugin body; the selected contributions mount only in Client environments. */
-export function apply(): void {}
+/** Required Host service: the Gateway owns the physical Remote stream mux. */
+export const inject = ['typertGateway']
+
+/** Host plugin body registering this application's selected Cordis event source. */
+export function apply(ctx: Context): void {
+  ctx.effect(
+    () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx)),
+    'api-remotes: forwarded Cordis event source',
+  )
+}
+
+/** Create the sole queue and listener set consumed by the registered Gateway. */
+function remoteEventSource(ctx: Context): TypertRemoteEventSource {
+  return (signal) => {
+    const queue = new RemoteEventQueue()
+    const disposers = API_REMOTE_FORWARDED_EVENTS.map(({ event, mode }) => {
+      if (mode === 'emit') {
+        return ctx.on(event as never, ((...args: unknown[]) => {
+          queue.push({ event, args: assertJsonArgs(event, args) })
+        }) as never)
+      }
+      return ctx.on(event as never, (function (
+        this: unknown,
+        request: object,
+        next: () => unknown,
+      ) {
+        const subject = carrierKeyOf(this)
+        if (subject === undefined) return next()
+        const value = Reflect.get(subject, 'ctx') as unknown
+        if (typeof value !== 'object' || value === null) {
+          throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} has no live Context`)
+        }
+        return forwardWaterfall(
+          queue,
+          event,
+          request,
+          { value: value as Context, subject },
+          next,
+        )
+      }) as never)
+    })
+    return queue.iterate(signal, () => {
+      for (const dispose of disposers) dispose()
+    })
+  }
+}
+
+/** One pull-driven queue bridging synchronous Cordis listeners to an AsyncIterable. */
+class RemoteEventQueue {
+  private readonly buffer: TypertRemoteEventDispatch[] = []
+  private waiter: (() => void) | undefined
+  private done = false
+
+  push(frame: TypertRemoteEventDispatch): boolean {
+    if (this.done) return false
+    this.buffer.push(frame)
+    this.waiter?.()
+    return true
+  }
+
+  private end(reason: unknown): void {
+    if (this.done) return
+    this.done = true
+    const buffered = this.buffer.splice(0)
+    for (const dispatch of buffered) {
+      if ('context' in dispatch) dispatch.reject(reason)
+    }
+    this.waiter?.()
+  }
+
+  async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<TypertRemoteEventDispatch> {
+    const abort = (): void => { this.end(remoteEventSourceEndReason(signal)) }
+    signal.addEventListener('abort', abort, { once: true })
+    try {
+      while (true) {
+        if (this.done || signal.aborted) return
+        while (this.buffer.length > 0) yield this.buffer.shift() as TypertRemoteEventDispatch
+        await new Promise<void>((resolve) => { this.waiter = resolve })
+        this.waiter = undefined
+      }
+    } finally {
+      signal.removeEventListener('abort', abort)
+      this.end(remoteEventSourceEndReason(signal))
+      cleanup()
+    }
+  }
+}
+
+/**
+ * Normalize an event-source shutdown for pending Host waterfalls.
+ * @param signal - source lifetime whose reason wins after cancellation.
+ * @returns the cancellation reason or an unexpected-end failure.
+ */
+function remoteEventSourceEndReason(signal: AbortSignal): unknown {
+  if (signal.aborted) return signal.reason
+  return new Error('api-remotes: forwarded Remote event source ended')
+}
+
+/** Bridge one Cordis waterfall listener through the Gateway-owned pending event. */
+function forwardWaterfall(
+  queue: RemoteEventQueue,
+  event: string,
+  request: object,
+  context: TypertRemoteEventInvocation['context'],
+  next: () => unknown,
+): Promise<unknown> {
+  const settled = Promise.withResolvers<unknown>()
+  const dispatch: TypertRemoteEventInvocation = {
+    event,
+    request,
+    context,
+    resolve: (outcome: TypertRemoteEventOutcome) => {
+      if (outcome.kind === 'result') {
+        settled.resolve(outcome.value)
+        return
+      }
+      void Promise.resolve().then(next).then(settled.resolve, settled.reject)
+    },
+    reject: settled.reject,
+  }
+  if (!queue.push(dispatch)) void Promise.resolve().then(next).then(settled.resolve, settled.reject)
+  return settled.promise
+}
+
+/** Reject an allowlisted event whose runtime arguments are not lossless JSON data. */
+function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] {
+  for (const [index, arg] of args.entries()) {
+    if (!isJsonValue(arg)) {
+      throw new Error(`forwarded host event "${event}" argument ${String(index)} is not lossless JSON data`)
+    }
+  }
+  return args as JsonValue[]
+}

+ 20 - 18
packages/api/remotes/src/remote-events.ts

@@ -6,24 +6,26 @@
  * type-only.
  */
 
+import { SESSION_CONTROLLER_REMOTE_EVENTS } from '@deepseek-ai/dsh-api-session-controller/remote-events'
+import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol'
+
 /**
- * Host events this application forwards to consumers verbatim: no projection,
- * no redaction, no renaming. The wire name is the Host cordis event name and
- * the payload is its argument list, so this array is simultaneously the whole
- * control point over what a consumer can receive and the legal key set of
- * `ctx.remote.$on`. Forwarding one more event is an entry here and nothing
- * else.
+ * Host events this application forwards without renaming. The explicit mode is
+ * both the Host dispatch strategy and the legal key set of `ctx.remote.$on`.
  */
 export const API_REMOTE_FORWARDED_EVENTS = [
-  'agent-preset/selected',
-  'commands/change',
-  'credentials/reference-updated',
-  'cordis/request-run',
-  'cordis/request-run-resolved',
-  'cordis/dynamic-package',
-  'cordis/dynamic-retract',
-  'cordis/inspect-query',
-  'cordis/inspect-query-resolved',
-  'llm/adapters-updated',
-  'settings/document-updated',
-] as const
+  { event: 'agent-preset/selected', mode: 'emit' },
+  { event: 'approval/request', mode: 'waterfall' },
+  ...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })),
+  { event: 'commands/change', mode: 'emit' },
+  { event: 'credentials/reference-updated', mode: 'emit' },
+  { event: 'cordis/request-run', mode: 'emit' },
+  { event: 'cordis/request-run-resolved', mode: 'emit' },
+  { event: 'cordis/dynamic-package', mode: 'emit' },
+  { event: 'cordis/dynamic-retract', mode: 'emit' },
+  { event: 'cordis/inspect-query', mode: 'emit' },
+  { event: 'cordis/inspect-query-resolved', mode: 'emit' },
+  { event: 'llm/adapters-updated', mode: 'emit' },
+  { event: 'settings/document-updated', mode: 'emit' },
+  { event: 'user-questions/request', mode: 'waterfall' },
+] as const satisfies readonly TypertForwardableEventEntry[]

+ 1 - 1
packages/api/remotes/src/types.ts

@@ -12,7 +12,7 @@
 import type { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
 
 /** Type projection of the allowlist; the consumer and the Host read this one. */
-export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]
+export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event']
 
 declare module '@deepseek-ai/dsh-typert-protocol' {
   interface TypertRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}

+ 216 - 0
packages/api/remotes/tests/remote-events.host.spec.ts

@@ -0,0 +1,216 @@
+import { Context } from '@deepseek-ai/cordis'
+import type { Fiber } from '@deepseek-ai/cordis'
+import type {
+  TypertRemoteEventInvocation,
+  TypertRemoteEventSource,
+} from '@deepseek-ai/dsh-api-gateway'
+import { scopeTarget } from '@deepseek-ai/dsh-scope'
+import { describe, expect, it } from 'vitest'
+import { apply, inject } from '../src/index.ts'
+
+interface GatewayProbe {
+  source: TypertRemoteEventSource | undefined
+  removals: number
+  registerRemoteEvents(source: TypertRemoteEventSource): () => Promise<void>
+}
+
+async function setup(): Promise<{
+  readonly ctx: Context
+  readonly gateway: GatewayProbe
+  readonly fiber: Fiber
+}> {
+  const ctx = new Context()
+  const gateway: GatewayProbe = {
+    source: undefined,
+    removals: 0,
+    registerRemoteEvents(source) {
+      gateway.source = source
+      return async () => {
+        if (gateway.source !== source) return
+        gateway.source = undefined
+        gateway.removals += 1
+      }
+    },
+  }
+  ctx.reflect.provide('typertGateway', gateway)
+  const fiber = ctx.plugin({ inject: [...inject], apply })
+  await fiber
+  return { ctx, gateway, fiber }
+}
+
+function sourceOf(gateway: GatewayProbe): TypertRemoteEventSource {
+  if (gateway.source === undefined) throw new Error('fixture Gateway has no Remote event source')
+  return gateway.source
+}
+
+function emitRaw(ctx: Context, event: string, args: readonly unknown[]): void {
+  const emit = ctx.emit.bind(ctx) as unknown as (name: string, ...values: readonly unknown[]) => void
+  emit(event, ...args)
+}
+
+function waterfallRaw(
+  ctx: Context,
+  target: object,
+  event: string,
+  args: readonly unknown[],
+  next: () => Promise<unknown>,
+): Promise<unknown> {
+  const waterfall = ctx.waterfall.bind(ctx) as unknown as (
+    receiver: object,
+    name: string,
+    ...values: readonly unknown[]
+  ) => Promise<unknown>
+  return waterfall(target, event, ...args, next)
+}
+
+function invocationOf(value: unknown): TypertRemoteEventInvocation {
+  if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'context')) {
+    throw new Error('fixture did not receive a scoped Remote Event invocation')
+  }
+  return value as TypertRemoteEventInvocation
+}
+
+describe('Remote event Host source', () => {
+  it('gives each Client stream an independent allowlisted event queue', async () => {
+    const { ctx, gateway, fiber } = await setup()
+    const firstAbort = new AbortController()
+    const secondAbort = new AbortController()
+    const first = sourceOf(gateway)(firstAbort.signal)[Symbol.asyncIterator]()
+    const second = sourceOf(gateway)(secondAbort.signal)[Symbol.asyncIterator]()
+
+    emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1])
+    await expect(first.next()).resolves.toEqual({
+      done: false,
+      value: { event: 'settings/document-updated', args: ['ui-theme', 1] },
+    })
+    await expect(second.next()).resolves.toEqual({
+      done: false,
+      value: { event: 'settings/document-updated', args: ['ui-theme', 1] },
+    })
+
+    const firstDone = first.next()
+    firstAbort.abort(new Error('first Client disconnected'))
+    emitRaw(ctx, 'commands/change', [])
+    await expect(firstDone).resolves.toEqual({ done: true, value: undefined })
+    await expect(second.next()).resolves.toEqual({
+      done: false,
+      value: { event: 'commands/change', args: [] },
+    })
+
+    const secondDone = second.next()
+    secondAbort.abort(new Error('second Client disconnected'))
+    await expect(secondDone).resolves.toEqual({ done: true, value: undefined })
+
+    await fiber.dispose()
+    expect(gateway.source).toBeUndefined()
+    expect(gateway.removals).toBe(1)
+    await ctx.fiber.dispose()
+  })
+
+  it('rejects a non-JSON argument without poisoning the stream', async () => {
+    const { ctx, gateway } = await setup()
+    const abort = new AbortController()
+    const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
+    const pending = iterator.next()
+
+    expect(() => {
+      emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1n])
+    }).toThrow('argument 1 is not lossless JSON data')
+    emitRaw(ctx, 'settings/document-updated', ['ui-theme', 2])
+    await expect(pending).resolves.toEqual({
+      done: false,
+      value: { event: 'settings/document-updated', args: ['ui-theme', 2] },
+    })
+
+    const done = iterator.next()
+    abort.abort()
+    await expect(done).resolves.toEqual({ done: true, value: undefined })
+
+    const alreadyAborted = new AbortController()
+    alreadyAborted.abort()
+    await expect(sourceOf(gateway)(alreadyAborted.signal)[Symbol.asyncIterator]().next())
+      .resolves.toEqual({ done: true, value: undefined })
+    await ctx.fiber.dispose()
+  })
+
+  it('bridges scoped waterfall result, next delegation, and rejection', async () => {
+    const { ctx, gateway } = await setup()
+    const abort = new AbortController()
+    const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
+    const agentCtx = ctx.extend()
+    const agent = { ctx: agentCtx }
+    const target = scopeTarget(ctx, agent)
+    const request = { questions: [], agent }
+
+    const claimed = waterfallRaw(
+      ctx,
+      target,
+      'user-questions/request',
+      [request],
+      () => Promise.resolve('host fallback'),
+    )
+    const claimedDispatch = invocationOf((await iterator.next()).value)
+    expect(claimedDispatch).toMatchObject({
+      event: 'user-questions/request',
+      request,
+      context: { value: agentCtx, subject: agent },
+    })
+    claimedDispatch.resolve({ kind: 'result', value: 'client answer' })
+    await expect(claimed).resolves.toBe('client answer')
+
+    const delegated = waterfallRaw(
+      ctx,
+      target,
+      'user-questions/request',
+      [request],
+      () => Promise.resolve('host fallback'),
+    )
+    const delegatedDispatch = invocationOf((await iterator.next()).value)
+    delegatedDispatch.resolve({ kind: 'next' })
+    await expect(delegated).resolves.toBe('host fallback')
+
+    const rejection = Object.assign(new Error('the user cancelled ask_user_question'), {
+      code: 'ASK_CANCELLED',
+    })
+    const rejected = waterfallRaw(
+      ctx,
+      target,
+      'user-questions/request',
+      [request],
+      () => Promise.resolve('host fallback'),
+    )
+    const rejectedAssertion = expect(rejected).rejects.toBe(rejection)
+    const rejectedDispatch = invocationOf((await iterator.next()).value)
+    rejectedDispatch.reject(rejection)
+    await rejectedAssertion
+
+    const done = iterator.next()
+    abort.abort()
+    await expect(done).resolves.toEqual({ done: true, value: undefined })
+    await ctx.fiber.dispose()
+  })
+
+  it('rejects a queued scoped waterfall when its source is withdrawn', async () => {
+    const { ctx, gateway, fiber } = await setup()
+    const abort = new AbortController()
+    const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]()
+    const delivery = iterator.next()
+    const agent = { ctx: ctx.extend() }
+    const reason = new Error('forwarded event source removed')
+    const pending = waterfallRaw(
+      ctx,
+      scopeTarget(ctx, agent),
+      'user-questions/request',
+      [{ questions: [], agent }],
+      () => Promise.resolve('host fallback'),
+    )
+    const rejected = expect(pending).rejects.toBe(reason)
+
+    abort.abort(reason)
+
+    await rejected
+    await expect(delivery).resolves.toEqual({ done: true, value: undefined })
+    await fiber.dispose()
+    await ctx.fiber.dispose()
+  })
+})

+ 1 - 8
packages/client/connection/src/api-path.ts

@@ -1,14 +1,7 @@
 /**
  * The /api URL prefix — single source for both halves of the web transport.
- * The node half registers this prefix on the web server; both halves share the
- * event paths below for the browser WebSocket downlinks.
+ * The node half registers this prefix on the web server.
  */
 
 /** Route prefix owning every api request (`/api` and `/api/<anything>`). */
 export const API_PATH = '/api'
-
-/** Browser mux-frame WebSocket pathname. */
-export const MUX_EVENTS_PATH = `${API_PATH}/events.mux`
-
-/** Browser host-frame WebSocket pathname. */
-export const HOST_EVENTS_PATH = `${API_PATH}/events.host`

+ 3 - 8
packages/client/connection/src/api-request-trust.ts

@@ -13,15 +13,10 @@
  * belongs to the webserver config, and this fence is not an auth layer.
  */
 
-import type { IncomingHttpHeaders } from 'node:http'
 import { isLoopbackHostname } from './loopback-hostname.ts'
+import type { ConnectionTrustRequest } from './rpc.ts'
 
-/** The request facts the fence reads from either HTTP representation. */
-interface ApiTrustRequest {
-  headers: IncomingHttpHeaders | Headers
-}
-
-function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined {
+function header(headers: ConnectionTrustRequest['headers'], name: string): string | undefined {
   if (headers instanceof Headers) return headers.get(name) ?? undefined
   const value = headers[name]
   return typeof value === 'string' ? value : undefined
@@ -93,7 +88,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
  * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
  * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
  */
-export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
+export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHosts: readonly string[]): boolean {
   // Host fence (DNS-rebinding defense), applied to every request: the browser
   // fills Host from the URL it believes it is talking to, so a rebound page
   // carries the attacker's domain here even though the socket lands on this

+ 6 - 9
packages/client/connection/src/client/api.ts

@@ -1,35 +1,32 @@
-// Central contract re-export point: every contract import inside
-// web-runtime goes through this single file.
+// Central contract re-export point: every legacy API contract import inside
+// the Connection package goes through this browser-safe file.
 // Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
 // (zero Node deps, browser-safe); AbstractApiClient is the client boundary.
 // NEVER import the package root: it drags bootHost/cordis into the browser bundle.
 // The ./api and ./client subpath exports are the browser-safe channels.
 
 export type {
-  ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
-  ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
+  ApiProxy, HostApi,
   DirectoryEntry, DirectoryListing,
-  ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
+  ResponseValue,
   SkillsApi, SkillEntry,
   ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
-  ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
+  ModelReasoningEffort, ModelSelection,
   GoalsApi, GoalRef,
   SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
   CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
   SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
-  JobView,
 } 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,
+  ClientRequest, ServerResponse, RpcMessage,
 } from '@deepseek-ai/dsh-host-apiproxy/api'
 // transportError lives in the apiproxy api layer (beside RpcResult, its
 // subject); re-exported here so connection consumers keep one contract
 // entry point.
 export {
   RpcId,
-  SESSION_SEARCH_RESULT_LIMIT,
   transportError,
 } from '@deepseek-ai/dsh-host-apiproxy/api'
 export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'

+ 95 - 56
packages/client/connection/src/client/connection.ts

@@ -1,4 +1,4 @@
-import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
+import type { HostDescription, IApiClient } from './api.ts'
 
 /** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
  *  future `ctx.connection` plugin's Config). All fields optional; defaults below. */
@@ -9,18 +9,15 @@ export interface ConnectionConfig {
   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
-   *  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 covers stragglers. */
-  streamOpenTimeoutMs?: number
+  /** Maximum wait for the registered generation source's ready signal. */
+  generationReadyTimeoutMs?: number
 }
 
 const CONNECTION_DEFAULTS: Required<ConnectionConfig> = {
   backoffBaseMs: 500,
   backoffFactor: 2,
   backoffMaxMs: 10_000,
-  streamOpenTimeoutMs: 3_000,
+  generationReadyTimeoutMs: 3_000,
 }
 
 function sleep(ms: number, signal: AbortSignal): Promise<void> {
@@ -39,12 +36,9 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
  *  '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. */
+/** Connection-generation callbacks owned by API Gateway. */
 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. */
+  /** After the generation source is ready and host.describe succeeds, first connect included. */
   onConnected?: (description: HostDescription) => 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. */
@@ -52,11 +46,22 @@ export interface ConnectionSinks {
 }
 
 /**
- * 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.
+ * One long-lived source defining a Connection generation. The source must
+ * attach its incremental listeners before calling `ready`, then remain pending
+ * until the generation is lost or `signal` aborts.
+ * @param signal - cancellation for the current generation.
+ * @param ready - one-shot report that incremental delivery is attached.
+ * @returns a promise settling only when this generation ends or fails.
+ */
+export type ConnectionGenerationSource = (
+  signal: AbortSignal,
+  ready: () => void,
+) => Promise<void>
+
+/**
+ * Opens the registered generation source, 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).
+ * Sink exceptions do not kill the generation loop.
  */
 export class ConnectionController {
   private generation = 0
@@ -68,6 +73,7 @@ export class ConnectionController {
 
   constructor(
     private readonly api: IApiClient,
+    private readonly source: ConnectionGenerationSource,
     private readonly sinks: ConnectionSinks = {},
     config: ConnectionConfig = {},
   ) {
@@ -81,7 +87,7 @@ export class ConnectionController {
     void this.loop()
   }
 
-  /** Stop the loop and abort the current generation's streams. */
+  /** Stop the loop and abort the current generation source. */
   stop(): void {
     this.running = false
     this.current?.abort()
@@ -110,37 +116,58 @@ export class ConnectionController {
       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 }),
-      ])
+      let sourceReady = false
+      let resolveReady!: () => void
+      let rejectReady!: (error: Error) => void
+      let rejectSourceLost!: (error: Error) => void
+      const ready = new Promise<void>((resolve, reject) => {
+        resolveReady = resolve
+        rejectReady = reject
+      })
+      const sourceLost = new Promise<never>((_resolve, reject) => {
+        rejectSourceLost = reject
+      })
+      const reportReady = (): void => {
+        sourceReady = true
+        resolveReady()
+      }
 
       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)
+        void Promise.resolve()
+          .then(() => this.source(ac.signal, reportReady))
+          .then(
+            () => {
+              const error = new Error('connection generation ended')
+              if (!sourceReady) rejectReady(error)
+              rejectSourceLost(error)
+              settle()
+            },
+            (error: unknown) => {
+              const failure = error instanceof Error
+                ? error
+                : new Error('connection generation failed', { cause: error })
+              if (!sourceReady) rejectReady(failure)
+              rejectSourceLost(failure)
+              settle()
+            },
+          )
       })
 
       try {
-        // Strict readiness handshake: describe proves unary reachability, onOpen
-        // proves each physical stream is established 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()
-        const [description] = await Promise.all([
-          this.api.host.describe({}),
-          Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
+        // The source reports ready only after its incremental listeners exist;
+        // describe may complete in parallel, but consumers see neither result
+        // until both sides of the baseline-plus-increment handshake are ready.
+        const [description] = await Promise.race([
+          Promise.all([
+            this.api.host.describe({}, ac.signal),
+            waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal),
+          ]),
+          sourceLost,
         ])
-        timeout.abort()
         const descriptionResult = description.result
         if (!descriptionResult.ok) {
           throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`)
@@ -162,7 +189,7 @@ export class ConnectionController {
       if (!this.isRunning()) return
       this.emitState('reconnecting')
       this.attempt += 1
-      console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
+      console.warn(`[connection] connection lost, retry #${this.attempt}`)
       const idle = new AbortController()
       await sleep(this.backoffDelay(this.attempt), idle.signal)
     }
@@ -175,28 +202,40 @@ export class ConnectionController {
     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): void {
     try {
       fn()
     } catch (error) {
-      console.error('[web-runtime] connection sink threw:', error)
+      console.error('[connection] connection sink threw:', error)
     }
   }
 }
+
+/** Await source readiness without letting a stalled carrier wedge startup forever. */
+function waitForReady(ready: Promise<void>, timeoutMs: number, signal: AbortSignal): Promise<void> {
+  return new Promise<void>((resolve, reject) => {
+    let settled = false
+    const timeout = setTimeout(() => {
+      finish(new Error(`connection generation was not ready within ${String(timeoutMs)}ms`))
+    }, timeoutMs)
+    const aborted = (): void => {
+      finish(new Error('connection generation aborted', { cause: signal.reason }))
+    }
+    const finish = (error?: Error): void => {
+      if (settled) return
+      settled = true
+      clearTimeout(timeout)
+      signal.removeEventListener('abort', aborted)
+      if (error === undefined) resolve()
+      else reject(error)
+    }
+    signal.addEventListener('abort', aborted, { once: true })
+    void ready.then(
+      () => { finish() },
+      (error: unknown) => {
+        finish(error as Error)
+      },
+    )
+  })
+}

Разлика између датотеке није приказан због своје велике величине
+ 985 - 382
packages/client/connection/src/client/fixture.ts


+ 62 - 23
packages/client/connection/src/client/index.ts

@@ -1,30 +1,44 @@
 /**
  * Browser wire client. The plugin selects fixture or HTTP transport, provides
- * the shared API client, and lets the runtime object layer start the stream
- * controller with its sinks.
+ * the shared API client, and lets API Gateway own the connection loop.
  */
 import type { Context } from '@deepseek-ai/cordis'
 import type { HostDescription, IApiClient } from './api.ts'
-import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
+import {
+  ConnectionController,
+  type ConnectionConfig,
+  type ConnectionGenerationSource,
+  type ConnectionSinks,
+  type ConnectionState,
+} from './connection.ts'
 import { FixtureApiClient } from './fixture.ts'
 import { WebApiClient } from './web-api-client.ts'
-import { createWebConnectionRpc, type RpcFetch } from './rpc.ts'
+import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts'
 import { isLoopbackHostname } from '../loopback-hostname.ts'
 import type { ClientConnectionRpc } from '../rpc.ts'
 
+declare module '@deepseek-ai/cordis' {
+  interface Events {
+    /**
+     * A connection generation was established. Wire-derived caches must
+     * repull; long-lived streams own their own resume and baseline lifecycle.
+     * @mode emit
+     */
+    'connection/reset'(): void
+  }
+}
+
 // ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
 export type {
-  ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
-  ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
+  ApiProxy, HostApi,
   DirectoryEntry, DirectoryListing,
-  ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
+  ToolCallView, ToolResultView,
   SkillsApi, SkillEntry,
   ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
-  MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
+  MessageId, ModelReasoningEffort, ModelSelection,
   SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
-  JobView,
   RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
-  ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
+  ClientRequest, ServerResponse, RpcMessage,
   HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
   GoalsApi, GoalRef,
   SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
@@ -38,8 +52,10 @@ export {
 
 // Connection loop types are public through ConnectionHandle.start; the
 // controller remains package-internal.
-export type { ConnectionConfig, ConnectionSinks, ConnectionState }
-export type { ClientConnectionRpc } from '../rpc.ts'
+export type { ConnectionConfig, ConnectionGenerationSource, ConnectionSinks, ConnectionState }
+export type {
+  ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult,
+} from '../rpc.ts'
 export type { RpcFetch } from './rpc.ts'
 
 /** Observable Host description published by each completed connection handshake. */
@@ -64,6 +80,8 @@ export interface ClientTransportHooks {
   createApiClient(): IApiClient
   /** Transport for generic unary RPC channels (the Typert gateway). */
   fetch: RpcFetch
+  /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */
+  openStream?: RpcStreamOpen
   /**
    * Bundle transport for the module system, present when the carrier also owns
    * bundle bytes (the worker tunnel). Absent in the served web app, whose
@@ -87,9 +105,9 @@ interface ClientTransportGlobal {
 }
 
 /**
- * The ctx.connection service API: the API client plus a one-shot
- * controller starter (the runtime plugin supplies sinks when its object layer
- * is ready — connection stays consumer-agnostic).
+ * The ctx.connection service API: the API client plus a one-shot controller
+ * starter. API Gateway supplies generation readiness and reset callbacks;
+ * Connection stays independent of downstream domain state.
  */
 export interface ConnectionHandle {
   /** Shared api client (fixture or real, decided at boot from the page URL). */
@@ -105,10 +123,16 @@ export interface ConnectionHandle {
   /** Generic logical RPC channels over the same Connection transport. */
   readonly rpc: ClientConnectionRpc
   /**
-   * 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.
+   * Register the sole source defining Host generations. The source reports
+   * ready only after its incremental listeners are attached.
+   * @param source - long-lived generation source owned by the push carrier.
+   * @returns disposer withdrawing the source and stopping an active loop.
+   */
+  registerGenerationSource(source: ConnectionGenerationSource): () => void
+  /**
+   * Start the connect/reconnect loop with the consumer's state callbacks.
+   * API Gateway owns the loop; a second call throws.
+   * @param sinks - connection-state callbacks.
    * @param config - reconnect/backoff tunables.
    * @returns stop handle for the loop.
    */
@@ -125,8 +149,10 @@ export function apply(ctx: Context): void {
   const fixtureClient = fixture ? new FixtureApiClient() : undefined
   const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
   const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient()
-  const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch)
+  const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream)
   let started = false
+  let generationSource: ConnectionGenerationSource | undefined
+  let controller: ConnectionController | undefined
   let description: HostDescription | undefined
   const descriptionListeners = new Set<() => void>()
   const publishDescription = (next: HostDescription | undefined): void => {
@@ -136,7 +162,7 @@ export function apply(ctx: Context): void {
       try {
         listener()
       } catch (error) {
-        console.error('[web-runtime] host-description listener threw:', error)
+        console.error('[connection] host-description listener threw:', error)
       }
     }
   }
@@ -151,10 +177,23 @@ export function apply(ctx: Context): void {
       },
     },
     rpc,
+    registerGenerationSource(source) {
+      if (generationSource !== undefined) {
+        throw new Error('connection: a generation source is already registered')
+      }
+      generationSource = source
+      return () => {
+        if (generationSource !== source) return
+        generationSource = undefined
+        controller?.stop()
+        publishDescription(undefined)
+      }
+    },
     start(sinks, config) {
       if (started) throw new Error('connection: the stream loop is already owned by another consumer')
+      if (generationSource === undefined) throw new Error('connection: no generation source is registered')
       started = true
-      const controller = new ConnectionController(api, {
+      controller = new ConnectionController(api, generationSource, {
         ...sinks,
         onConnected: (next) => {
           publishDescription(next)
@@ -173,7 +212,7 @@ export function apply(ctx: Context): void {
       controller.start()
       return {
         stop: () => {
-          controller.stop()
+          controller?.stop()
           publishDescription(undefined)
         },
       }

+ 56 - 4
packages/client/connection/src/client/rpc.ts

@@ -2,10 +2,10 @@
 
 import {
   RpcId,
-  serverResponseSchema,
   type ClientRequest,
+  type RpcId as RpcIdType,
 } from '@deepseek-ai/dsh-host-apiproxy/api'
-import type { ClientConnectionRpc } from '../rpc.ts'
+import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts'
 import { randomUuid } from './random-uuid.ts'
 
 const INTERNAL_BASE = 'http://dsh.internal'
@@ -15,12 +15,20 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
 /** Transport this caller posts through; same signature as the global `fetch`. */
 export type RpcFetch = (input: URL, init: RequestInit) => Promise<Response>
 
+/** Worker-local opener for decoded Gateway Remote streams. */
+export type RpcStreamOpen = (
+  endpoint: string,
+  payload: unknown,
+  signal: AbortSignal,
+) => AsyncIterable<unknown>
+
 /**
  * Create the browser-backed generic RPC caller.
  * @param doFetch - transport override; defaults to the page's global fetch.
+ * @param openStream - optional worker-local Gateway stream carrier.
  * @returns caller that owns request correlation and response-envelope validation.
  */
-export function createWebConnectionRpc(doFetch?: RpcFetch): ClientConnectionRpc {
+export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStreamOpen): ClientConnectionRpc {
   const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init))
   return {
     async call(channel, endpoint, payload, signal) {
@@ -44,13 +52,57 @@ export function createWebConnectionRpc(doFetch?: RpcFetch): ClientConnectionRpc
       if (!response.ok) {
         throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
       }
-      const full = serverResponseSchema.parse(await response.json())
+      const full = parseConnectionResponse(await response.json())
       if (full.rpcId !== rpcId) {
         throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`)
       }
       return full.result
     },
+    ...openStream === undefined ? {} : {
+      open(channel, endpoint, payload, signal) {
+        assertTarget(channel, endpoint)
+        if (channel !== '/api') {
+          throw new Error(`connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`)
+        }
+        return openStream(endpoint, payload, signal)
+      },
+    },
+  }
+}
+
+function parseConnectionResponse(value: unknown): {
+  readonly rpcId: RpcIdType
+  readonly result: ConnectionRpcResult<unknown>
+} {
+  if (!isRecord(value) || value.type !== 'server-response' || typeof value.rpcId !== 'string') {
+    throw new TypeError('connection: invalid server-response envelope')
+  }
+  const result = value.result
+  if (!isRecord(result)) throw new TypeError('connection: invalid server-response result')
+  if (result.ok === true) {
+    return {
+      rpcId: RpcId(value.rpcId),
+      result: { ok: true, value: result.value },
+    }
+  }
+  if (result.ok !== false || !isRecord(result.error)) {
+    throw new TypeError('connection: invalid server-response result')
   }
+  const error = result.error
+  if (typeof error.code !== 'string' || typeof error.message !== 'string' || !isRecord(error.details)) {
+    throw new TypeError('connection: invalid server-response failure')
+  }
+  return {
+    rpcId: RpcId(value.rpcId),
+    result: {
+      ok: false,
+      error: { code: error.code, message: error.message, details: error.details },
+    },
+  }
+}
+
+function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
 }
 
 function resolveBase(): string {

+ 2 - 83
packages/client/connection/src/client/web-api-client.ts

@@ -1,91 +1,10 @@
-/** Browser API carrier: HTTP upstream plus one WebSocket per downstream event stream. */
+/** Browser API carrier for unary HTTP calls. */
 
-import type { ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest } from './api.ts'
 import { AbstractApiClient } from './api.ts'
-import { hostFrameSchema, muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema'
-import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema'
-import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../api-path.ts'
 
-type SocketItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' }
-type Parser<F> = { parse(value: unknown): F }
-
-/** Browser platform subclass: unary/respond use fetch; mux/host use downlink-only WebSockets. */
+/** Browser platform subclass supplying fetch for unary calls. */
 export class WebApiClient extends AbstractApiClient {
   protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
     return globalThis.fetch(input, init)
   }
-
-  protected override openMux(
-    _payload: Parameters<ApiProxy['events']['mux']>[0]['payload'],
-    signal: AbortSignal,
-    onOpen?: () => void,
-  ): AsyncIterable<RpcRequest<MuxFrame>> {
-    return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen)
-  }
-
-  protected override openHost(
-    _payload: Parameters<ApiProxy['events']['host']>[0]['payload'],
-    signal: AbortSignal,
-    onOpen?: () => void,
-  ): AsyncIterable<RpcRequest<HostFrame>> {
-    return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen)
-  }
-
-  private async *readWebSocket<F extends MuxFrame | HostFrame>(
-    path: string,
-    signal: AbortSignal,
-    frameSchema: Parser<F>,
-    onOpen?: () => void,
-  ): AsyncGenerator<RpcRequest<F>> {
-    const url = new URL(path, this.resolveBase())
-    url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
-    const socket = new WebSocket(url)
-    const inbox: SocketItem<F>[] = []
-    let wake: (() => void) | undefined
-    const enqueue = (item: SocketItem<F>): void => {
-      inbox.push(item)
-      wake?.()
-      wake = undefined
-    }
-    const handleOpen = (): void => { onOpen?.() }
-    const handleMessage = (event: MessageEvent): void => {
-      let full: ServerRequest
-      let frame: F
-      try {
-        if (typeof event.data !== 'string') throw new Error('binary WebSocket frame')
-        full = serverRequestSchema.parse(JSON.parse(event.data))
-        frame = frameSchema.parse(full.payload)
-      } catch (error) {
-        console.error(`[client-connection] dropping malformed WebSocket frame on ${path}:`, error)
-        return
-      }
-      this.onEnvelope(full)
-      enqueue({ kind: 'frame', envelope: { rpcId: full.rpcId, payload: frame } })
-    }
-    const handleClose = (): void => { enqueue({ kind: 'end' }) }
-    const handleAbort = (): void => {
-      if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) socket.close()
-    }
-    socket.addEventListener('open', handleOpen)
-    socket.addEventListener('message', handleMessage)
-    socket.addEventListener('close', handleClose, { once: true })
-    signal.addEventListener('abort', handleAbort, { once: true })
-    if (signal.aborted) handleAbort()
-    try {
-      while (true) {
-        while (inbox.length > 0) {
-          const item = inbox.shift() as SocketItem<F>
-          if (item.kind === 'end') return
-          yield item.envelope
-        }
-        await new Promise<void>((resolve) => { wake = resolve })
-      }
-    } finally {
-      signal.removeEventListener('abort', handleAbort)
-      socket.removeEventListener('open', handleOpen)
-      socket.removeEventListener('message', handleMessage)
-      socket.removeEventListener('close', handleClose)
-      handleAbort()
-    }
-  }
 }

+ 4 - 4
packages/client/connection/src/http-bridge.ts

@@ -23,7 +23,7 @@ export interface FetchHandler {
 
 /**
  * Bridge one node:http request to the fetch-shaped handler (client close
- * aborts; SSE bodies stream out chunk by chunk).
+ * aborts; response bodies stream out chunk by chunk).
  * @param req - incoming node:http request (fully read before dispatch).
  * @param res - node:http response the bridge writes and owns to completion.
  * @param apiHandler - fetch-shaped API carrier the request is dispatched to.
@@ -38,8 +38,8 @@ export async function bridge(
   const abort = new AbortController()
   // Client-disconnect detection MUST hang off the response, not the request:
   // since Node 16, IncomingMessage 'close' fires as soon as the request body is
-  // fully consumed (immediately for a bodyless GET), which would abort every SSE
-  // stream right after open. ServerResponse 'close' fires on connection teardown;
+  // fully consumed (immediately for a bodyless GET), which would abort a
+  // streaming response right after open. ServerResponse 'close' fires on connection teardown;
   // writableEnded distinguishes a normal end() from the client going away.
   res.on('close', () => {
     if (!res.writableEnded) abort.abort()
@@ -80,7 +80,7 @@ export async function bridge(
   }
   for await (const chunk of response.body) {
     // Backpressure: a false return means the socket buffer is full — wait for drain
-    // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also
+    // instead of buffering unboundedly (slow or suspended consumers). 'close' also
     // resolves so a mid-wait disconnect can't park this loop forever; the close
     // handler above aborts the handler stream, which then ends the iteration.
     if (!res.write(chunk)) {

+ 7 - 32
packages/client/connection/src/index.ts

@@ -3,25 +3,27 @@ import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import type {} from '@deepseek-ai/dsh-attachment'
 // Activates the webServer Context merge used below.
-import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
+import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
 import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
-import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
+import { API_PATH } from './api-path.ts'
 import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts'
 import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
 import { HostConnectionService } from './rpc-host.ts'
-import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
 
 export type {
   ConnectionRpcAuthority,
   ConnectionRpcEndpointMatcher,
+  ConnectionRpcFailure,
   ConnectionRpcHandler,
   ConnectionRpcHandlerOptions,
+  ConnectionRpcResult,
+  ConnectionTrustRequest,
   HostConnectionHandle,
   HostConnectionRpc,
 } from './rpc.ts'
 export { HostConnectionService } from './rpc-host.ts'
 
-export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
+export { API_PATH } from './api-path.ts'
 
 /** Stable Cordis plugin name. */
 export const name = 'client-connection'
@@ -147,12 +149,6 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
         && !isTrustedApiRequest(request, [])) {
         return new Response('forbidden', { status: 403 })
       }
-      if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
-        return new Response('upgrade required', {
-          status: 426,
-          headers: { connection: 'Upgrade', upgrade: 'websocket' },
-        })
-      }
       const apiProxy = ctx.get('apiProxy')
       if (apiProxy === undefined) return new Response('not found', { status: 404 })
       return toFetchHandler(apiProxy).fetch(request)
@@ -171,26 +167,5 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
     },
   }
   ctx.effect(() => ctx.webServer.register(route), 'client-connection: /api route')
-  ctx.inject(['apiProxy'], (apiCtx) => {
-    assertImageBodyCapacity(apiCtx, maxRequestBodyBytes)
-    const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
-    const registerDownlink = (
-      path: string,
-      handle: WebUpgradeRoute['handler'],
-    ): void => {
-      apiCtx.effect(() => apiCtx.webServer.registerUpgrade({
-        path,
-        handler: (req, socket, head) => {
-          if (!isTrustedApiRequest(req, trustedHosts)) {
-            rejectWebSocketUpgrade(socket)
-            return
-          }
-          return handle(req, socket, head)
-        },
-      }), `client-connection: ${path} WebSocket`)
-    }
-    apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
-    registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
-    registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
-  })
+  ctx.inject(['apiProxy'], (apiCtx) => { assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) })
 }

+ 16 - 3
packages/client/connection/src/rpc-host.ts

@@ -9,7 +9,6 @@ import {
   type RpcError,
   type RpcErrorDetailsMap,
   type RpcId as RpcIdType,
-  type ServerResponse as RpcServerResponse,
 } from '@deepseek-ai/dsh-host-apiproxy/api'
 import { bridge, type FetchHandler } from './http-bridge.ts'
 import { isTrustedApiRequest } from './api-request-trust.ts'
@@ -18,6 +17,9 @@ import type {
   ConnectionRpcEndpointMatcher,
   ConnectionRpcHandler,
   ConnectionRpcHandlerOptions,
+  ConnectionRpcResult,
+  ConnectionRpcAuthority,
+  ConnectionTrustRequest,
   HostConnectionHandle,
   HostConnectionRpc,
 } from './rpc.ts'
@@ -32,6 +34,12 @@ interface ConnectionRpcInterceptor {
   readonly options: ConnectionRpcHandlerOptions
 }
 
+interface ConnectionServerResponse {
+  readonly type: 'server-response'
+  readonly rpcId: RpcIdType
+  readonly result: ConnectionRpcResult<unknown>
+}
+
 declare module '@deepseek-ai/cordis' {
   interface Context {
     /** Host Connection transport and RPC registrations. */
@@ -62,6 +70,11 @@ export class HostConnectionService extends Service implements HostConnectionHand
     }
   }
 
+  /** Apply the existing configured request trust policy to a sibling Web route. */
+  isTrustedRequest(request: ConnectionTrustRequest, authority: ConnectionRpcAuthority): boolean {
+    return isTrustedApiRequest(request, authority === 'loopback' ? [] : this.trustedHosts)
+  }
+
   /**
    * Compose one shared-channel Fetch handler from its interceptor and fallback.
    * @param channel - shared channel mounted by Connection.
@@ -212,8 +225,8 @@ function errorResponse(rpcId: RpcIdType, error: RpcError): Response {
   return fullResponse(rpcId, { ok: false, error })
 }
 
-function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response {
-  const body: RpcServerResponse = { type: 'server-response', rpcId, result }
+function fullResponse(rpcId: RpcIdType, result: ConnectionRpcResult<unknown>): Response {
+  const body: ConnectionServerResponse = { type: 'server-response', rpcId, result }
   return Response.json(body)
 }
 

+ 44 - 4
packages/client/connection/src/rpc.ts

@@ -1,6 +1,22 @@
 /** Generic unary RPC contracts shared by the Host and Client Connection halves. */
 
-import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
+/** Carrier-neutral failure returned by one logical RPC endpoint. */
+export interface ConnectionRpcFailure {
+  readonly code: string
+  readonly message: string
+  readonly details: object
+}
+
+/** Carrier-neutral result returned by one logical RPC endpoint. */
+export type ConnectionRpcResult<T> =
+  | { readonly ok: true; readonly value: T }
+  | { readonly ok: false; readonly error: ConnectionRpcFailure }
+
+/** HTTP request facts consumed by the existing browser trust fence. */
+export interface ConnectionTrustRequest {
+  /** Request headers supplied by either the Fetch or node:http representation. */
+  readonly headers: Headers | Readonly<Record<string, string | readonly string[] | undefined>>
+}
 
 /** Trust fence applied before a Host RPC channel reaches its handler. */
 export type ConnectionRpcAuthority = 'trusted-host' | 'loopback'
@@ -16,7 +32,7 @@ export type ConnectionRpcHandler = (
   endpoint: string,
   payload: unknown,
   signal: AbortSignal,
-) => Promise<RpcResult<unknown>>
+) => Promise<ConnectionRpcResult<unknown>>
 
 /** Synchronous ownership test for one endpoint on a shared RPC channel. */
 export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
@@ -56,6 +72,14 @@ export interface HostConnectionRpc {
 export interface HostConnectionHandle {
   /** Generic RPC channel registry. */
   readonly rpc: HostConnectionRpc
+
+  /**
+   * Apply Connection's configured browser trust policy to another Web route.
+   * @param request - request headers from the HTTP or upgrade request.
+   * @param authority - configured trusted hosts or loopback-only policy.
+   * @returns whether the route may accept the request.
+   */
+  isTrustedRequest(request: ConnectionTrustRequest, authority: ConnectionRpcAuthority): boolean
 }
 
 /** Client caller for logical RPC channels carried by the current transport. */
@@ -66,12 +90,28 @@ export interface ClientConnectionRpc {
    * @param endpoint - channel-relative endpoint such as `goals/create`.
    * @param payload - channel-owned request payload.
    * @param signal - optional caller cancellation.
-   * @returns the existing RPC success/error result; correlation stays inside Connection.
+   * @returns the endpoint-owned success/error result; correlation stays inside Connection.
    */
   call(
     channel: string,
     endpoint: string,
     payload: unknown,
     signal?: AbortSignal,
-  ): Promise<RpcResult<unknown>>
+  ): Promise<ConnectionRpcResult<unknown>>
+
+  /**
+   * Open an in-process logical stream when the selected carrier supplies one.
+   * Browser transports omit this method; API Gateway owns their WebSocket mux.
+   * @param channel - absolute logical channel such as `/api`.
+   * @param endpoint - channel-relative endpoint such as `session/follow`.
+   * @param payload - channel-owned request payload.
+   * @param signal - caller cancellation for this logical stream.
+   * @returns decoded stream values from the in-process carrier.
+   */
+  readonly open?: (
+    channel: string,
+    endpoint: string,
+    payload: unknown,
+    signal: AbortSignal,
+  ) => AsyncIterable<unknown>
 }

+ 0 - 153
packages/client/connection/src/websocket-downlink.ts

@@ -1,153 +0,0 @@
-/** Host-side WebSocket carrier for the two server-to-browser event streams. */
-
-import { randomUUID } from 'node:crypto'
-import type { IncomingMessage } from 'node:http'
-import type { Duplex } from 'node:stream'
-import WebSocket, { WebSocketServer } from 'ws'
-import type {
-  ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest,
-} from '@deepseek-ai/dsh-host-apiproxy/api'
-import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
-
-type Frame = MuxFrame | HostFrame
-
-function serverRequest(frame: RpcRequest<Frame>): ServerRequest {
-  return {
-    type: 'server-request',
-    rpcId: frame.rpcId,
-    method: frame.payload.type,
-    payload: frame.payload,
-  }
-}
-
-function send(socket: WebSocket, frame: RpcRequest<Frame>): Promise<void> {
-  return new Promise((resolve, reject) => {
-    if (socket.readyState !== WebSocket.OPEN) {
-      reject(new Error('websocket downlink closed before frame delivery'))
-      return
-    }
-    socket.send(JSON.stringify(serverRequest(frame)), (error) => {
-      if (error) reject(error)
-      else resolve()
-    })
-  })
-}
-
-function failureFrame(error: unknown): RpcRequest<Frame> {
-  return {
-    rpcId: RpcId(randomUUID()),
-    payload: {
-      type: 'stream/error',
-      error: { code: 'internal', message: String(error), details: {} },
-    },
-  }
-}
-
-/**
- * Owns WebSocket negotiation and frame pumping for the connection plugin's
- * two downlinks. Client messages are a protocol violation: upstream traffic
- * remains on HTTP.
- */
-export class WebSocketDownlinks {
-  private readonly server = new WebSocketServer({ noServer: true })
-  private readonly pumps = new Set<Promise<void>>()
-
-  /** @param api - host API supplying the typed event streams. */
-  constructor(private readonly api: ApiProxy) {}
-
-  /**
-   * Upgrade one socket and pump the mux stream until either side closes.
-   * @param req - HTTP upgrade request.
-   * @param socket - Raw socket transferred by the HTTP server.
-   * @param head - Bytes already read after the upgrade headers.
-   */
-  handleMux(req: IncomingMessage, socket: Duplex, head: Buffer): void {
-    this.upgrade(req, socket, head, signal => this.api.events.mux({
-      rpcId: RpcId(randomUUID()),
-      payload: {},
-    }, signal))
-  }
-
-  /**
-   * Upgrade one socket and pump the host stream until either side closes.
-   * @param req - HTTP upgrade request.
-   * @param socket - Raw socket transferred by the HTTP server.
-   * @param head - Bytes already read after the upgrade headers.
-   */
-  handleHost(req: IncomingMessage, socket: Duplex, head: Buffer): void {
-    this.upgrade(req, socket, head, signal => this.api.events.host({
-      rpcId: RpcId(randomUUID()),
-      payload: {},
-    }, signal))
-  }
-
-  /**
-   * Terminate owned sockets and await the no-server acceptor plus frame pumps.
-   * @returns A promise resolving after every socket and source iterator stops.
-   */
-  async close(): Promise<void> {
-    for (const socket of this.server.clients) socket.terminate()
-    await new Promise<void>((resolve, reject) => {
-      this.server.close((error) => {
-        if (error === undefined) resolve()
-        else reject(error)
-      })
-    })
-    await Promise.all(this.pumps)
-  }
-
-  private upgrade<F extends Frame>(
-    req: IncomingMessage,
-    socket: Duplex,
-    head: Buffer,
-    open: (signal: AbortSignal) => AsyncIterable<RpcRequest<F>>,
-  ): void {
-    this.server.handleUpgrade(req, socket, head, (websocket) => {
-      const abort = new AbortController()
-      websocket.once('close', () => { abort.abort() })
-      websocket.once('error', () => { abort.abort() })
-      websocket.once('message', () => {
-        websocket.close(1008, 'downlink only')
-      })
-      const pump = this.pump(websocket, open(abort.signal), abort)
-      this.pumps.add(pump)
-      void pump.then(() => { this.pumps.delete(pump) })
-    })
-  }
-
-  private async pump<F extends Frame>(
-    socket: WebSocket,
-    frames: AsyncIterable<RpcRequest<F>>,
-    abort: AbortController,
-  ): Promise<void> {
-    try {
-      for await (const frame of frames) await send(socket, frame)
-    } catch (error) {
-      if (!abort.signal.aborted) {
-        try {
-          await send(socket, failureFrame(error))
-        } catch {
-          // Socket loss won the race; no downstream remains to receive the failure frame.
-        }
-      }
-    } finally {
-      abort.abort()
-      if (socket.readyState === WebSocket.OPEN) socket.close()
-    }
-  }
-}
-
-/**
- * Reject an untrusted upgrade before protocol negotiation.
- * @param socket - Raw HTTP socket that remains owned by the caller.
- */
-export function rejectWebSocketUpgrade(socket: Duplex): void {
-  socket.end([
-    'HTTP/1.1 403 Forbidden',
-    'Connection: close',
-    'Content-Type: text/plain; charset=utf-8',
-    'Content-Length: 9',
-    '',
-    'forbidden',
-  ].join('\r\n'))
-}

+ 151 - 141
packages/client/connection/tests/client-apply.client.spec.ts

@@ -1,58 +1,56 @@
 /**
  * Connection plugin browser-half apply: ctx.connection handle mounting, mode
- * selection off the page URL, and the single-consumer stream-loop ownership.
+ * selection off the page URL, and single-consumer connection-loop ownership.
  */
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { apply, type ConnectionHandle } from '../src/client/index.ts'
-import type { RpcMessage } from '../src/client/api.ts'
-import { RpcId } from '../src/client/api.ts'
+import {
+  apply,
+  type ClientTransportHooks,
+  type ConnectionGenerationSource,
+  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?: { hostname: string; search: string; origin?: string } }
-type WebSocketGlobal = { WebSocket?: typeof WebSocket }
-
-const originalWebSocket = globalThis.WebSocket
-const sockets: FakeWebSocket[] = []
-
-class FakeWebSocket extends EventTarget {
-  static readonly CONNECTING = 0
-  static readonly OPEN = 1
-  static readonly CLOSING = 2
-  static readonly CLOSED = 3
+type Win = {
+  location?: { hostname: string; search: string; origin?: string }
+  __DSH_TRANSPORT__?: ClientTransportHooks
+}
 
-  readonly url: string
-  readyState = FakeWebSocket.CONNECTING
+afterEach(() => {
+  delete (globalThis as Win).location
+  delete (globalThis as Win).__DSH_TRANSPORT__
+})
 
-  constructor(url: string | URL) {
-    super()
-    this.url = String(url)
-    sockets.push(this)
-    queueMicrotask(() => {
-      if (this.readyState !== FakeWebSocket.CONNECTING) return
-      this.readyState = FakeWebSocket.OPEN
-      this.dispatchEvent(new Event('open'))
-    })
-  }
+class GenerationProbe {
+  private readonly active = new Set<() => void>()
 
-  close(): void {
-    if (this.readyState === FakeWebSocket.CLOSED) return
-    this.readyState = FakeWebSocket.CLOSED
-    this.dispatchEvent(new Event('close'))
-  }
+  readonly source: ConnectionGenerationSource = (signal, ready) => new Promise<void>((resolve) => {
+    let settled = false
+    const finish = (): void => {
+      if (settled) return
+      settled = true
+      signal.removeEventListener('abort', finish)
+      this.active.delete(finish)
+      resolve()
+    }
+    this.active.add(finish)
+    signal.addEventListener('abort', finish, { once: true })
+    ready()
+    if (signal.aborted) finish()
+  })
 
-  receive(data: unknown): void {
-    this.dispatchEvent(new MessageEvent('message', { data }))
+  end(): void {
+    for (const finish of [...this.active]) finish()
   }
 }
 
-afterEach(() => {
-  delete (globalThis as Win).location
-  sockets.length = 0
-  if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
-  else globalThis.WebSocket = originalWebSocket
-})
+function installGeneration(handle: ConnectionHandle): GenerationProbe {
+  const probe = new GenerationProbe()
+  handle.registerGenerationSource(probe.source)
+  return probe
+}
 
 async function mount(): Promise<ConnectionHandle> {
   const ctx = new Context()
@@ -84,9 +82,33 @@ describe('connection client apply', () => {
     expect((await mount()).isLoopback).toBe(false)
   })
 
-  it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
+  it('requires one generation source and ignores a stale source disposer', async () => {
     ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
     const handle = await mount()
+    const first = new GenerationProbe()
+    const second = new GenerationProbe()
+
+    expect(() => handle.start({})).toThrow('no generation source is registered')
+    const unregisterFirst = handle.registerGenerationSource(first.source)
+    expect(() => { handle.registerGenerationSource(second.source) })
+      .toThrow('a generation source is already registered')
+    unregisterFirst()
+    const unregisterSecond = handle.registerGenerationSource(second.source)
+    unregisterFirst()
+
+    const loop = handle.start({})
+    await vi.waitFor(() => {
+      expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
+    })
+    unregisterSecond()
+    expect(handle.hostDescription.getSnapshot()).toBeUndefined()
+    loop.stop()
+  })
+
+  it('start() hands out one loop, rejects a second consumer, and stop() aborts the generation', async () => {
+    ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
+    const handle = await mount()
+    installGeneration(handle)
     const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
     const descriptions: Array<boolean | undefined> = []
     const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') })
@@ -114,6 +136,7 @@ describe('connection client apply', () => {
   it('does not announce a generation synchronously stopped by a description subscriber', async () => {
     ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
     const handle = await mount()
+    installGeneration(handle)
     const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
     let sawDescription = false
     const stopDescription = handle.hostDescription.subscribe(() => {
@@ -137,6 +160,7 @@ describe('connection client apply', () => {
   it('retracts the host description while reconnecting and republishes the next generation', async () => {
     ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
     const handle = await mount()
+    const generation = installGeneration(handle)
     const descriptions: Array<boolean | undefined> = []
     const reconnectSnapshots: Array<boolean | undefined> = []
     const stopDescription = handle.hostDescription.subscribe(() => {
@@ -149,16 +173,12 @@ describe('connection client apply', () => {
           reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath)
         }
       },
-    }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 })
+    }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 })
     try {
       await vi.waitFor(() => {
         expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
       })
-      const timing = (globalThis as Record<string, unknown>).__fxTiming as
-        | { breakStreams(): void }
-        | undefined
-      if (timing === undefined) throw new Error('fixture timing hooks missing')
-      timing.breakStreams()
+      generation.end()
 
       await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) })
       await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) })
@@ -170,7 +190,7 @@ describe('connection client apply', () => {
     }
   })
 
-  it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {
+  it('WebApiClient keeps unary calls on globalThis.fetch', async () => {
     ;(globalThis as Win).location = { hostname: 'localhost', search: '' }
     const handle = await mount()
     const original = globalThis.fetch
@@ -182,103 +202,10 @@ describe('connection client apply', () => {
     try {
       // Schema rejection is fine — the transport hop is the assertion.
       await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
-      await handle.api.respond({
-        type: 'client-response',
-        rpcId: RpcId('response-over-http'),
-        result: { ok: true, value: {} },
-      }).catch(() => undefined)
     } finally {
       globalThis.fetch = original
     }
     expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true)
-    expect(seen.some(u => u.includes('/api/respond'))).toBe(true)
-  })
-
-  it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => {
-    ;(globalThis as Win).location = {
-      hostname: 'localhost', search: '', origin: 'http://localhost:3080',
-    }
-    ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
-    const fetch = vi.spyOn(globalThis, 'fetch')
-    const client = (await mount()).api as WebApiClient
-    const envelopes: RpcMessage[][] = []
-    client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) })
-    const opened: string[] = []
-    const muxAbort = new AbortController()
-    const hostAbort = new AbortController()
-    const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]()
-    const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]()
-    const muxFrame = mux.next()
-    const hostFrame = host.next()
-    await vi.waitFor(() => { expect(sockets).toHaveLength(2) })
-    expect(sockets.map(socket => socket.url)).toEqual([
-      'ws://localhost:3080/api/events.mux',
-      'ws://localhost:3080/api/events.host',
-    ])
-    await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) })
-
-    const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
-    sockets[0]!.receive(new Uint8Array([1, 2, 3]))
-    sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} }))
-    sockets[0]!.receive(JSON.stringify({
-      type: 'server-request',
-      rpcId: 'mux-browser',
-      method: 'session/subscribed',
-      payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 },
-    }))
-    sockets[1]!.receive(JSON.stringify({
-      type: 'server-request',
-      rpcId: 'host-browser',
-      method: 'host/remote-event',
-      payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
-    }))
-    expect(await muxFrame).toMatchObject({
-      value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
-    })
-    expect(await hostFrame).toMatchObject({
-      value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } },
-    })
-    expect(errors).toHaveBeenCalledTimes(2)
-    await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })
-    expect(fetch).not.toHaveBeenCalled()
-
-    const muxEnd = mux.next()
-    const hostEnd = host.next()
-    muxAbort.abort()
-    hostAbort.abort()
-    await expect(muxEnd).resolves.toMatchObject({ done: true })
-    await expect(hostEnd).resolves.toMatchObject({ done: true })
-    expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true)
-    errors.mockRestore()
-    fetch.mockRestore()
-  })
-
-  it('maps an HTTPS page origin to a secure WebSocket URL', async () => {
-    ;(globalThis as Win).location = {
-      hostname: 'harness.example', search: '', origin: 'https://harness.example',
-    }
-    ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
-    const client = (await mount()).api
-    const abort = new AbortController()
-    const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
-    const pending = iterator.next()
-    await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') })
-    abort.abort()
-    await expect(pending).resolves.toMatchObject({ done: true })
-  })
-
-  it('closes a WebSocket immediately when its signal was already aborted', async () => {
-    ;(globalThis as Win).location = {
-      hostname: 'localhost', search: '', origin: 'http://localhost:3080',
-    }
-    ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
-    const client = (await mount()).api
-    const abort = new AbortController()
-    abort.abort()
-    const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
-    await expect(iterator.next()).resolves.toMatchObject({ done: true })
-    expect(sockets).toHaveLength(1)
-    expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
   })
 
   it('carries RPC calls without requiring secure-context randomUUID', async () => {
@@ -319,6 +246,44 @@ describe('connection client apply', () => {
     })
   })
 
+  it('exposes a worker-local Gateway stream through connection.rpc.open', async () => {
+    ;(globalThis as Win).location = { hostname: 'preview.example', search: '' }
+    const openStream = vi.fn<NonNullable<ClientTransportHooks['openStream']>>(
+      (endpoint, payload, signal) => (async function *(): AsyncGenerator {
+        signal.throwIfAborted()
+        yield { endpoint, payload }
+      })(),
+    )
+    ;(globalThis as Win).__DSH_TRANSPORT__ = {
+      createApiClient: () => new FixtureApiClient(),
+      fetch: vi.fn<ClientTransportHooks['fetch']>(),
+      openStream,
+      ownsHost: true,
+    }
+    const handle = await mount()
+    const abort = new AbortController()
+    const open = handle.rpc.open
+    if (open === undefined) throw new Error('worker-local stream carrier was not installed')
+
+    const values = []
+    for await (const value of open('/api', 'session/follow', { args: { sessionId: 'session-1' } }, abort.signal)) {
+      values.push(value)
+    }
+    expect(values).toEqual([{
+      endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } },
+    }])
+    expect(openStream).toHaveBeenCalledWith(
+      'session/follow',
+      { args: { sessionId: 'session-1' } },
+      abort.signal,
+    )
+    expect(handle.isLoopback).toBe(true)
+    expect(() => open('/rpc', 'session/follow', {}, abort.signal))
+      .toThrow('worker-local streams require the /api channel')
+    expect(() => open('/api/path', 'session/follow', {}, abort.signal))
+      .toThrow('invalid RPC target')
+  })
+
   it('validates generic RPC transport failures, correlation, and targets', async () => {
     ;(globalThis as Win).location = {
       hostname: 'harness.example', search: '', origin: 'https://harness.example',
@@ -345,6 +310,51 @@ describe('connection client apply', () => {
       const fetch = vi.mocked(globalThis.fetch)
       expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
       expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
+
+      const respond = (result: unknown): void => {
+        globalThis.fetch = async (_input: URL | RequestInfo, init?: RequestInit) => {
+          if (typeof init?.body !== 'string') throw new TypeError('expected a JSON request body')
+          const request = JSON.parse(init.body) as { rpcId: string }
+          return Response.json({ type: 'server-response', rpcId: request.rpcId, result })
+        }
+      }
+      for (const envelope of [
+        null,
+        { type: 'other', rpcId: 'rpc', result: { ok: true } },
+        { type: 'server-response', rpcId: 1, result: { ok: true } },
+      ]) {
+        globalThis.fetch = vi.fn().mockResolvedValue(Response.json(envelope))
+        await expect(handle.rpc.call('/api', 'goals/create', {}))
+          .rejects.toThrow('invalid server-response envelope')
+      }
+
+      respond(null)
+      await expect(handle.rpc.call('/api', 'goals/create', {}))
+        .rejects.toThrow('invalid server-response result')
+      respond({ ok: 'yes' })
+      await expect(handle.rpc.call('/api', 'goals/create', {}))
+        .rejects.toThrow('invalid server-response result')
+      respond({ ok: false, error: null })
+      await expect(handle.rpc.call('/api', 'goals/create', {}))
+        .rejects.toThrow('invalid server-response result')
+
+      for (const error of [
+        { code: 1, message: 'failed', details: {} },
+        { code: 'failed', message: 1, details: {} },
+        { code: 'failed', message: 'failed', details: [] },
+      ]) {
+        respond({ ok: false, error })
+        await expect(handle.rpc.call('/api', 'goals/create', {}))
+          .rejects.toThrow('invalid server-response failure')
+      }
+      respond({
+        ok: false,
+        error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } },
+      })
+      await expect(handle.rpc.call('/api', 'goals/create', {})).resolves.toEqual({
+        ok: false,
+        error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } },
+      })
     } finally {
       globalThis.fetch = original
     }

+ 75 - 73
packages/client/connection/tests/connection.client.spec.ts

@@ -1,32 +1,24 @@
 /**
- * ConnectionController: stream pumping into sinks, the strict readiness
- * handshake (describe + both streams' onOpen, timeout-guarded), generation
+ * ConnectionController: strict readiness handshake (describe + incremental
+ * source ready), 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.client.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
-}
+const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }
 
 describe('connection lifecycle', () => {
-  it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
+  it('announces connected after describe plus generation readiness', async () => {
     const api = new FakeApiClient()
-    const muxSeen: string[] = []
     const descriptions: boolean[] = []
     let connected = 0
-    const controller = new ConnectionController(api, {
-      onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
+    const controller = new ConnectionController(api, api.generation, {
       onConnected: (description) => {
         connected++
         descriptions.push(description.canOpenPath)
@@ -35,8 +27,6 @@ describe('connection lifecycle', () => {
     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)
       expect(descriptions).toEqual([true])
     } finally {
@@ -44,25 +34,25 @@ describe('connection lifecycle', () => {
     }
   })
 
-  it('reconnects with a fresh generation when a stream fails, and stop() ends the loop', async () => {
+  it('reconnects with a fresh generation when its source 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)
+    const controller = new ConnectionController(api, api.generation, { 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
+      expect(api.openGenerationCount).toBe(1)
     } 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) })
+    // stop() aborts the live generation and no reconnect follows.
+    await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) })
     await new Promise(resolve => setTimeout(resolve, 40))
-    expect(api.openMuxCount).toBe(0)
+    expect(api.openGenerationCount).toBe(0)
   })
 
   it('treats describe failure as generation failure and retries', async () => {
@@ -75,7 +65,7 @@ describe('connection lifecycle', () => {
     }
     let connected = 0
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
-    const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
+    const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
     controller.start()
     try {
       await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
@@ -106,7 +96,7 @@ describe('connection lifecycle', () => {
     }
     let connected = 0
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
-    const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
+    const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
     controller.start()
     try {
       await vi.waitFor(() => { expect(describeCalls).toBe(2) })
@@ -117,70 +107,45 @@ describe('connection lifecycle', () => {
     }
   })
 
-  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 () => {
+  it('isolates a connected sink exception from the generation', 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)
+    const controller = new ConnectionController(api, api.generation, {
+      onConnected: () => {
+        connected++
         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
+      expect(api.openGenerationCount).toBe(1)
+      expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error))
     } finally {
       controller.stop()
       errorSpy.mockRestore()
     }
   })
 
-  it('holds onConnected until both streams establish even after describe succeeds', async () => {
+  it('holds onConnected until the incremental source is ready after describe succeeds', async () => {
     const api = new FakeApiClient()
-    api.holdStreamOpen = true // describe resolves immediately; stream establishment is in the case's hand
+    api.holdGenerationReady = true
     let connected = 0
-    const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
+    const controller = new ConnectionController(api, api.generation, { 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()
+      api.releaseGenerationReady()
       await vi.waitFor(() => { expect(connected).toBe(1) })
     } finally {
       controller.stop()
     }
   })
 
-  it('rejects a generation whose streams end during readiness and retries', async () => {
+  it('rejects a generation whose source ends during readiness and retries', async () => {
     const api = new FakeApiClient()
     const firstDescribe = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
     let describeCalls = 0
@@ -193,13 +158,13 @@ describe('connection lifecycle', () => {
     const states: ConnectionState[] = []
     let connected = 0
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
-    const controller = new ConnectionController(api, {
+    const controller = new ConnectionController(api, api.generation, {
       onConnected: () => { connected++ },
       onStateChange: state => states.push(state),
     }, FAST)
     controller.start()
     try {
-      await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) })
+      await vi.waitFor(() => { expect(api.openGenerationCount).toBe(1) })
       api.endStreams()
       firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }))
 
@@ -212,16 +177,54 @@ describe('connection lifecycle', () => {
     }
   })
 
-  it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => {
+  it.each([
+    { label: 'ends normally', fail: () => Promise.resolve() },
+    {
+      label: 'rejects with a non-Error reason',
+      // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error source normalization is the scenario.
+      fail: () => Promise.reject('fixture offline'),
+    },
+  ])('retries when the generation source $label before reporting ready', async ({ fail }) => {
     const api = new FakeApiClient()
-    api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires
+    let sourceCalls = 0
     let connected = 0
-    const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, { ...FAST, streamOpenTimeoutMs: 20 })
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const controller = new ConnectionController(api, (signal, ready) => {
+      sourceCalls++
+      if (sourceCalls === 1) return fail()
+      ready()
+      return new Promise<void>((resolve) => {
+        signal.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+    }, { onConnected: () => { connected++ } }, FAST)
     controller.start()
     try {
-      await vi.waitFor(() => { expect(connected).toBe(1) }) // handshake resolved by the guard, not wedged
+      await vi.waitFor(() => { expect(sourceCalls).toBe(2) })
+      await vi.waitFor(() => { expect(connected).toBe(1) })
     } finally {
       controller.stop()
+      warnSpy.mockRestore()
+    }
+  })
+
+  it('rejects and retries a generation whose source never reports ready', async () => {
+    const api = new FakeApiClient()
+    api.suppressGenerationReady = true
+    let connected = 0
+    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const controller = new ConnectionController(
+      api,
+      api.generation,
+      { onConnected: () => { connected++ } },
+      { ...FAST, generationReadyTimeoutMs: 20 },
+    )
+    controller.start()
+    try {
+      await vi.waitFor(() => { expect(api.callsOf('host.describe').length).toBeGreaterThan(1) })
+      expect(connected).toBe(0)
+    } finally {
+      controller.stop()
+      warnSpy.mockRestore()
     }
   })
 
@@ -230,7 +233,7 @@ describe('connection lifecycle', () => {
     const states: ConnectionState[] = []
     let connected = 0
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
-    const controller = new ConnectionController(api, {
+    const controller = new ConnectionController(api, api.generation, {
       onConnected: () => { connected++ },
       onStateChange: state => states.push(state),
     }, FAST)
@@ -251,7 +254,7 @@ describe('connection lifecycle', () => {
     const api = new FakeApiClient()
     const states: ConnectionState[] = []
     let connected = 0
-    const controller = new ConnectionController(api, {
+    const controller = new ConnectionController(api, api.generation, {
       onConnected: () => { connected++ },
       onStateChange: (state) => {
         states.push(state)
@@ -261,7 +264,7 @@ describe('connection lifecycle', () => {
 
     controller.start()
     await vi.waitFor(() => { expect(states).toEqual(['connected']) })
-    await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
+    await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) })
     expect(connected).toBe(0)
   })
 
@@ -276,7 +279,7 @@ describe('connection lifecycle', () => {
     const states: ConnectionState[] = []
     let connected = 0
     const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
-    const controller = new ConnectionController(api, {
+    const controller = new ConnectionController(api, api.generation, {
       onConnected: () => { connected++ },
       onStateChange: state => states.push(state),
     }, FAST)
@@ -294,11 +297,10 @@ describe('connection lifecycle', () => {
 
   it('runs with no sinks at all (every callback slot optional)', async () => {
     const api = new FakeApiClient()
-    const controller = new ConnectionController(api, {}, FAST)
+    const controller = new ConnectionController(api, api.generation, {}, 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()
@@ -308,12 +310,12 @@ describe('connection lifecycle', () => {
   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)
+    const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST)
     controller.start()
     controller.start()
     try {
       await vi.waitFor(() => { expect(connected).toBe(1) })
-      expect(api.openMuxCount).toBe(1)
+      expect(api.openGenerationCount).toBe(1)
       expect(api.callsOf('host.describe')).toHaveLength(1)
     } finally {
       controller.stop()

+ 26 - 125
packages/client/connection/tests/fake-api.client.ts

@@ -1,10 +1,8 @@
 // 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, ModelSelection, MuxFrame,
-  RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId,
-} from '../src/client/api.ts'
+// deferred-controlled timing). The generation source is a hand pump.
+import type { IApiClient, RpcResponse, SkillEntry } from '../src/client/api.ts'
+import type { ConnectionGenerationSource } from '../src/client/connection.ts'
 import { RpcId } from '../src/client/api.ts'
 
 export interface Deferred<T> {
@@ -31,10 +29,10 @@ export function ok<T>(value: T): RpcResponse<T> {
 }
 
 
-type StreamItem<F> = { kind: 'frame'; envelope: RpcRequest<F> } | { kind: 'end' } | { kind: 'fail'; error: unknown }
+type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown }
 
-interface StreamConn<F> {
-  feed(item: StreamItem<F>): void
+interface StreamConn {
+  feed(item: StreamItem): void
 }
 
 export class FakeApiClient implements IApiClient {
@@ -42,34 +40,6 @@ export class FakeApiClient implements IApiClient {
   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: [] }))
-  onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
-    () => Promise.resolve(ok({ items: [], hasMore: false }))
-  onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
-  onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
-  onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
-  onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
-  => Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelSelection: ModelSelection }>> =
-    () => Promise.resolve(ok({
-      events: [],
-      hasMore: false,
-      modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
-    }))
-
-  onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
-    current: { provider: 'deepseek-official', model: 'deepseek-chat' },
-    routable: true,
-    groups: [],
-    failures: [],
-  }))
-  onSelectModel: (payload: ModelSelection & { sessionId: SessionId })
-  => Promise<RpcResponse<{ selected: ModelSelection }>> =
-    payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
-  onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
-  onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
-    () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
-  onUpdateQueue: (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
@@ -97,42 +67,13 @@ export class FakeApiClient implements IApiClient {
   onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
     () => Promise.resolve(ok({ path: '/home/fake/new' }))
 
-  private readonly muxConns: StreamConn<MuxFrame>[] = []
-  private readonly hostConns: StreamConn<HostFrame>[] = []
-  lastSearchSignal: AbortSignal | undefined
-
-  // 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)),
-    search: (payload: unknown, signal?: AbortSignal) => {
-      this.lastSearchSignal = signal
-      return this.record('session.search', payload, this.onSearch(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)),
-    models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
-    selectModel: (payload: ModelSelection & { sessionId: SessionId }) =>
-      this.record('session.selectModel', payload, this.onSelectModel(payload)),
-    rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
-    fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
-    prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
-    attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
-    updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
-    cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
-  }
+  private readonly generationConns: StreamConn[] = []
 
   readonly subagents: IApiClient['subagents'] = {
     list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({
       entries: [],
       parentAvailable: true,
     }))),
-    history: (payload: unknown) => this.record('subagent.history', payload, Promise.resolve(ok({
-      events: [],
-      hasMore: false,
-    }))),
     prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
       messageId: 'fake-message' as never,
     }))),
@@ -149,27 +90,6 @@ export class FakeApiClient implements IApiClient {
     openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
   }
 
-  readonly workspace: IApiClient['workspace'] = {
-    list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))),
-    create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({
-      workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
-      created: true,
-    }))),
-    rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
-      workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
-    }))),
-    delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))),
-    insertBefore: (payload: unknown) => this.record('workspace.insertBefore', payload, Promise.resolve(ok({
-      workspaceIds: [(payload as { workspaceId: WorkspaceId }).workspaceId],
-    }))),
-    insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
-      workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
-    }))),
-    archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({
-      archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId],
-    }))),
-  }
-
   // Payloads stay `unknown` (lint-lane note above); response rows are the real
   // wire shapes so cases can program catalogs and skill lists without casts.
   onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
@@ -225,51 +145,33 @@ export class FakeApiClient implements IApiClient {
     discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
   }
 
-  /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
-  suppressStreamOpen = false
+  /** When true, the source never reports ready. */
+  suppressGenerationReady = 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
+  /** When true, ready callbacks remain parked until the test releases them. */
+  holdGenerationReady = false
   private heldOpens: (() => void)[] = []
 
-  releaseStreamOpens(): void {
+  releaseGenerationReady(): 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 } })
-  }
+  readonly generation: ConnectionGenerationSource = (signal, ready) =>
+    this.openGeneration(signal, ready)
 
   /** 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' })
+    for (const conn of [...this.generationConns]) conn.feed({ kind: 'end' })
   }
 
   failStreams(error: unknown): void {
-    for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error })
+    for (const conn of [...this.generationConns]) conn.feed({ kind: 'fail', error })
   }
 
-  get openMuxCount(): number {
-    return this.muxConns.length
+  get openGenerationCount(): number {
+    return this.generationConns.length
   }
 
   callsOf(method: string): unknown[] {
@@ -281,25 +183,24 @@ export class FakeApiClient implements IApiClient {
     return response
   }
 
-  private async *openStream<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
-    const inbox: StreamItem<F>[] = []
+  private async openGeneration(signal: AbortSignal, onOpen: () => void): Promise<void> {
+    const inbox: StreamItem[] = []
     let wake: (() => void) | null = null
-    const conn: StreamConn<F> = {
+    const conn: StreamConn = {
       feed: (item) => {
         inbox.push(item)
         wake?.()
       },
     }
-    registry.push(conn)
-    if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen)
-    else if (!this.suppressStreamOpen) onOpen?.()
+    this.generationConns.push(conn)
+    if (this.holdGenerationReady) this.heldOpens.push(onOpen)
+    else if (!this.suppressGenerationReady) onOpen()
     try {
       while (!signal.aborted) {
         while (inbox.length > 0) {
-          const item = inbox.shift() as StreamItem<F>
+          const item = inbox.shift() as StreamItem
           if (item.kind === 'end') return
           if (item.kind === 'fail') throw item.error
-          yield item.envelope
         }
         await new Promise<void>((resolve) => {
           wake = resolve
@@ -308,7 +209,7 @@ export class FakeApiClient implements IApiClient {
         wake = null
       }
     } finally {
-      registry.splice(registry.indexOf(conn), 1)
+      this.generationConns.splice(this.generationConns.indexOf(conn), 1)
     }
   }
 }

+ 17 - 11
packages/client/connection/tests/fixture-commands.client.spec.ts

@@ -45,15 +45,18 @@ describe('createFixtureApi commands/skills', () => {
     expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
   })
 
-  it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => {
-    const { api, rpc } = createFixtureFaces()
+  it('executes a known command line: pure admission plus a followed lifecycle pair', async () => {
+    const { rpc } = createFixtureFaces()
     const frames: unknown[] = []
     const abort = new AbortController()
-    const stream = api.events.mux(req({}), abort.signal)
+    const stream = rpc.open?.('/api', 'session/follow', {
+      args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } },
+    }, abort.signal)
+    if (stream === undefined) throw new Error('fixture session follow stream is unavailable')
     const pump = (async () => {
       for await (const frame of stream) {
-        frames.push(frame.payload)
-        if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
+        frames.push(frame)
+        if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort()
       }
     })()
     const execution = await callRemote<{ commandId: string } | undefined>(
@@ -61,7 +64,7 @@ describe('createFixtureApi commands/skills', () => {
     expect(execution?.commandId).toBeTruthy()
     await pump
     const events = frames
-      .filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
+      .filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'event')
       .map(f => f.event)
     expect(events).toMatchObject([
       { type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } },
@@ -83,14 +86,17 @@ describe('createFixtureApi commands/skills', () => {
   })
 
   it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => {
-    const { api, rpc } = createFixtureFaces()
+    const { rpc } = createFixtureFaces()
     const frames: unknown[] = []
     const abort = new AbortController()
-    const stream = api.events.mux(req({}), abort.signal)
+    const stream = rpc.open?.('/api', 'session/follow', {
+      args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } },
+    }, abort.signal)
+    if (stream === undefined) throw new Error('fixture session follow stream is unavailable')
     const pump = (async () => {
       for await (const frame of stream) {
-        frames.push(frame.payload)
-        if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort()
+        frames.push(frame)
+        if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort()
       }
     })()
     const png = { mediaType: 'image/png', data: 'AA==' }
@@ -100,7 +106,7 @@ describe('createFixtureApi commands/skills', () => {
     expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' })
     await pump
     const events = frames
-      .filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'session/event')
+      .filter((f): f is { type: string; event: { type: string; data: Record<string, unknown> } } => (f as { type: string }).type === 'event')
       .map(f => f.event)
     expect(events).toMatchObject([
       { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } },

Разлика између датотеке није приказан због своје велике величине
+ 668 - 176
packages/client/connection/tests/fixture.client.spec.ts


+ 23 - 31
packages/client/connection/tests/node-half.host.spec.ts

@@ -1,7 +1,7 @@
 /** Node half: registers the /api prefix route bridging to the api gateway. */
-import { EventEmitter, once } from 'node:events'
+import { EventEmitter } from 'node:events'
 import { createServer, request as httpRequest } from 'node:http'
-import { PassThrough, Readable } from 'node:stream'
+import { Readable } from 'node:stream'
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it } from 'vitest'
 import type { AddressInfo } from 'node:net'
@@ -10,7 +10,7 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
 import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
-import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
+import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts'
 import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts'
 
 /** Structural webServer fake recording both route registries. */
@@ -78,6 +78,7 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b
 async function mounted(config?: { trustedHosts?: string[] }): Promise<{
   routes: WebRoute[]
   upgrades: WebUpgradeRoute[]
+  connection: HostConnectionHandle
   dispose: () => Promise<void>
 }> {
   const ctx = new Context()
@@ -87,7 +88,12 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{
   ctx.provide('apiProxy', {} as unknown as ApiProxy)
   const fiber = ctx.plugin({ inject: [...inject], apply }, config)
   await fiber.await()
-  return { routes, upgrades, dispose: () => fiber.dispose() }
+  return {
+    routes,
+    upgrades,
+    connection: ctx.get('connection') as HostConnectionHandle,
+    dispose: () => fiber.dispose(),
+  }
 }
 
 describe('connection node half', () => {
@@ -121,41 +127,16 @@ describe('connection node half', () => {
     expect(upgrades).toHaveLength(0)
   })
 
-  it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => {
+  it('registers only the HTTP route and removes it with the fiber', async () => {
     const { routes, upgrades, dispose } = await mounted()
     expect(routes).toHaveLength(1)
     expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
-    expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH])
+    expect(upgrades).toHaveLength(0)
     await dispose()
     expect(routes).toHaveLength(0)
     expect(upgrades).toHaveLength(0)
   })
 
-  it('requires WebSocket upgrade for network GETs to either event path', async () => {
-    const { routes, dispose } = await mounted()
-    for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) {
-      const { response, state } = fakeResponse()
-      await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response)
-      expect(state.status).toBe(426)
-      expect(state.body).toBe('upgrade required')
-    }
-    await dispose()
-  })
-
-  it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => {
-    const { upgrades, dispose } = await mounted()
-    const socket = new PassThrough()
-    const chunks: Buffer[] = []
-    socket.on('data', (chunk: Buffer) => { chunks.push(chunk) })
-    const ended = once(socket, 'end')
-    await upgrades[0]!.handler(fakeRequest({
-      host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
-    }, MUX_EVENTS_PATH), socket, Buffer.alloc(0))
-    await ended
-    expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden')
-    await dispose()
-  })
-
   it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
     const { routes, dispose } = await mounted()
     const { response, state } = fakeResponse()
@@ -219,6 +200,17 @@ describe('connection node half', () => {
     await dispose()
   })
 
+  it('shares its configured trust policy with sibling routes', async () => {
+    const { connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
+    const loopback = fakeRequest({ host: '127.0.0.1:3080' })
+    const declared = fakeRequest({ host: 'harness.example' })
+
+    expect(connection.isTrustedRequest(loopback, 'loopback')).toBe(true)
+    expect(connection.isTrustedRequest(declared, 'loopback')).toBe(false)
+    expect(connection.isTrustedRequest(declared, 'trusted-host')).toBe(true)
+    await dispose()
+  })
+
   it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
     const ctx = new Context()
     const routes: WebRoute[] = []

+ 0 - 308
packages/client/connection/tests/websocket-downlink.host.spec.ts

@@ -1,308 +0,0 @@
-import { once } from 'node:events'
-import { createServer } from 'node:http'
-import type { AddressInfo } from 'node:net'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import WebSocket from 'ws'
-import type {
-  ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest,
-} from '@deepseek-ai/dsh-host-apiproxy/api'
-import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
-import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../src/api-path.ts'
-import { WebSocketDownlinks } from '../src/websocket-downlink.ts'
-
-type MuxSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<MuxFrame>>
-type HostSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<HostFrame>>
-
-const running: (() => Promise<void>)[] = []
-
-afterEach(async () => {
-  await Promise.all(running.splice(0).map(close => close()))
-})
-
-function untilAbort(signal: AbortSignal): Promise<void> {
-  if (signal.aborted) return Promise.resolve()
-  return new Promise((resolve) => {
-    signal.addEventListener('abort', () => { resolve() }, { once: true })
-  })
-}
-
-async function * idle<F>(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
-  await untilAbort(signal)
-}
-
-function api(mux: MuxSource, host: HostSource): ApiProxy {
-  return {
-    events: {
-      mux: (_request, signal) => mux(signal),
-      host: (_request, signal) => host(signal),
-    },
-  } as ApiProxy
-}
-
-async function serve(downlinks: WebSocketDownlinks): Promise<{
-  origin: string
-  close: () => Promise<void>
-}> {
-  const server = createServer()
-  server.on('upgrade', (request, socket, head) => {
-    const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname
-    if (pathname === MUX_EVENTS_PATH) downlinks.handleMux(request, socket, head)
-    else if (pathname === HOST_EVENTS_PATH) downlinks.handleHost(request, socket, head)
-    else socket.destroy()
-  })
-  await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
-  const port = (server.address() as AddressInfo).port
-  return {
-    origin: `ws://127.0.0.1:${String(port)}`,
-    close: async () => {
-      await downlinks.close()
-      await new Promise<void>(resolve => server.close(() => { resolve() }))
-    },
-  }
-}
-
-function read(socket: WebSocket): Promise<ServerRequest> {
-  return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest)
-}
-
-async function acceptedSocket(downlinks: WebSocketDownlinks): Promise<WebSocket> {
-  const server = (downlinks as unknown as { server: { clients: Set<WebSocket> } }).server
-  let accepted: WebSocket | undefined
-  await vi.waitFor(() => {
-    accepted = server.clients.values().next().value
-    expect(accepted).toBeDefined()
-  })
-  return accepted as WebSocket
-}
-
-describe('WebSocket downlinks', () => {
-  it('carries mux and host over independent downstream sockets and cancels each source on close', async () => {
-    let muxAborted = false
-    let hostAborted = false
-    const downlinks = new WebSocketDownlinks(api(
-      async function * (signal) {
-        try {
-          yield {
-            rpcId: RpcId('mux-1'),
-            payload: { type: 'session/subscribed', sessionId: 'session-1' as never, lastSeq: 4 },
-          }
-          await untilAbort(signal)
-        } finally {
-          muxAborted = true
-        }
-      },
-      async function * (signal) {
-        try {
-          yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } }
-          await untilAbort(signal)
-        } finally {
-          hostAborted = true
-        }
-      },
-    ))
-    const host = await serve(downlinks)
-    running.push(host.close)
-
-    const mux = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
-    const hostSocket = new WebSocket(`${host.origin}${HOST_EVENTS_PATH}`)
-    const muxFrame = read(mux)
-    const hostFrame = read(hostSocket)
-    expect(await muxFrame).toEqual({
-      type: 'server-request',
-      rpcId: 'mux-1',
-      method: 'session/subscribed',
-      payload: { type: 'session/subscribed', sessionId: 'session-1', lastSeq: 4 },
-    })
-    expect(await hostFrame).toEqual({
-      type: 'server-request',
-      rpcId: 'host-1',
-      method: 'host/remote-event',
-      payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
-    })
-
-    const muxClosed = once(mux, 'close')
-    const hostClosed = once(hostSocket, 'close')
-    mux.close()
-    hostSocket.close()
-    await Promise.all([muxClosed, hostClosed])
-    await vi.waitFor(() => {
-      expect(muxAborted).toBe(true)
-      expect(hostAborted).toBe(true)
-    })
-  })
-
-  it('rejects client messages because upstream remains HTTP', async () => {
-    let aborted = false
-    const downlinks = new WebSocketDownlinks(api(
-      async function * (signal) {
-        try {
-          await untilAbort(signal)
-        } finally {
-          aborted = true
-        }
-      },
-      idle,
-    ))
-    const host = await serve(downlinks)
-    running.push(host.close)
-    const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
-    await once(socket, 'open')
-    const closed = once(socket, 'close')
-    socket.send('upstream payload')
-    const [code, reason] = await closed as [number, Buffer]
-    expect(code).toBe(1008)
-    expect(String(reason)).toBe('downlink only')
-    await vi.waitFor(() => { expect(aborted).toBe(true) })
-  })
-
-  it('sends stream/error before closing when a source fails', async () => {
-    const downlinks = new WebSocketDownlinks(api(
-      async function * () {
-        throw new Error('mux source failed')
-      },
-      idle,
-    ))
-    const host = await serve(downlinks)
-    running.push(host.close)
-    const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
-    const failure = read(socket)
-    const closed = once(socket, 'close')
-    expect((await failure).payload).toEqual({
-      type: 'stream/error',
-      error: { code: 'internal', message: 'Error: mux source failed', details: {} },
-    })
-    await closed
-  })
-
-  it('aborts the source when an accepted socket reports a transport error', async () => {
-    let aborted = false
-    const downlinks = new WebSocketDownlinks(api(
-      async function * (signal) {
-        try {
-          await untilAbort(signal)
-        } finally {
-          aborted = true
-        }
-      },
-      idle,
-    ))
-    const host = await serve(downlinks)
-    running.push(host.close)
-    const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
-    await once(socket, 'open')
-    const accepted = await acceptedSocket(downlinks)
-    const closed = once(socket, 'close')
-    accepted.emit('error', new Error('transport failed'))
-    await closed
-    expect(aborted).toBe(true)
-  })
-
-  it('drops a source frame that races after the client has closed', async () => {
-    let release!: () => void
-    const gate = new Promise<void>((resolve) => { release = resolve })
-    let finish!: () => void
-    const finished = new Promise<void>((resolve) => { finish = resolve })
-    let sourceSignal: AbortSignal | undefined
-    const downlinks = new WebSocketDownlinks(api(
-      async function * (signal) {
-        sourceSignal = signal
-        try {
-          await gate
-          yield {
-            rpcId: RpcId('late'),
-            payload: { type: 'session/subscribed', sessionId: 'session-late' as never, lastSeq: 0 },
-          }
-        } finally {
-          finish()
-        }
-      },
-      idle,
-    ))
-    const host = await serve(downlinks)
-    running.push(host.close)
-    const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
-    await once(socket, 'open')
-    const closed = once(socket, 'close')
-    socket.close()
-    await closed
-    await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) })
-    release()
-    await finished
-  })
-
-  it('contains socket send callback failures and closes the downlink', async () => {
-    let release!: () => void
-    const gate = new Promise<void>((resolve) => { release = resolve })
-    const downlinks = new WebSocketDownlinks(api(
-      async function * () {
-        await gate
-        yield {
-          rpcId: RpcId('send-failure'),
-          payload: { type: 'session/subscribed', sessionId: 'session-send' as never, lastSeq: 0 },
-        }
-      },
-      idle,
-    ))
-    const host = await serve(downlinks)
-    running.push(host.close)
-    const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
-    await once(socket, 'open')
-    const accepted = await acceptedSocket(downlinks)
-    const send = vi.spyOn(accepted, 'send').mockImplementation(((
-      _data: unknown,
-      optionsOrCallback?: unknown,
-      callback?: (error?: Error) => void,
-    ) => {
-      const done = typeof optionsOrCallback === 'function'
-        ? optionsOrCallback as (error?: Error) => void
-        : callback
-      done?.(new Error('socket send failed'))
-    }) as WebSocket['send'])
-    const closed = once(socket, 'close')
-    release()
-    await closed
-    expect(send).toHaveBeenCalledTimes(2)
-    send.mockRestore()
-  })
-
-  it('rejects when its acceptor has already closed', async () => {
-    const downlinks = new WebSocketDownlinks(api(idle, idle))
-    await downlinks.close()
-    await expect(downlinks.close()).rejects.toThrow('The server is not running')
-  })
-
-  it('waits for source cleanup before teardown resolves', async () => {
-    let cleanupStarted!: () => void
-    const started = new Promise<void>((resolve) => { cleanupStarted = resolve })
-    let releaseCleanup!: () => void
-    const cleanupGate = new Promise<void>((resolve) => { releaseCleanup = resolve })
-    let cleaned = false
-    const downlinks = new WebSocketDownlinks(api(
-      async function * (signal) {
-        try {
-          await untilAbort(signal)
-        } finally {
-          cleanupStarted()
-          await cleanupGate
-          cleaned = true
-        }
-      },
-      idle,
-    ))
-    const host = await serve(downlinks)
-    const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
-    await once(socket, 'open')
-    let closed = false
-    const closing = host.close().then(() => { closed = true })
-    try {
-      await started
-      expect(closed).toBe(false)
-      releaseCleanup()
-      await closing
-      expect(cleaned).toBe(true)
-    } finally {
-      releaseCleanup()
-      await closing
-    }
-  })
-})

+ 5 - 6
packages/client/locale/tests/apply.client.spec.ts

@@ -45,11 +45,10 @@ async function bench() {
     }
   })
   ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
-  // The settings transport and the forwarded-event port the plugin injects.
-  new TestRemote(ctx)
+  const events = new TestRemote(ctx)
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
   return {
-    ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate,
+    ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, events,
     setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
   }
 }
@@ -136,19 +135,19 @@ describe('locale apply', () => {
     // Preference must differ from the provisional locale (FALLBACK_LOCALE = en
     // with no window), or clearing it below would be unobservable.
     b.setHostPreference('zh')
-    b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
+    b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
     declareItems(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     const locale = b.ctx.get('locale') as LocaleRuntime
     await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
     // Cleared preference falls back to the provisional locale.
     b.setHostPreference(undefined)
-    b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
+    b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
     await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
     // Re-selecting zh after the clear is an explicit pick of the provisional
     // value and must persist as a written preference.
     b.setHostPreference('zh')
-    b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
+    b.events.emit('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
     await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
     expect(b.describe).toHaveBeenCalledTimes(4)
   })

+ 3 - 2
packages/client/runtime/src/client/agents/scope.ts

@@ -18,11 +18,12 @@
 import { Context as CordisContext } from '@deepseek-ai/cordis'
 import type { Context, Fiber } from '@deepseek-ai/cordis'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
-import type { TypertClientRemote, TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol'
+import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
+import type { TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol'
 
 /** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */
 export type AgentContext = Omit<Context, 'remote'> & {
-  readonly remote: TypertClientRemote & TypertRemoteScopeApi<'agent'>
+  readonly remote: ClientRemote & TypertRemoteScopeApi<'agent'>
 }
 
 /** Context tag written by {@link createScope}. */

+ 2 - 2
packages/client/runtime/src/client/contract/conversation.ts

@@ -1,5 +1,5 @@
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client'
+import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types'
 
 /* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
  * The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;
@@ -8,7 +8,7 @@ import type { ToolEventView } from '@deepseek-ai/dsh-api-remotes/client'
 /** One raw log event plus its optional envelope-level presentation view. */
 export interface ConversationEventInput {
   readonly event: SessionEvent
-  readonly view: ToolEventView | undefined
+  readonly view: SessionToolView | undefined
 }
 
 /** Definition-local identity and lifecycle role extracted from one event. */

+ 6 - 6
packages/client/runtime/src/client/contract/session.ts

@@ -9,7 +9,7 @@
  */
 import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
-  MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
+  ClientResult, MessageId, PromptContentPart, QueueAction, SessionId,
 } from '@deepseek-ai/dsh-api-remotes/client'
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { ConversationSnapshot } from '../sessions/conversation.ts'
@@ -42,7 +42,7 @@ export interface ISession {
     content: PromptContentPart[],
     mode: 'queue' | 'steer',
     signal?: AbortSignal,
-  ): Promise<RpcResult<{ accepted: true }>>
+  ): Promise<ClientResult<{ accepted: true }>>
   /**
    * Resolve one durable image referenced by this session.
    * @param attachmentId - opaque id found in the folded session log.
@@ -50,27 +50,27 @@ export interface ISession {
    */
   readAttachment(
     attachmentId: AttachmentIdType,
-  ): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
+  ): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
   /**
    * Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
    * @param itemId - agent-owned inbox occurrence identity.
    * @param action - requested queue operation.
    * @returns acceptance, or a business/transport error.
    */
-  updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
+  updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>>
   /**
    * Cancel the running turn. Pending queued work remains and resumes in FIFO
    * order after the Host reaches cancellation quiescence.
    * @returns acceptance, or the business error.
    */
-  cancel(): Promise<RpcResult<{ accepted: true }>>
+  cancel(): Promise<ClientResult<{ accepted: true }>>
   /**
    * Rename this session (explicit user title; pins it against automatic
    * regeneration).
    * @param title - raw title text (the host normalizes acceptance).
    * @returns the normalized accepted title and its event seq, or the business error.
    */
-  rename(title: string): Promise<RpcResult<{ title: string; seq: number }>>
+  rename(title: string): Promise<ClientResult<{ title: string; seq: number }>>
   /**
    * Extend the history window backwards (older messages pagination).
    * @returns completion; failures land in snapshot.openState/loadingOlder.

+ 4 - 4
packages/client/runtime/src/client/contract/sessions.ts

@@ -1,15 +1,15 @@
 /**
  * The outward sessions-service face — what `ctx.sessions` exposes to feature
  * packages and the renderer host, and therefore exactly what the test
- * runtime's sessions double must implement. Wire-pump entry points
- * (handleMuxEnvelope/handleConnected/refresh) and runtime internals stay on
+ * runtime's sessions double must implement. Transport entry points and
+ * runtime internals stay on
  * the concrete class; cross-domain consumers keep the narrower
  * [SessionsPort](./sessions-port.ts). Widening this interface is the
  * explicit act of widening what features may do to the sessions domain.
  */
 import type { Context } from '@deepseek-ai/cordis'
 import type {
-  RpcResult, SessionId, SubagentAddress,
+  ClientResult, SessionId, SubagentAddress,
 } from '@deepseek-ai/dsh-api-remotes/client'
 import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
 import type { AgentContext } from '../agents/scope.ts'
@@ -83,7 +83,7 @@ export interface ISessions {
   search(
     query: string,
     signal: AbortSignal,
-  ): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
+  ): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
   /**
    * Fork a session from a completed-turn prefix of the source; on resolution
    * the child is in the list store and `open()` can target it.

+ 56 - 38
packages/client/runtime/src/client/index.ts

@@ -1,6 +1,13 @@
 /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
 import type { Context } from '@deepseek-ai/cordis'
 import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import {
+  createSessionControlStream,
+  SESSION_SEARCH_RESULT_LIMIT,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import {
+  createWorkspaceStateStream, ClientWorkspaceModel,
+} from '@deepseek-ai/dsh-api-workspace-controller/client'
 // Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather
 // than api-remotes': that face imports a Host-tsdown-generated artifact, and this
 // project sits in the Host build graph.
@@ -17,6 +24,7 @@ import { ConversationEventRegistry } from './conversation/event-registry.ts'
 import { ConversationViewRegistry } from './conversation/view-registry.ts'
 
 export { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
+export { SESSION_SEARCH_RESULT_LIMIT }
 
 export { SlotRegistry } from './slots.ts'
 export { ConversationEventRegistry } from './conversation/event-registry.ts'
@@ -58,12 +66,12 @@ export type {
   SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
 } from './sessions/service.ts'
 export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts'
-export type { SubagentAddress, JobView } from '@deepseek-ai/dsh-client-connection/client'
-export type { WorkspaceListPhase } from './workspaces/manager.ts'
+export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
+export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types'
+export type { WorkspaceListPhase } from '@deepseek-ai/dsh-api-workspace-controller/client'
 export type { WorkspaceListState } from './workspaces/service.ts'
-export type {
-  DirectoryEntry, DirectoryListing, WorkspaceId, WorkspaceView,
-} from '@deepseek-ai/dsh-client-connection/client'
+export type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-connection/client'
+export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
 // Runtime owns the snapshot store; ui-renderer only binds it to React.
 export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
 export type {
@@ -158,8 +166,8 @@ declare module '@deepseek-ai/cordis' {
     'slots/changed'(key: string): void
     /**
      * A connection generation was (re-)established. Wire-derived caches must
-     * treat their state as stale and repull (commands directory; the queue
-     * mirrors reset themselves through the session resync path).
+     * treat their state as stale and repull. Session follow and control
+     * streams own their independent resume and baseline lifecycles.
      * @mode emit
      */
     'connection/reset'(): void
@@ -178,7 +186,14 @@ declare module '@deepseek-ai/cordis' {
 }
 
 /** Required services: the wire handle and Client Typert registry. */
-export const inject = ['connection', 'typert', 'remote', 'remote.commands']
+export const inject = [
+  'connection',
+  'typert',
+  'remote',
+  'remote.commands',
+  'remote.session',
+  'remote.workspace',
+]
 
 /** Mounts the browser runtime services and connection stream.
  * @param ctx - Client Cordis context.
@@ -191,41 +206,44 @@ export function apply(ctx: Context): void {
   }
   const connection = ctx.get('connection') as ConnectionHandle
   const sessions = new SessionRuntime(ctx, connection.api, ctx.remote, conversation)
+  ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
+  ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
+  ctx.remote.$on('api-session/status', (sessionId, running) => {
+    sessions.handleSessionStatus(sessionId, running)
+  })
+  ctx.remote.$on('api-session/activity', (sessionId, updatedAt) => {
+    sessions.handleSessionActivity(sessionId, updatedAt)
+  })
+  ctx.remote.$on('api-session/error', (sessionId, message) => {
+    sessions.handleSessionError(sessionId, message)
+  })
+  const sessionControl = createSessionControlStream(ctx.remote, {
+    accept: (frame) => { sessions.handleControlFrame(frame) },
+    failed: (error) => { console.error('[web-runtime] session control stream failed:', error) },
+  })
+  sessionControl.start()
   ctx.typert.contexts.registerClient('agent', {
     identity: candidate => sessions.scopeOf(candidate),
+    resolve: sessionId => sessions.scope(sessionId),
+  })
+  const workspaceModel = new ClientWorkspaceModel(ctx.remote.workspace)
+  const workspaces = new WorkspaceRuntime(ctx, connection.api, workspaceModel, sessions)
+  const workspaceControl = createWorkspaceStateStream(ctx.remote, {
+    accept: workspaceModel,
+    carrierFailed: () => { workspaceModel.handleCarrierFailure() },
+    failed: (error) => { workspaceModel.handleStreamFailure(error) },
   })
-  const workspaces = new WorkspaceRuntime(ctx, connection.api, sessions)
+  workspaceControl.start()
   ctx.effect(
     () => workspaces.startInitialSelection(),
     'runtime: initial Workspace selection',
   )
-  const loop = connection.start({
-    onMuxEnvelope: (envelope) => {
-      sessions.handleMuxEnvelope(envelope)
-    },
-    onHostEnvelope: (envelope) => {
-      sessions.handleHostEnvelope(envelope)
-      workspaces.handleHostEnvelope(envelope)
-      // Forwarded-event bridge: the session layer ignores registry frames (no
-      // session routing). This plugin owns the frame sink, so it hands the
-      // decoded frame straight to the Remote service, which fans it out to
-      // `ctx.remote.$on` subscribers; no consumer reads a frame.
-      const frame = envelope.payload
-      if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args)
-    },
-    onConnected: () => {
-      sessions.handleConnected()
-      workspaces.handleConnected()
-      ctx.emit('connection/reset')
-    },
-    onStateChange: (state) => {
-      // Generation death fires before any next-generation frame can arrive
-      // (reconnect replays flow from stream open, ahead of onConnected):
-      // the only safe moment to drop generation-scoped interaction state.
-      if (state === 'reconnecting') {
-        sessions.handleDisconnected()
-      }
-    },
-  })
-  ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
+  ctx.on('connection/reset', () => { sessions.handleConnected() })
+  if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected()
+  ctx.effect(() => async () => {
+    await Promise.all([
+      workspaceControl.dispose(),
+      sessionControl.dispose(),
+    ])
+  }, 'runtime: connection streams')
 }

+ 8 - 8
packages/client/runtime/src/client/sessions/conversation.ts

@@ -9,7 +9,7 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
 import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
 import type {
-  RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
+  ClientFailure, SessionId, SubagentAddress, ToolCallView, ToolResultView,
 } from '@deepseek-ai/dsh-api-remotes/client'
 import type { PendingInteraction } from './pending.ts'
 import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
@@ -310,7 +310,7 @@ export interface RunningToolCall {
 /** One running or settled call, recursively owning its child calls. */
 export type ToolCallBlock = RunningToolCall | ToolResultNode
 
-/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
+/** One transient inbox occurrence from the Session control stream's queue snapshot. */
 export interface QueuedMessage {
   readonly id: MessageId
   /** Stable message identity used for transient-to-durable steering handoff. */
@@ -358,7 +358,7 @@ export type ComposerPhase = 'blank' | 'engaging' | 'active'
 /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
 export interface PromptError {
   op: 'send' | 'stop'
-  error: RpcError
+  error: ClientFailure
 }
 
 /**
@@ -456,20 +456,20 @@ export interface ConversationSnapshot {
   subagent: { address: SubagentAddress; parentAvailable: boolean } | null
   /** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
   composerPhase: ComposerPhase
-  /** Set after host/session-removed; the UI grays out and disables input. */
+  /** Set after the forwarded `api-session/removed` event; the UI disables input. */
   removed: boolean
   openState: OpenState
-  openError: RpcError | null
+  openError: ClientFailure | null
   hasMore: boolean
   loadingOlder: boolean
   promptError: PromptError | null
   /**
    * Whether this session still has an empty log (no user message yet).
-   * Mirrors the host summary's derived blank bit: seeded from `session.list`
-   * / the `host/session-added` frame, flipped false by the first ACCEPTED
+   * Mirrors the Host summary's derived blank bit: seeded from `session.list`
+   * or `api-session/added`, flipped false by the first accepted
    * prompt locally (on the RPC success response — acceptance proves the
    * user message is in the host log; a rejected first prompt keeps the
-   * session blank and reusable) and by any `running: true` status remotely,
+   * Session blank and reusable) and by any remote `running: true` status,
    * and re-aligned by every list re-pull (the summary stays authoritative).
    * Blank sessions are hidden from session lists and reused by New Session.
    */

+ 2 - 2
packages/client/runtime/src/client/sessions/lineage.ts

@@ -6,7 +6,7 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/cli
 import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
 import type { PendingInteractionStatus } from './pending.ts'
 
-/** Host list summary enriched with the latest mux-projected durable title. */
+/** Host list summary enriched with the latest Session Controller title projection. */
 export interface TitledSessionSummary extends SessionSummary {
   title?: string
   /** Current host-computed projection values for list consumers. */
@@ -29,7 +29,7 @@ export interface SessionListEntry {
   agentPreset?: string
   /** Current host-computed projection values for list consumers. */
   projectionValues?: Readonly<Partial<SessionProjectionMap>>
-  /** User interaction currently blocking this session, derived from live mux frames. */
+  /** User interaction currently blocking this session, derived from live control frames. */
   pendingInteraction?: PendingInteractionStatus
   /** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */
   completed: boolean

+ 215 - 243
packages/client/runtime/src/client/sessions/manager.ts

@@ -3,9 +3,18 @@
 // List data never enters zustand; React connects via subscribe/getListSnapshot.
 
 import type {
-  IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
+  ClientFailure, ClientResult, IApiClient, SessionId,
   SessionSummary, SubagentAddress, SubagentCatalog, JobView, WorkspaceId,
 } from '@deepseek-ai/dsh-api-remotes/client'
+import type {
+  SessionApprovalRequest,
+  SessionControlBaseline,
+  SessionControlFrame,
+  SessionInteractionId,
+  SessionQuestionRequest,
+  SessionQueuedItem,
+  SessionError,
+} from '@deepseek-ai/dsh-api-session-controller/types'
 // Value import from the inline-safe wire layer (not the connection plugin):
 // plugin-to-plugin value imports are a bundle purity error.
 import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -47,7 +56,7 @@ export interface SessionListSnapshot {
   state: 'idle' | 'loading' | 'error'
   /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
   phase: SessionListPhase
-  error: RpcError | null
+  error: ClientFailure | null
   subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
   /** Background jobs per session; an absent key is an empty set. */
   jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
@@ -57,7 +66,7 @@ export interface SessionListSnapshot {
 /** One parent-addressed durable catalog projected through the sessions snapshot. */
 export interface SubagentCatalogSnapshot extends SubagentCatalog {
   state: 'loading' | 'ready' | 'error'
-  error: RpcError | null
+  error: ClientFailure | null
 }
 
 interface CatalogInflight {
@@ -76,21 +85,9 @@ type SessionListMutation =
   /** Local first-send flip: the sender clears blank without waiting for a host frame. */
   | { kind: 'engaged'; sessionId: SessionId }
 
-/** Stable identity of a frame retained until an uninstantiated Session can consume it. */
-function bufferedRequestKey(envelope: RpcRequest<MuxFrame>): string | undefined {
-  const frame = envelope.payload
-  switch (frame.type) {
-    case 'approval/requested': return `a:${frame.approvalId}`
-    case 'question/requested': return `q:${envelope.rpcId}`
-    case 'session/queue': return 'queue'
-    /* v8 ignore next -- pendingBuffers contains only the three frame types above. */
-    default: return undefined
-  }
-}
-
 /** Match ui-user-questions's binary plan-review routing at the wire boundary. */
 function questionInteractionStatus(
-  questions: Extract<MuxFrame, { type: 'question/requested' }>['questions'],
+  questions: SessionQuestionRequest['questions'],
 ): PendingInteractionStatus {
   if (questions.length !== 1) return 'question'
   const question = questions[0] as typeof questions[number]
@@ -105,14 +102,17 @@ function questionInteractionStatus(
 /** Instance cluster + frame entry + the session list. */
 export class SessionManager {
   private readonly sessions = new Map<SessionId, Session>()
-  /** Pre-instantiation buffer for answerable requests and the queued-turn snapshot, which history
-   *  cannot reconstruct on open. Live requests remain until resolution; queue and replay duplicates
-   *  compact by identity. Instantiation replays and clears it, while removal drops it. */
-  private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
+  /** Latest transient queues, retained independently of Session object materialization. */
+  private readonly queues = new Map<SessionId, readonly SessionQueuedItem[]>()
+  /** Answerable requests retained by stable identity for lazy Session materialization. */
+  private readonly interactions = new Map<
+    SessionId,
+    Map<SessionInteractionId, SessionApprovalRequest | SessionQuestionRequest>
+  >()
   /** Outstanding answerable interactions per session, keyed by their stable request identity.
    *  Manager-owned rather than read off Session instances because the sidebar must light up for
-   *  sessions never instantiated. Cleared per connection generation — the reopen replay re-adds
-   *  still-pending requests — and on session-removed. */
+   *  sessions never instantiated. Each control baseline replaces the complete set, and
+   *  session removal clears the corresponding rows. */
   private readonly pendingInteractions = new Map<SessionId, Map<string, PendingInteractionStatus>>()
   /**
    * Sessions that finished running while not selected — the sidebar's green
@@ -131,7 +131,7 @@ export class SessionManager {
   private listState: 'idle' | 'loading' | 'error' = 'idle'
   /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
   private listPhase: SessionListPhase = 'pending'
-  private listError: RpcError | null = null
+  private listError: ClientFailure | null = null
   private listInflight: Promise<void> | null = null
   /** Mutations arriving after a list request starts are replayed over its response. */
   private listMutations: SessionListMutation[] | null = null
@@ -143,8 +143,9 @@ export class SessionManager {
   private readonly openCatalogs = new Set<SessionId>()
   private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
   /**
-   * Background jobs per session, last-wins from `session/jobs`. An empty set
-   * is stored as an absent key, so absence and `[]` are one representation.
+   * Background jobs per session, last-wins from Session Controller's control
+   * stream. An empty set is stored as an absent key, so absence and `[]` are
+   * one representation.
    */
   private readonly jobsBySession = new Map<SessionId, readonly JobView[]>()
 
@@ -274,16 +275,14 @@ export class SessionManager {
     if (session === undefined) {
       session = this.createSession(sessionId)
       this.sessions.set(sessionId, session)
-      // Replay approval/question/queued frames buffered before instantiation (rpcId
-      // verbatim, same semantics as the subscribed baseline replay). Replay happens
-      // BEFORE the running-bit sync: a not-running summary must sweep replayed queue
+      // Install the latest control baseline before the running-bit sync: a
+      // not-running summary must sweep replayed queue
       // rows the same way a live status flip would (their retirement events dropped
       // while the session was uninstantiated).
-      const buffered = this.pendingBuffers.get(sessionId)
-      if (buffered !== undefined) {
-        this.pendingBuffers.delete(sessionId)
-        for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload)
-      }
+      session.replaceControl(
+        this.queues.get(sessionId) ?? [],
+        [...(this.interactions.get(sessionId)?.values() ?? [])],
+      )
       // Sync the running and blank bits from the list snapshot into the new
       // instance (consistency when the list precedes open).
       const summary = this.summaries.find(s => s.sessionId === sessionId)
@@ -446,10 +445,10 @@ export class SessionManager {
     this.notifier.markDirty()
     this.listInflight = (async () => {
       try {
-        const { result } = await this.api.sessions.list({})
+        const result = toSessionResult(await this.remote.session.list({}))
         if (result.ok) {
-          const baseline = this.listPhase === 'pending'
-            ? result.value.items
+          const baseline: SessionSummary[] = this.listPhase === 'pending'
+            ? [...result.value.items]
             : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
           // Seed first observations from the pull-time baseline BEFORE replaying
           // in-flight mutations, then reconcile the reminders after EVERY
@@ -518,9 +517,17 @@ export class SessionManager {
   async search(
     query: string,
     signal: AbortSignal,
-  ): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
+  ): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
     try {
-      return (await this.api.sessions.search({ query }, signal)).result
+      const result = toSessionResult(await this.remote.session.search({ query }, signal))
+      if (!result.ok) return result
+      return {
+        ok: true,
+        value: {
+          items: [...result.value.items],
+          hasMore: result.value.hasMore,
+        },
+      }
     } catch (error: unknown) {
       return transportError(error)
     }
@@ -532,16 +539,20 @@ export class SessionManager {
    * (entity birth precedes the first message).
    * @param opts - target workspace or working directory, plus an optional caller-owned id.
    * @returns the create result.
-   */
+  */
   async create(
-    opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
-  ): Promise<RpcResult<{ sessionId: SessionId }>> {
+    opts: {
+      workspaceId?: WorkspaceId
+      cwd?: string
+      sessionId?: SessionId
+    } = {},
+  ): Promise<ClientResult<{ sessionId: SessionId }>> {
     try {
       const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
       const payload = opts.workspaceId !== undefined
         ? { workspaceId: opts.workspaceId, ...shared }
         : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
-      const { result } = await this.api.sessions.create(payload)
+      const result = toSessionResult(await this.remote.session.create(payload))
       if (result.ok) {
         this.recordMutation({ kind: 'upsert', summary: {
           sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
@@ -579,13 +590,13 @@ export class SessionManager {
    */
   async fork(
     opts: { sessionId: SessionId; atSeq?: number },
-  ): Promise<RpcResult<{ sessionId: SessionId }>> {
+  ): Promise<ClientResult<{ sessionId: SessionId }>> {
     try {
       const source = this.summaries.find(s => s.sessionId === opts.sessionId)
-      const { result } = await this.api.sessions.fork({
+      const result = toSessionResult(await this.remote.session.fork({
         sessionId: opts.sessionId,
         ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
-      })
+      }))
       const childId = result.ok
         ? result.value.sessionId
         : workspaceAttachSessionId(result.error)
@@ -672,240 +683,195 @@ export class SessionManager {
     this.notifier.markDirty()
   }
 
-  // ---- ConnectionController sinks (wired by boot) ----
+  // ---- Live control and Host-event sinks ----
 
   /**
-   * 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.
+   * Apply a complete control baseline or one later replacement frame.
+   * @param frame - baseline or live control replacement from Session Controller.
    */
-  handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
-    const frame = envelope.payload
-    if (frame.type === 'stream/error') return // Controller already treats this as stream failure
-    if (
-      frame.type === 'session/event'
-      && frame.event.type === 'user/message'
-      && frame.event.data.source.kind === 'user'
-    ) {
-      // session.list supplies the cold baseline, while a direct prompt or an
-      // admitted steer advances it between pulls. Max keeps replayed or
-      // repaired older user messages from moving the row backwards.
-      this.recordMutation({ kind: 'activity', sessionId: frame.sessionId, updatedAt: frame.event.time })
+  handleControlFrame(frame: SessionControlFrame): void {
+    if (frame.type === 'baseline') {
+      this.replaceControlBaseline(frame.value)
+      return
     }
-    if (frame.type === 'session/projection') {
-      // Finished host-computed value: land it in the resident store whether or
-      // not the Session is instantiated (list rows read the 'title' key). The
-      // synchronous markDirty keeps the list snapshot same-tick fresh (the
-      // store's own any-key channel is microtask-batched).
+    if (frame.type === 'projection') {
       this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq)
       this.notifier.markDirty()
       return
     }
-    if (frame.type === 'session/jobs') {
-      // Whole-set snapshot, so last-wins with no reconciliation. The Host omits
-      // the baseline for an empty set, which is the same fact an emptying change
-      // reports as `[]` — both land as an absent key.
+    if (frame.type === 'jobs') {
       if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId)
       else this.jobsBySession.set(frame.sessionId, frame.jobs)
       this.notifier.markDirty()
       return
     }
-    if (frame.type === 'session/subscribed') {
-      // Rows past the host's durable baseline rode state a restart lost; drop
-      // them so last-wins cannot pin a phantom value over recomputed truth.
-      this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
-      // Same re-baseline reasoning as the queue below: this generation sends a
-      // task baseline only when the set is non-empty, so a mirror kept from the
-      // previous generation would survive as a phantom list.
-      this.jobsBySession.delete(frame.sessionId)
-      this.notifier.markDirty()
-      // New mux-generation baseline: discard the previous queue snapshot.
-      // The host omits session/queue when the live queue is empty, so retaining
-      // it could replay stale work when the Session is instantiated later.
-      // This is the same re-baseline signal Session uses for its own mirror.
-      const buffered = this.pendingBuffers.get(frame.sessionId)
-      if (buffered !== undefined) {
-        const kept = buffered.filter(item => item.payload.type !== 'session/queue')
-        if (kept.length !== buffered.length) {
-          if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId)
-          else this.pendingBuffers.set(frame.sessionId, kept)
-        }
+    if (frame.type === 'queue') this.queues.set(frame.sessionId, frame.items)
+    if (frame.type === 'approval/requested' || frame.type === 'question/requested') {
+      let interactions = this.interactions.get(frame.sessionId)
+      if (interactions === undefined) {
+        interactions = new Map()
+        this.interactions.set(frame.sessionId, interactions)
       }
-    }
-    // List-level pending-interaction status (the sidebar amber dot): tracked
-    // for every session, instantiated or not; stable keys make replays idempotent.
-    if (frame.type === 'approval/requested') {
-      this.trackPending(frame.sessionId, `a:${frame.approvalId}`, 'approval')
-    } else if (frame.type === 'approval/resolved') {
-      this.resolvePending(frame.sessionId, `a:${frame.approvalId}`)
-    } else if (frame.type === 'question/requested') {
+      interactions.set(frame.interactionId, frame)
       this.trackPending(
         frame.sessionId,
-        `q:${envelope.rpcId}`,
-        questionInteractionStatus(frame.questions),
+        `${frame.type === 'approval/requested' ? 'a' : 'q'}:${frame.interactionId}`,
+        frame.type === 'approval/requested' ? 'approval' : questionInteractionStatus(frame.questions),
+      )
+    } else if (frame.type === 'approval/resolved' || frame.type === 'question/resolved') {
+      const interactions = this.interactions.get(frame.sessionId)
+      interactions?.delete(frame.interactionId)
+      if (interactions?.size === 0) this.interactions.delete(frame.sessionId)
+      this.resolvePending(
+        frame.sessionId,
+        `${frame.type === 'approval/resolved' ? 'a' : 'q'}:${frame.interactionId}`,
       )
-    } else if (frame.type === 'question/resolved') {
-      this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`)
     }
-    const session = this.sessions.get(frame.sessionId)
-    if (session === undefined) {
-      // Answerable requests never hit history: retain each live identity until
-      // instantiation, compacting replay duplicates and resolutions so list
-      // status cannot outlive the PendingWait the user would need to answer.
-      // Queue is a latest-value snapshot; everything else drops because open
-      // backfills it from history.
-      switch (frame.type) {
-        case 'approval/requested':
-        case 'question/requested':
-        case 'session/queue': {
-          const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
-          const key = frame.type === 'approval/requested'
-            ? `a:${frame.approvalId}`
-            : frame.type === 'question/requested' ? `q:${envelope.rpcId}` : 'queue'
-          const prior = buffer.findIndex(item => bufferedRequestKey(item) === key)
-          if (prior === -1) buffer.push(envelope)
-          else buffer[prior] = envelope
-          this.pendingBuffers.set(frame.sessionId, buffer)
-          return
-        }
-        case 'approval/resolved':
-        case 'question/resolved': {
-          const buffer = this.pendingBuffers.get(frame.sessionId)
-          if (buffer === undefined) return
-          const key = frame.type === 'approval/resolved'
-            ? `a:${frame.approvalId}`
-            : `q:${frame.questionRpcId}`
-          const prior = buffer.findIndex(item => bufferedRequestKey(item) === key)
-          if (prior !== -1) buffer.splice(prior, 1)
-          if (buffer.length === 0) this.pendingBuffers.delete(frame.sessionId)
-          return
-        }
-        default:
-          return
+    this.sessions.get(frame.sessionId)?.handleControlFrame(frame)
+  }
+
+  private replaceControlBaseline(baseline: SessionControlBaseline): void {
+    this.queues.clear()
+    for (const [sessionId, items] of Object.entries(baseline.queues)) {
+      this.queues.set(sessionId as SessionId, items)
+    }
+
+    this.jobsBySession.clear()
+    for (const [sessionId, jobs] of Object.entries(baseline.jobs)) {
+      if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs)
+    }
+
+    this.interactions.clear()
+    this.pendingInteractions.clear()
+    for (const interaction of [...baseline.approvals, ...baseline.questions]) {
+      let interactions = this.interactions.get(interaction.sessionId)
+      if (interactions === undefined) {
+        interactions = new Map()
+        this.interactions.set(interaction.sessionId, interactions)
+      }
+      interactions.set(interaction.interactionId, interaction)
+      let statuses = this.pendingInteractions.get(interaction.sessionId)
+      if (statuses === undefined) {
+        statuses = new Map()
+        this.pendingInteractions.set(interaction.sessionId, statuses)
       }
+      const approval = 'approvalId' in interaction
+      statuses.set(
+        `${approval ? 'a' : 'q'}:${interaction.interactionId}`,
+        approval ? 'approval' : questionInteractionStatus(interaction.questions),
+      )
+    }
+
+    for (const [sessionId, block] of Object.entries(baseline.projections)) {
+      const store = this.projectionStore(sessionId as SessionId)
+      store.truncate(block.asOfSeq)
+      store.seed(block)
+    }
+    for (const [sessionId, session] of this.sessions) {
+      session.replaceControl(
+        this.queues.get(sessionId) ?? [],
+        [...(this.interactions.get(sessionId)?.values() ?? [])],
+      )
     }
-    session.handleMuxEnvelope(envelope.rpcId, frame)
+    this.notifier.markDirty()
   }
 
   /**
-   * Host frame entry: list upkeep + per-instance running/removed/agent-error relay.
-   * @param envelope - the frame with its wire rpcId.
+   * Apply one Session-list addition forwarded through `ctx.remote.$on`.
+   * @param summary - current Host summary for the added Session.
    */
-  handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
-    const frame = envelope.payload
-    switch (frame.type) {
-      case 'host/session-added': {
-        this.mergeSummary({
-          sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
-          ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
-          ...(frame.origin !== undefined ? { origin: frame.origin } : {}),
-          ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
-          ...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}),
-        })
-        this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
-        if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
-          this.markCatalogParentExpandable(frame.parentSessionId)
-        }
-        if (frame.parentSessionId !== undefined
-          && (this.selected === frame.parentSessionId || this.openCatalogs.has(frame.parentSessionId))) {
-          this.scheduleCatalogRefresh(frame.parentSessionId)
-        }
-        return
-      }
-      case 'host/session-removed': {
-        const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
-        const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
-        this.recordMutation(durableSubagent
-          ? { kind: 'status', sessionId: frame.sessionId, running: false }
-          : { kind: 'remove', sessionId: frame.sessionId })
-        this.updateCatalogActivity(frame.sessionId, false)
-        if (durableSubagent) {
-          // An Activation detaching is not durable child deletion:
-          // keep its lineage and conversation while returning it to idle.
-          this.sessions.get(frame.sessionId)?.handleRunning(false)
-        } else {
-          this.sessions.get(frame.sessionId)?.handleRemoved()
-        }
-        this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
-        this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone
-        // Owner disposal already dropped these registry-side, but that lands on
-        // the mux stream while this frame rides the host stream, so the two have
-        // no relative order. Clearing here makes a detached Activation's rows
-        // disappear whichever arrives first.
-        this.jobsBySession.delete(frame.sessionId)
-        if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
-        // A pull already in flight was requested before this removal and can
-        // carry the pre-removal parentAvailable:true, which would resurrect
-        // the writable editor this invalidation just closed. Replay false over
-        // that response and queue one trailing refresh so the post-removal
-        // host truth converges.
-        const inflightCatalog = this.catalogInflight.get(frame.sessionId)
-        if (inflightCatalog !== undefined) {
-          inflightCatalog.parentAvailableOverride = false
-          this.catalogStale.add(frame.sessionId)
-        }
-        // The removed session can no longer be the delivery owner of its
-        // catalog: invalidate availability immediately. Removal schedules no
-        // catalog refresh, and without this an addressed child keeps a
-        // writable editor against a dead continuation owner until an
-        // unrelated refresh (or forever, for a closed menu).
-        const ownedCatalog = this.catalogs.get(frame.sessionId)
-        if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
-          this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false })
-        }
-        for (const [childId, address] of this.addresses) {
-          if (address.parentSessionId !== frame.sessionId) continue
-          this.sessions.get(childId)?.handleSubagentParentAvailable(false)
-        }
-        return
+  handleSessionAdded(summary: SessionSummary): void {
+    this.mergeSummary(summary)
+    this.sessions.get(summary.sessionId)?.handleBlank(summary.blank)
+    const projections = summary.projections
+    if (projections !== undefined) {
+      const store = this.projectionStore(summary.sessionId)
+      for (const [key, value] of Object.entries(projections.values)) {
+        store.apply(key, value, projections.asOfSeq)
       }
-      case 'host/session-status': {
-        this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
-        this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
-        this.updateCatalogActivity(frame.sessionId, frame.running)
-        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)
+    }
+    if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) {
+      this.markCatalogParentExpandable(summary.parentSessionId)
+    }
+    if (summary.parentSessionId !== undefined
+      && (this.selected === summary.parentSessionId || this.openCatalogs.has(summary.parentSessionId))) {
+      this.scheduleCatalogRefresh(summary.parentSessionId)
     }
   }
 
   /**
-   * The moment a connection generation dies (before any next-generation frame
-   * can arrive — onConnected waits for the readiness handshake while replayed
-   * frames flow from stream open, so clearing there would race the replay):
-   * drop generation-scoped live state. Interactions resolved while disconnected
-   * send no frame, so stale statuses and buffered answerable frames must not
-   * survive into the next generation — mux-open replay re-adds every still-pending
-   * request with its live rpcId.
-  */
-  handleDisconnected(): void {
-    if (this.pendingInteractions.size > 0) {
-      this.pendingInteractions.clear()
-      this.notifier.markDirty()
+   * Apply one Session removal forwarded through `ctx.remote.$on`.
+   * @param sessionId - removed Session identity.
+   */
+  handleSessionRemoved(sessionId: SessionId): void {
+    const summary = this.summaries.find(candidate => candidate.sessionId === sessionId)
+    const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(sessionId)
+    this.recordMutation(durableSubagent
+      ? { kind: 'status', sessionId, running: false }
+      : { kind: 'remove', sessionId })
+    this.updateCatalogActivity(sessionId, false)
+    if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false)
+    else this.sessions.get(sessionId)?.handleRemoved()
+    this.queues.delete(sessionId)
+    this.interactions.delete(sessionId)
+    this.pendingInteractions.delete(sessionId)
+    this.jobsBySession.delete(sessionId)
+    if (!durableSubagent) this.projectionStores.delete(sessionId)
+    const inflightCatalog = this.catalogInflight.get(sessionId)
+    if (inflightCatalog !== undefined) {
+      inflightCatalog.parentAvailableOverride = false
+      this.catalogStale.add(sessionId)
     }
-    for (const [sessionId, buffer] of [...this.pendingBuffers]) {
-      const kept = buffer.filter(item =>
-        item.payload.type !== 'approval/requested' && item.payload.type !== 'question/requested')
-      if (kept.length === buffer.length) continue
-      if (kept.length === 0) this.pendingBuffers.delete(sessionId)
-      else this.pendingBuffers.set(sessionId, kept)
+    const ownedCatalog = this.catalogs.get(sessionId)
+    if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
+      this.catalogs.set(sessionId, { ...ownedCatalog, parentAvailable: false })
     }
+    for (const [childId, address] of this.addresses) {
+      if (address.parentSessionId === sessionId) {
+        this.sessions.get(childId)?.handleSubagentParentAvailable(false)
+      }
+    }
+  }
+
+  /**
+   * Apply one live Agent running-state change.
+   * @param sessionId - Session whose Agent state changed.
+   * @param running - current Agent running state.
+   */
+  handleSessionStatus(sessionId: SessionId, running: boolean): void {
+    this.recordMutation({ kind: 'status', sessionId, running })
+    this.sessions.get(sessionId)?.handleRunning(running)
+    this.updateCatalogActivity(sessionId, running)
+  }
+
+  /**
+   * Advance Session-list activity from one user-authored durable message.
+   * @param sessionId - Session whose activity changed.
+   * @param updatedAt - durable message timestamp.
+   */
+  handleSessionActivity(sessionId: SessionId, updatedAt: number): void {
+    this.recordMutation({ kind: 'activity', sessionId, updatedAt })
   }
 
-  /** After each connection generation: refresh the session baseline and rebuild opened windows. */
+  /**
+   * Surface one live Agent failure on an already-materialized Session.
+   * @param sessionId - Session whose Agent failed.
+   * @param message - caller-visible failure description.
+   */
+  handleSessionError(sessionId: SessionId, message: string): void {
+    this.sessions.get(sessionId)?.handleAgentError(message)
+  }
+
+  /**
+   * Repair one re-established Host-event generation with queryable baselines.
+   * Opened Session follow streams resume independently through API Gateway.
+   */
   handleConnected(): void {
     void this.refreshList()
     const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
     if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)
     if (this.selected !== undefined) void this.refreshSubagents(this.selected)
     for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId)
-    for (const session of this.sessions.values()) void session.resync()
   }
 
   /** Debounce membership refetches while one parent catalog is selected or open. */
@@ -1125,7 +1091,13 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
 }
 
 /** Temporary source-plane bridge while the Host contract and client project build independently. */
-function workspaceAttachSessionId(error: RpcError): SessionId | undefined {
-  const candidate = error as unknown as { code: string; details: { sessionId?: SessionId } }
-  return candidate.code === 'workspace-attach-failed' ? candidate.details.sessionId : undefined
+function workspaceAttachSessionId(error: ClientFailure): SessionId | undefined {
+  return error.code === 'workspace-attach-failed' ? error.details.sessionId : undefined
+}
+
+/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */
+function toSessionResult<T>(
+  result: import('@deepseek-ai/dsh-typert-protocol').RemoteResult<T>,
+): ClientResult<T> {
+  return result.ok ? result : { ok: false, error: result.error as SessionError }
 }

+ 35 - 23
packages/client/runtime/src/client/sessions/pending.ts

@@ -1,15 +1,20 @@
-// PendingWait: the carrier-protocol half of a pending host interaction. The runtime owns only
-// envelope knowledge (rpcId backfill into a client-response); domain result encoding belongs to
-// the interaction's consumer package.
+// PendingWait: the render-facing half of one Session Controller interaction.
 
+import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import type {
-  ClientResponse, MuxFrame, RpcId, RpcReceipt, SessionId,
-} from '@deepseek-ai/dsh-api-remotes/client'
+  SessionApprovalRequest,
+  SessionInteractionId,
+  SessionInteractionResult,
+  SessionQuestionRequest,
+  SessionRespondReceipt,
+  SessionRespondRequest,
+} from '@deepseek-ai/dsh-api-session-controller/types'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 
 /** Kind-keyed payload map: the requested frame's domain fields (envelope fields stripped). */
 export interface PendingPayloads {
-  approval: Omit<Extract<MuxFrame, { type: 'approval/requested' }>, 'type' | 'sessionId'>
-  question: Omit<Extract<MuxFrame, { type: 'question/requested' }>, 'type' | 'sessionId'>
+  approval: Omit<SessionApprovalRequest, 'interactionId' | 'sessionId'>
+  question: Omit<SessionQuestionRequest, 'interactionId' | 'sessionId'>
 }
 
 /** Pending-interaction discriminant (the keys of PendingPayloads). */
@@ -26,53 +31,60 @@ const KEY_PREFIX: Record<PendingKind, string> = { approval: 'a', question: 'q' }
 
 /**
  * One pending host-owned interaction wait: an immutable render face
- * (kind/key/sessionId/payload) plus the response carrier. respond() backfills
- * the requested frame's rpcId into a client-response envelope — no consumer
- * ever sees the raw rpcId. Settlement is expressed only by pending-list
+ * (kind/key/sessionId/payload) plus the response carrier. respond() addresses
+ * the Host's opaque interaction identity. Settlement is expressed only by pending-list
  * membership (the settled flag is a fail-loud guard, not a render input).
  */
 export class PendingWait<K extends PendingKind = PendingKind> {
   /** Interaction kind (union discriminant). */
   readonly kind: K
-  /** Opaque render identity, `<prefix>:<rpcId>` — stable across baseline replay, usable as a React key. */
+  /** Opaque render identity, stable across baseline replay and usable as a React key. */
   readonly key: string
   /** Owning session. */
   readonly sessionId: SessionId
   /** The requested frame's domain fields, verbatim. */
   readonly payload: PendingPayloads[K]
   #settled = false
-  readonly #rpcId: RpcId
-  readonly #respond: (message: ClientResponse) => Promise<RpcReceipt>
+  readonly #interactionId: SessionInteractionId
+  readonly #respond: (request: SessionRespondRequest) => Promise<RemoteResult<SessionRespondReceipt>>
 
   /**
    * Minted by Session on a requested frame (public construction is the test-fixture path).
    * @param kind - interaction kind.
-   * @param rpcId - the requested frame's stable envelope id (kept private; respond echoes it).
+   * @param interactionId - the Host-minted stable interaction identity.
    * @param sessionId - owning session.
    * @param payload - the requested frame's domain fields.
-   * @param respond - the client-response carrier (api.respond).
+   * @param respond - Session Controller response method.
    */
   constructor(
-    kind: K, rpcId: RpcId, sessionId: SessionId, payload: PendingPayloads[K],
-    respond: (message: ClientResponse) => Promise<RpcReceipt>,
+    kind: K, interactionId: SessionInteractionId, sessionId: SessionId, payload: PendingPayloads[K],
+    respond: (request: SessionRespondRequest) => Promise<RemoteResult<SessionRespondReceipt>>,
   ) {
     this.kind = kind
-    this.key = `${KEY_PREFIX[kind]}:${rpcId}`
+    this.key = `${KEY_PREFIX[kind]}:${interactionId}`
     this.sessionId = sessionId
     this.payload = payload
-    this.#rpcId = rpcId
+    this.#interactionId = interactionId
     this.#respond = respond
   }
 
   /**
-   * Send a result for this wait: wraps it into the client-response envelope
-   * with the rpcId backfilled. Throws synchronously once settled.
+   * Send a result for this wait. Throws synchronously once settled and rejects
+   * when the generated Remote call itself fails.
    * @param result - the result shell (ok value / error envelope), domain-encoded by the caller.
    * @returns the carrier receipt.
    */
-  respond(result: ClientResponse['result']): Promise<RpcReceipt> {
+  respond(result: SessionInteractionResult): Promise<SessionRespondReceipt> {
     if (this.#settled) throw new Error(`pending wait ${this.key} is already settled`)
-    return this.#respond({ type: 'client-response', rpcId: this.#rpcId, result })
+    return this.send(result)
+  }
+
+  private async send(result: SessionInteractionResult): Promise<SessionRespondReceipt> {
+    const response = await this.#respond({ interactionId: this.#interactionId, result })
+    if (!response.ok) {
+      throw new Error(`session interaction response failed: ${response.error.code}: ${response.error.message}`)
+    }
+    return response.value
   }
 
   /** Session-only settlement mark (the authoritative resolved frame arrived); respond() throws afterwards. */

+ 11 - 13
packages/client/runtime/src/client/sessions/projection-store.ts

@@ -2,8 +2,8 @@
  * Generic per-session projection value store (push model; see the
  * session-projection subsystem page, docs/subsystems/session-projection.md):
  * the host is the only computation site; the client holds finished
- * whole values per key — `key → { value, seq }` — seeded by the history tail
- * page's projections block and updated by `session/projection` push frames,
+ * whole values per key — `key → { value, seq }` — seeded by a Session page's
+ * projections block and updated by Session Controller `projection` frames,
  * under the single rule **higher seq wins**. No client-side domain folding
  * exists: a domain ships projection support with zero client code. Per-key
  * bare observable faces feed `useProjection` (ui-renderer binds them).
@@ -40,8 +40,8 @@ export type UseProjection = {
 }
 
 /**
- * Tail-page projections baseline — structurally identical to the wire's
- * `SessionProjectionsBlock` (apiproxy api layer), restated here so the
+ * Tail-page projections baseline — structurally identical to Session
+ * Controller's `SessionProjectionsBlock`, restated here so the
  * React-free store depends only on the type table, not the wire package's
  * response vocabulary.
  */
@@ -49,7 +49,7 @@ export interface ProjectionsBaseline {
   /** The consistent-cut seq (equals the window tail seq by construction). */
   asOfSeq: number
   /** Whole current values by key; a registered key absent here means the capability is absent. */
-  values: Partial<SessionProjectionMap>
+  values: Readonly<Record<string, unknown>>
 }
 
 /** One key's row: the latest finished value and the seq it is consistent with. */
@@ -126,7 +126,7 @@ export class ProjectionValueStore {
   }
 
   /**
-   * Apply one finished value (the `session/projection` push-frame path).
+   * Apply one finished value from the Session control stream.
    * @param key - projection key.
    * @param value - whole value computed by the host unit.
    * @param seq - the unit's watermark at emission.
@@ -160,13 +160,11 @@ export class ProjectionValueStore {
   }
 
   /**
-   * Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`):
-   * a row claiming knowledge beyond the host's own durable baseline rode
-   * state a restart lost — under last-wins it would wrongly outrank the
-   * host's recomputed (lower-seq) values forever. Durable replay and the next
-   * baseline re-seed whatever truly survived (the title-snapshot precedent,
-   * generalized).
-   * @param lastSeq - the subscribed frame's durable baseline seq.
+   * Drop rows beyond a replacement control baseline. Such rows describe
+   * process state the Host lost before persisting it and would otherwise
+   * outrank recomputed lower-seq values forever. The caller seeds the new
+   * baseline immediately afterward.
+   * @param lastSeq - highest durable sequence reflected by the baseline.
    */
   truncate(lastSeq: number): void {
     for (const [key, row] of this.rows) {

+ 13 - 20
packages/client/runtime/src/client/sessions/queue-mirror.ts

@@ -1,5 +1,5 @@
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { MuxFrame } from '@deepseek-ai/dsh-api-remotes/client'
+import type { SessionQueuedItem } from '@deepseek-ai/dsh-api-session-controller/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { QueuedMessage } from './conversation.ts'
 
@@ -18,7 +18,7 @@ function textOf(content: readonly ContentBlock[]): string | null {
   return content.map(block => block.text).join('')
 }
 
-type QueueItems = Extract<MuxFrame, { type: 'session/queue' }>['items']
+type QueueItems = readonly SessionQueuedItem[]
 
 /** Authoritative transient queue projection and durable steering handoff. */
 export class SessionQueueMirror {
@@ -32,29 +32,22 @@ export class SessionQueueMirror {
     return this.current
   }
 
-  /**
-   * Drop the stale generation before its replacement queue baseline arrives.
-   * @returns whether any projected queue row was removed.
-   */
-  reset(): boolean {
-    if (this.current.length === 0) return false
-    this.current = []
-    return true
-  }
-
   /**
    * Replace from one authoritative stream queue frame.
    * @param items - complete host queue snapshot.
    */
   replace(items: QueueItems): void {
-    this.current = items.map(item => ({
-      id: item.id,
-      messageId: item.message.id,
-      placement: item.placement,
-      content: item.message.content,
-      preview: previewOf(item.message.content),
-      text: textOf(item.message.content),
-    }))
+    this.current = items.map((item) => {
+      const content = item.message.content as unknown as readonly ContentBlock[]
+      return {
+        id: item.id,
+        messageId: item.message.id,
+        placement: item.placement,
+        content,
+        preview: previewOf(content),
+        text: textOf(content),
+      }
+    })
   }
 
   /**

+ 2 - 2
packages/client/runtime/src/client/sessions/remotes.ts

@@ -8,5 +8,5 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 
-/** The generated Remote namespaces a Session and its manager call. */
-export type SessionRemotes = Pick<Context['remote'], 'commands'>
+/** The generated Remote namespaces and Gateway stream factory a Session cluster uses. */
+export type SessionRemotes = Pick<Context['remote'], '$stream' | 'commands' | 'session'>

+ 48 - 23
packages/client/runtime/src/client/sessions/service.ts

@@ -16,11 +16,9 @@
  */
 import type { Context, Fiber } from '@deepseek-ai/cordis'
 import type {
-  IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, JobView, WorkspaceId,
+  ClientFailure, ClientResult, IApiClient, SessionId, SubagentAddress, JobView, WorkspaceId,
 } from '@deepseek-ai/dsh-api-remotes/client'
-// Value import from the inline-safe wire layer (not the connection plugin):
-// plugin-to-plugin value imports are a bundle purity error.
-import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
+import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-api-session-controller/client'
 import type {
   HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
 } from '@deepseek-ai/dsh-client-ui-slots'
@@ -88,9 +86,9 @@ export interface SessionListState {
   /** Direct durable catalogs keyed by their selected parent address. */
   subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
   /**
-   * Background jobs each session can see, mirrored last-wins from
-   * `session/jobs`. A missing key is an empty set — the Host sends no baseline
-   * for a session without tasks — so consumers read absence, never a sentinel.
+   * Background jobs each session can see, mirrored last-wins from Session
+   * Controller's control baseline and `jobs` frames. A missing key is an empty
+   * set, so consumers read absence rather than a sentinel.
    */
   jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
   /** Current session's catalog-derived address, absent on ordinary navigation. */
@@ -112,7 +110,7 @@ export class SessionCreateError extends Error {
    * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
    */
   constructor(
-    readonly rpcError: RpcError,
+    readonly rpcError: ClientFailure,
     readonly requestedSessionId: SessionId | undefined,
   ) {
     super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
@@ -128,7 +126,7 @@ export class SessionForkError extends Error {
    * @param sourceSessionId - the session the fork was cut from.
    */
   constructor(
-    readonly rpcError: RpcError,
+    readonly rpcError: ClientFailure,
     readonly sourceSessionId: SessionId,
   ) {
     super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
@@ -441,24 +439,56 @@ export class SessionRuntime implements ISessions {
   search(
     query: string,
     signal: AbortSignal,
-  ): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
+  ): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
     return this.manager.search(query, signal)
   }
 
   /**
-   * Route a mux stream envelope into the Session object layer.
-   * @param envelope - validated mux stream envelope.
+   * Apply one Session Controller live-control frame.
+   * @param frame - baseline or live control replacement.
    */
-  handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
-    this.manager.handleMuxEnvelope(envelope)
+  handleControlFrame(frame: Parameters<SessionManager['handleControlFrame']>[0]): void {
+    this.manager.handleControlFrame(frame)
   }
 
   /**
-   * Route a Host stream envelope into the Session object layer.
-   * @param envelope - validated Host stream envelope.
+   * Apply one remotely forwarded Session-list addition.
+   * @param summary - current Host summary for the added Session.
    */
-  handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
-    this.manager.handleHostEnvelope(envelope)
+  handleSessionAdded(summary: Parameters<SessionManager['handleSessionAdded']>[0]): void {
+    this.manager.handleSessionAdded(summary)
+  }
+
+  /**
+   * Apply one remotely forwarded Session removal.
+   * @param sessionId - removed Session identity.
+   */
+  handleSessionRemoved(sessionId: Parameters<SessionManager['handleSessionRemoved']>[0]): void {
+    this.manager.handleSessionRemoved(sessionId)
+  }
+
+  /**
+   * Apply one remotely forwarded running-state change.
+   * @param args - Session identity and current Agent running state.
+   */
+  handleSessionStatus(...args: Parameters<SessionManager['handleSessionStatus']>): void {
+    this.manager.handleSessionStatus(...args)
+  }
+
+  /**
+   * Apply one remotely forwarded list-activity change.
+   * @param args - Session identity and durable activity timestamp.
+   */
+  handleSessionActivity(...args: Parameters<SessionManager['handleSessionActivity']>): void {
+    this.manager.handleSessionActivity(...args)
+  }
+
+  /**
+   * Apply one remotely forwarded Agent failure.
+   * @param args - Session identity and caller-visible failure description.
+   */
+  handleSessionError(...args: Parameters<SessionManager['handleSessionError']>): void {
+    this.manager.handleSessionError(...args)
   }
 
   /** Rebuild the Session baseline and every opened window after connection. */
@@ -466,11 +496,6 @@ export class SessionRuntime implements ISessions {
     this.manager.handleConnected()
   }
 
-  /** Drop generation-scoped live interaction state the moment a connection generation dies. */
-  handleDisconnected(): void {
-    this.manager.handleDisconnected()
-  }
-
   /**
    * Create a session on the host. Resolution guarantee: by the time the
    * promise resolves, the created session is in the list store and

+ 212 - 219
packages/client/runtime/src/client/sessions/session.ts

@@ -1,12 +1,31 @@
-// Sessions remain resident after creation so they continue consuming mux frames off-screen.
+// Sessions remain resident after creation so their open Remote sources keep running off-screen.
 
 import type { Context } from '@deepseek-ai/cordis'
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type {
-  HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError,
-  RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
+  ClientFailure, ClientResult, IApiClient, MessageId, PromptContentPart, QueueAction,
+  SessionId, SubagentAddress,
 } from '@deepseek-ai/dsh-api-remotes/client'
+import {
+  SessionEventStream,
+  sessionStreamFailure,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import type {
+  SessionEventChange,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import type {
+  SessionAddress,
+  SessionApprovalRequest,
+  SessionControlFrame,
+  SessionEventEntry,
+  SessionQuestionRequest,
+  SessionQueuedItem,
+  SessionRequestId,
+  SessionError,
+  SessionToolView,
+} from '@deepseek-ai/dsh-api-session-controller/types'
 // Value import from the inline-safe wire layer (not the connection plugin):
 // plugin-to-plugin value imports are a bundle purity error.
 import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -64,17 +83,16 @@ export interface SessionOptions {
  */
 export class Session implements SessionFace {
   // ---- Window and derived state (all private; the snapshot is the only read API) ----
-  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 eventWindow: SessionEvent[] = []
+  /** Wire views aligned with `eventWindow` by index (envelope annotations; undefined = no view).
+   *  Kept parallel so `eventWindow` remains the raw log slice (model-visible ⟺ logged). */
+  private views: (SessionToolView | undefined)[] = []
   private baseSeq = 0
   private hasMore = false
   private openState: OpenState = 'cold'
-  private openError: RpcError | null = null
+  private openError: ClientFailure | 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. Stale doOpen
+  /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale
    *  passes drop all writes once the generation moves on. */
   private openGeneration = 0
   private loadingOlder = false
@@ -101,18 +119,14 @@ export class Session implements SessionFace {
   private removed = false
   private promptError: PromptError | null = null
   private lastAgentError: string | null = null
-  /** Live events buffered during open/resync and stitched by sequence once history lands. */
-  private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
-  /** Gap repair in flight; live events detour to the buffer until the tail page lands. */
-  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
+  /** Owns the addressed page/follow lifecycle while this Session is open. */
+  private events: SessionEventStream | undefined
 
   /**
    * Per-session projection value store (push model; see the session-projection
    * subsystem page, docs/subsystems/session-projection.md): finished whole
-   * values computed on the host, seeded by the tail page's
-   * projections block and updated by `session/projection` frames under the
+   * values computed on the Host, seeded by the tail page's
+   * projections block and updated by Session Controller control frames under the
    * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
    * (the useProjection resolution face); the conversation snapshot never
    * carries projection values, and no client-side domain folding exists.
@@ -191,7 +205,7 @@ export class Session implements SessionFace {
     content: PromptContentPart[],
     mode: 'queue' | 'steer',
     signal?: AbortSignal,
-  ): Promise<RpcResult<{ accepted: true }>> {
+  ): Promise<ClientResult<{ accepted: true }>> {
     this.promptError = null
     this.lastAgentError = null
     // Synchronous, before the first await: the blank → engaging edge must be
@@ -200,15 +214,17 @@ export class Session implements SessionFace {
     this.promptAttempted = true
     if (this.blankBit) this.firstPromptPendingTurn = true
     this.notifier.markDirty()
-    let result: RpcResult<{ accepted: true }>
+    let result: ClientResult<{ accepted: true }>
     try {
       if (this.address === undefined) {
-        result = (await this.api.sessions.prompt({
+        const clientTimeZone = resolvedClientTimeZone()
+        result = toSessionResult(await this.remote.session.prompt({
+          requestId: randomUUID() as SessionRequestId,
           sessionId: this.sessionId,
           mode,
           content,
-          clientTimeZone: resolvedClientTimeZone(),
-        }, signal)).result
+          clientTimeZone,
+        }, signal))
       } else if (this.address.mode === 'one-shot') {
         result = {
           ok: false,
@@ -270,13 +286,13 @@ export class Session implements SessionFace {
    */
   async readAttachment(
     attachmentId: AttachmentIdType,
-  ): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
+  ): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
     try {
-      const result = (await this.api.sessions.attachment({
+      const result = await this.remote.session.attachment({
         sessionId: this.sessionId,
         attachmentId,
-      })).result
-      if (!result.ok) return result
+      })
+      if (!result.ok) return toSessionResult(result)
       const binary = atob(result.value.data)
       const data = Uint8Array.from(binary, char => char.charCodeAt(0))
       return { ok: true, value: { attachment: result.value.attachment, data } }
@@ -286,9 +302,9 @@ export class Session implements SessionFace {
   }
 
   /** Apply one operation to a still-pending queue occurrence. */
-  async updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
+  async updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>> {
     try {
-      return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result
+      return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action }))
     } catch (error) {
       return transportError(error)
     }
@@ -303,10 +319,10 @@ export class Session implements SessionFace {
    * defensive).
    * @returns the cancel result.
    */
-  async cancel(): Promise<RpcResult<{ accepted: true }>> {
+  async cancel(): Promise<ClientResult<{ accepted: true }>> {
     const address = this.address
     if (address !== undefined && address.mode === 'one-shot') {
-      const result: RpcResult<{ accepted: true }> = {
+      const result: ClientResult<{ accepted: true }> = {
         ok: false,
         error: {
           code: 'subagent-delivery-unavailable',
@@ -318,11 +334,11 @@ export class Session implements SessionFace {
       this.notifier.markDirty()
       return result
     }
-    let result: RpcResult<{ accepted: true }>
+    let result: ClientResult<{ accepted: true }>
     try {
       result = address !== undefined
         ? (await this.api.subagents.interrupt(address)).result
-        : (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
+        : toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId }))
     } catch (error) {
       result = transportError(error)
     }
@@ -338,13 +354,13 @@ export class Session implements SessionFace {
    * projection cell from the response's `{title, seq}` under the store's
    * higher-seq-wins rule (the push frame arriving later is a no-op replay),
    * so the list row and any useProjection('title') reader update without
-   * waiting for the mux frame.
+   * waiting for the control-stream projection update.
    * @param title - raw title text (the host normalizes acceptance).
    * @returns the rename result (normalized accepted title + title event seq).
    */
-  async rename(title: string): Promise<RpcResult<{ title: string; seq: number }>> {
+  async rename(title: string): Promise<ClientResult<{ title: string; seq: number }>> {
     try {
-      const { result } = await this.api.sessions.rename({ sessionId: this.sessionId, title })
+      const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title }))
       if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
       return result
     } catch (error) {
@@ -380,63 +396,37 @@ export class Session implements SessionFace {
   /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
   async loadOlder(): Promise<void> {
     if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
+    const events = this.events
+    if (events === undefined) return
     this.loadingOlder = true
     this.notifier.markDirty()
     try {
-      const { result } = await this.history({ 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
-        this.conversation.prepend([], this.hasMore)
-        return
-      }
-      const tail = older[older.length - 1]
-      if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
-        // 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
-        this.conversation.prepend([], 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.conversation.prepend(older.map(conversationInput), this.hasMore)
+      await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
     } catch (error) {
-      console.error('[web-runtime] loadOlder failed:', error)
+      if (sessionStreamFailure(error) === undefined) {
+        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'. */
+  /** Rebuild an opened history source after address replacement.
+   *  Invalidates any in-flight open first; queue and pending-interaction state belongs
+   *  to the independently reconnecting control stream and remains untouched. */
   async resync(): Promise<void> {
-    // The queue mirror is NOT cleared here: onConnected (which drives resync)
-    // races the mux frames — the fresh generation's baseline may have landed
-    // already, and the host never resends it. The mirror re-baselines on the
-    // session/subscribed frame instead (same stream as the queue snapshot
-    // that follows it, so ordering is guaranteed).
     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++
+    const events = this.events
+    this.events = undefined
+    await events?.dispose()
     this.openPromise = null
     this.openState = 'cold'
     this.openError = null
-    this.events = []
+    this.eventWindow = []
     this.views = []
     this.baseSeq = 0
-    // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
-    // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
-    this.pending.clear()
-    this.pendingRev++
-    this.subscribedLastSeq = null
-    this.liveBuffer = []
     this.notifier.markDirty()
     await this.open()
   }
@@ -464,57 +454,42 @@ export class Session implements SessionFace {
   // ---- 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.
+   * Replace every transient control value for this Session from one stream baseline.
+   * @param queue - complete pending queue for this Session.
+   * @param interactions - complete pending approval and question set.
    */
-  handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void {
+  replaceControl(
+    queue: readonly SessionQueuedItem[],
+    interactions: readonly (SessionApprovalRequest | SessionQuestionRequest)[],
+  ): void {
+    this.queueMirror.replace(queue)
+    this.pending.clear()
+    this.pendingRev++
+    for (const interaction of interactions) this.requestInteraction(interaction)
+    this.notifier.markDirty()
+  }
+
+  /**
+   * Apply one Session-addressed live control update.
+   * @param frame - queue or interaction replacement addressed to this Session.
+   */
+  handleControlFrame(frame: Exclude<SessionControlFrame, { type: 'baseline' | 'jobs' | 'projection' }>): void {
     switch (frame.type) {
-      case 'session/event': {
-        this.acceptLiveEvent(frame.event, frame.view)
-        return
-      }
-      case 'session/queue': {
+      case 'queue':
         this.queueMirror.replace(frame.items)
         this.notifier.markDirty()
         return
-      }
-      case 'session/subscribed': {
-        this.subscribedLastSeq = frame.lastSeq
-        // New mux-generation baseline: the host pushes this session's queue
-        // snapshot AFTER the subscribed frame on the same stream, so the
-        // stale mirror clears here — race-free against onConnected/resync
-        // timing (clearing there could wipe a baseline that already landed).
-        if (this.queueMirror.reset()) this.notifier.markDirty()
-        return
-      }
-      case 'approval/requested': {
-        const { type: _type, sessionId: _sid, ...payload } = frame
-        this.mint(new PendingWait('approval', rpcId, this.sessionId, payload, m => this.api.respond(m)))
+      case 'approval/requested':
+      case 'question/requested':
+        this.requestInteraction(frame)
         this.notifier.markDirty()
         return
-      }
-      case 'approval/resolved': {
-        for (const item of this.pending.values()) {
-          if (item.kind === 'approval' && item.payload.approvalId === frame.approvalId) this.settle(item)
-        }
-        this.notifier.markDirty()
+      case 'approval/resolved':
+        this.resolveInteraction(`a:${frame.interactionId}`)
         return
-      }
-      case 'question/requested': {
-        const { type: _type, sessionId: _sid, ...payload } = frame
-        this.mint(new PendingWait('question', rpcId, this.sessionId, payload, m => this.api.respond(m)))
-        this.notifier.markDirty()
-        return
-      }
-      case 'question/resolved': {
-        const item = this.pending.get(`q:${frame.questionRpcId}`)
-        if (item !== undefined) this.settle(item)
-        this.notifier.markDirty()
+      case 'question/resolved':
+        this.resolveInteraction(`q:${frame.interactionId}`)
         return
-      }
-      default:
-        return // stream/error never reaches Session (Controller converges it); unknown frames ignored (documented default)
     }
   }
 
@@ -562,8 +537,8 @@ export class Session implements SessionFace {
   }
 
   /**
-   * Blank-bit relay from the authoritative summary source (list baseline and
-   * the session-added frame). Monotone: once any signal (local first send,
+   * Blank-bit relay from the authoritative summary source (`session.list` and
+   * `api-session/added`). Monotone: once any signal (local first send,
    * running flip, an earlier summary) cleared it, a stale true never
    * re-blanks.
    * @param blank - the summary's derived empty-log bit.
@@ -575,14 +550,14 @@ export class Session implements SessionFace {
     this.notifier.markDirty()
   }
 
-  /** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
+  /** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */
   handleRemoved(): void {
     this.removed = true
     this.notifier.markDirty()
   }
 
   /**
-   * host/agent-error relay: the only outlet for live failures with no turn position.
+   * `api-session/error` relay: the outlet for live failures with no turn position.
    * @param message - the stringified error.
    */
   handleAgentError(message: string): void {
@@ -590,8 +565,13 @@ export class Session implements SessionFace {
     this.notifier.markDirty()
   }
 
-  /** No-op because session instances remain resident. */
-  dispose(): void {}
+  /** Stop the Session's live Remote source. */
+  dispose(): void {
+    this.openGeneration++
+    const events = this.events
+    this.events = undefined
+    void events?.dispose()
+  }
 
   /** Rebuild the current window after a low-frequency Definition or view registration change. */
   rebuildConversationRegistry(): void {
@@ -613,66 +593,100 @@ export class Session implements SessionFace {
     this.pendingRev++
   }
 
-  /** @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 requestInteraction(interaction: SessionApprovalRequest | SessionQuestionRequest): void {
+    if ('approvalId' in interaction) {
+      const { interactionId, sessionId: _sessionId, ...payload } = interaction
+      this.mint(new PendingWait(
+        'approval', interactionId, this.sessionId, payload,
+        request => this.remote.session.respond(request),
+      ))
+      return
+    }
+    const { interactionId, sessionId: _sessionId, ...payload } = interaction
+    this.mint(new PendingWait(
+      'question', interactionId, this.sessionId, payload,
+      request => this.remote.session.respond(request),
+    ))
+  }
+
+  private resolveInteraction(key: string): void {
+    const interaction = this.pending.get(key)
+    if (interaction === undefined) return
+    this.settle(interaction)
+    this.notifier.markDirty()
+  }
+
+  /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */
   private async doOpen(generation: number): Promise<void> {
     this.openState = 'loading'
     this.openError = null
     this.notifier.markDirty()
+    const events = new SessionEventStream(this.remote, this.sessionAddress(), {
+      publish: (change) => {
+        if (generation !== this.openGeneration || this.events !== events) return
+        this.acceptEventChange(change)
+      },
+      failed: (error) => {
+        this.failEventStream(events, generation, error)
+      },
+    })
+    this.events = events
     try {
-      let { result } = await this.history({ 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, result.value.projections)
-      // Gap detection: 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.history({ maxMessages: PAGE_MESSAGES })).result
-        if (generation !== this.openGeneration) return
-        if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
-      }
+      await events.open({ maxMessages: PAGE_MESSAGES })
+      if (generation !== this.openGeneration || this.events !== events) return
       this.openState = 'open'
     } catch (error) {
-      if (generation !== this.openGeneration) return
+      if (generation !== this.openGeneration || this.events !== events) return
+      this.events = undefined
       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
+      this.openError = openFailure(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.
-   *  A carried projections block seeds the value store (higher seq wins, so a stale
-   *  baseline cannot overwrite a newer push frame); the window events themselves are
-   *  never folded — the host is the only computation site. */
-  private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
-    this.events = entries.map(e => e.event)
-    this.views = entries.map(e => e.view)
-    this.baseSeq = this.events[0]?.seq ?? 0
+  /** Apply one contiguous journal update already reconciled by the Remote stream. */
+  private acceptEventChange(change: SessionEventChange): void {
+    switch (change.type) {
+      case 'replace':
+        this.installWindow(change.entries, change.hasMore, change.page.projections)
+        return
+      case 'prepend':
+        this.prependWindow(change.entries, change.hasMore)
+        return
+      case 'append': {
+        const entry = conversationInput(change.entry)
+        this.scheduleConversation(this.appendLive(entry.event, entry.view))
+      }
+    }
+  }
+
+  /** Replace the complete contiguous window and apply page-owned projection metadata. */
+  private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
+    const normalized = entries.map(conversationInput)
+    this.eventWindow = normalized.map(entry => entry.event)
+    this.views = normalized.map(entry => entry.view)
+    this.baseSeq = this.eventWindow[0]?.seq ?? 0
     this.hasMore = hasMore
-    if (this.events.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
-    this.conversation.replaceWindow(entries.map(conversationInput), hasMore)
+    if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
+    this.conversation.replaceWindow(normalized, hasMore)
     if (projections !== undefined) this.projections.seed(projections)
-    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): ConversationPublication {
-    const tailSeq = this.windowTailSeq()
-    if (tailSeq !== null && event.seq <= tailSeq) return 'none' // replay overlap, drop
-    this.events.push(event)
+  /** Prepend one stream-validated history page. */
+  private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void {
+    const normalized = entries.map(conversationInput)
+    this.eventWindow = [...normalized.map(entry => entry.event), ...this.eventWindow]
+    this.views = [...normalized.map(entry => entry.view), ...this.views]
+    this.baseSeq = this.eventWindow[0]?.seq ?? 0
+    this.hasMore = hasMore
+    this.conversation.prepend(normalized, hasMore)
+  }
+
+  /** Append one stream-validated live event. */
+  private appendLive(event: SessionEvent, view?: SessionToolView): ConversationPublication {
+    this.eventWindow.push(event)
     this.views.push(view)
     if (event.type === 'turn/start') this.firstPromptPendingTurn = false
     const queueChanged = this.queueMirror.acceptDurable(event)
@@ -680,56 +694,22 @@ export class Session implements SessionFace {
     return queueChanged ? 'immediate' : publication
   }
 
-  /** 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 (a gap is an
-   *  expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
-   *  raw range, which lets Conversation Definitions correlate every recorded event between its
-   *  ends and lets a compaction checkpoint resolve its cited summary event. */
-  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.scheduleConversation(this.appendLive(event, view))
-  }
-
   /** Route assembler cadence into the Session's existing microtask/RAF notifier. */
   private scheduleConversation(publication: ConversationPublication): void {
     if (publication === 'immediate') this.notifier.markDirty()
     else if (publication === 'animation-frame') this.notifier.markFrameDirty()
   }
 
-  /** Resync-lite: 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.history({ 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, result.value.projections)
-      }
-    } catch (error) {
-      console.error('[web-runtime] gap repair failed:', error)
-    } finally {
-      this.stitching = false
-    }
-  }
-
-  private windowTailSeq(): number | null {
-    const tail = this.events[this.events.length - 1]
-    return tail === undefined ? null : tail.seq
+  /** Publish a terminal background failure only while this stream still owns the Session. */
+  private failEventStream(events: SessionEventStream, generation: number, error: unknown): void {
+    if (generation !== this.openGeneration || this.events !== events) return
+    this.openGeneration++
+    this.events = undefined
+    this.openPromise = null
+    this.openState = 'error'
+    this.openError = openFailure(error)
+    void events.dispose()
+    this.notifier.markDirty()
   }
 
   private buildSnapshot(): ConversationSnapshot {
@@ -771,21 +751,34 @@ export class Session implements SessionFace {
     }
   }
 
-  /** Select ordinary or addressed history transport from the stored browser fact. */
-  private history(payload: { beforeSeq?: number; maxMessages?: number }): Promise<RpcResponse<{
-    events: HistoryEntry[]
-    hasMore: boolean
-    projections?: ProjectionsBaseline
-  }>> {
+  private sessionAddress(): SessionAddress {
     return this.address === undefined
-      ? this.api.sessions.history({ sessionId: this.sessionId, ...payload })
-      : this.api.subagents.history({ ...this.address, ...payload })
+      ? { kind: 'session', sessionId: this.sessionId }
+      : { kind: 'subagent', ...this.address }
   }
 }
 
 /** Convert one wire history row into the assembler's transport-neutral input. */
-function conversationInput(entry: HistoryEntry): ConversationEventInput {
-  return { event: entry.event, view: entry.view }
+function conversationInput(entry: SessionEventEntry): ConversationEventInput {
+  return {
+    event: entry.event as SessionEvent,
+    view: entry.view,
+  }
+}
+
+/** Convert a terminal Session stream failure to the Client error vocabulary. */
+function openFailure(error: unknown): ClientFailure {
+  const failure = sessionStreamFailure(error)
+  if (failure !== undefined) return failure as SessionError
+  const folded = transportError<never>(error)
+  /* v8 ignore next -- transportError never returns an ok result. */
+  if (folded.ok) throw new Error('transportError returned an unexpected success')
+  return folded.error
+}
+
+/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */
+function toSessionResult<T>(result: RemoteResult<T>): ClientResult<T> {
+  return result.ok ? result : { ok: false, error: result.error as SessionError }
 }
 
 /** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */

+ 79 - 58
packages/client/runtime/tests/client-apply.client.spec.ts

@@ -1,33 +1,37 @@
 /**
  * Runtime plugin browser-half apply: slots + object services mounting over the
- * connection handle, stream-loop sink wiring into the object layer, and the
- * fiber-scoped loop teardown.
+ * connection handle, Remote stream wiring into the object layer, and
+ * fiber-scoped stream teardown.
  */
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
-import type { ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client'
-import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
+import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-api-session-controller/client'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
 import * as RuntimeClient from '../src/client/index.ts'
 import type { ConversationNodeDefinition } from '../src/client/contract/conversation.ts'
 import { Session } from '../src/client/sessions/session.ts'
-import type { SessionRuntime } from '../src/client/sessions/service.ts'
-import type { WorkspaceRuntime } from '../src/client/workspaces/service.ts'
+import { SessionRuntime } from '../src/client/sessions/service.ts'
 import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
 
 interface Bench {
   ctx: Context
   api: FakeApiClient
-  sinks: ConnectionSinks | undefined
-  stopped: number
+  start: ReturnType<typeof vi.fn<ConnectionHandle['start']>>
+  dispatchRemote(event: string, args: readonly unknown[]): void
 }
 
-async function mount(): Promise<Bench> {
+async function mount(configure?: (api: FakeApiClient) => void): Promise<Bench> {
   const ctx = new Context()
   await ctx.plugin(TypertRegistry)
   const api = new FakeApiClient()
-  const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
+  configure?.(api)
+  const listeners = new Map<string, Set<(...args: never[]) => void>>()
+  const dispatchRemote = (event: string, args: readonly unknown[]): void => {
+    for (const listener of listeners.get(event) ?? []) listener(...args as never[])
+  }
+  const start = vi.fn<ConnectionHandle['start']>(() => ({ stop: () => {} }))
+  const bench: Bench = { ctx, api, start, dispatchRemote }
   const handle: ConnectionHandle = {
     api,
     isLoopback: true,
@@ -38,14 +42,23 @@ async function mount(): Promise<Bench> {
     rpc: {
       call: () => Promise.reject(new Error('unexpected generic RPC call')),
     },
-    start: (sinks) => {
-      bench.sinks = sinks
-      return { stop: () => { bench.stopped += 1 } }
-    },
+    registerGenerationSource: () => () => {},
+    start,
   }
+  const remote = fakeRemote(api)
   ctx.reflect.provide('connection', handle)
-  ctx.reflect.provide('remote', {})
-  ctx.reflect.provide('remote.commands', fakeRemote().commands)
+  ctx.reflect.provide('remote', {
+    ...remote,
+    $on: (event: string, listener: (...args: never[]) => void) => {
+      const eventListeners = listeners.get(event) ?? new Set()
+      eventListeners.add(listener)
+      listeners.set(event, eventListeners)
+      return () => { eventListeners.delete(listener) }
+    },
+  })
+  ctx.reflect.provide('remote.commands', remote.commands)
+  ctx.reflect.provide('remote.session', remote.session)
+  ctx.reflect.provide('remote.workspace', remote.workspace)
   await ctx.plugin(RuntimeClient).await()
   return bench
 }
@@ -55,7 +68,18 @@ async function flushMicrotasks(): Promise<void> {
 }
 
 describe('runtime client apply', () => {
-  it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => {
+  it('refreshes Sessions on every Gateway connection generation', async () => {
+    const refresh = vi.spyOn(SessionRuntime.prototype, 'handleConnected')
+    const bench = await mount()
+
+    bench.ctx.emit('connection/reset')
+    bench.ctx.emit('connection/reset')
+
+    expect(refresh).toHaveBeenCalledTimes(2)
+    refresh.mockRestore()
+  })
+
+  it('mounts slots, Sessions, and Workspaces and routes their independent streams', async () => {
     const bench = await mount()
     expect(bench.ctx.get('slots') !== undefined).toBe(true)
     // The built-in 'root' declaration ships with this package's SlotRegistry
@@ -68,52 +92,53 @@ describe('runtime client apply', () => {
     // The bound the wire schema enforces, not a per-connection negotiation.
     expect((sessions as SessionRuntime).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
     if (workspaces === undefined) throw new Error('WorkspaceRuntime missing after runtime apply')
-    expect(bench.sinks).toBeDefined()
+    expect(bench.start).not.toHaveBeenCalled()
 
-    // 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', blank: true, sessionId: 's-new' } as never,
-    })
+    // Session Remote events reach the object layer and land in the list store.
+    bench.dispatchRemote('api-session/added', [{
+      sessionId: 's-new', updatedAt: 1, running: false, blank: true,
+    }])
     await Promise.resolve()
     expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
-    bench.sinks?.onHostEnvelope?.({
-      rpcId: 'r-workspace' as never,
-      payload: {
-        type: 'host/workspace-changed',
-        workspace: {
-          workspaceId: 'w-new', path: '/w/new', title: 'new', sessionIds: [],
-          createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
-        },
-      } as never,
+    await flushMicrotasks()
+    bench.api.pushWorkspace({
+      type: 'upsert',
+      workspace: {
+        workspaceId: 'w-new' as never, path: '/w/new', title: 'new', sessionIds: [],
+        createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
+      },
     })
-    await Promise.resolve()
+    await flushMicrotasks()
     expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-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?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })
+    // Gateway generation publication routes without throwing.
+    bench.ctx.emit('connection/reset')
   })
 
   it('selects the recent Workspace once when the first baselines have no current session', async () => {
-    const bench = await mount()
-    bench.api.onWorkspaceList = () => Promise.resolve(ok({
-      items: [{
-        workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [],
-        createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
-      }] as never[],
-    }))
-    bench.api.onList = () => Promise.resolve(ok({ items: [] }))
+    const bench = await mount((api) => {
+      api.workspaceBaseline = {
+        items: [{
+          workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [],
+          createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
+        }] as never[],
+        archivedSessionIds: [],
+      }
+      api.onList = () => Promise.resolve(ok({ items: [] }))
+    })
 
-    bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })
-    await flushMicrotasks()
+    bench.ctx.emit('connection/reset')
 
     const sessions = bench.ctx.get('sessions') as SessionRuntime
-    const workspaces = bench.ctx.get('workspaces') as WorkspaceRuntime
-    expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }])
+    await vi.waitFor(() => {
+      expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }])
+    })
     expect(sessions.list.getSnapshot().current).toBe('fk-new')
 
     sessions.clear()
-    await workspaces.refresh()
+    bench.api.pushWorkspace({
+      type: 'upsert',
+      workspace: bench.api.workspaceBaseline.items[0] as never,
+    })
     await flushMicrotasks()
     expect(sessions.list.getSnapshot().current).toBeUndefined()
     expect(bench.api.callsOf('session.create')).toHaveLength(1)
@@ -122,10 +147,9 @@ describe('runtime client apply', () => {
   it('wires registry changes into resident Sessions during the runtime apply pass', async () => {
     const bench = await mount()
     const sessions = bench.ctx.get('sessions') as SessionRuntime
-    bench.sinks?.onHostEnvelope?.({
-      rpcId: 'r-registry' as never,
-      payload: { type: 'host/session-added', blank: true, sessionId: 's-registry' } as never,
-    })
+    bench.dispatchRemote('api-session/added', [{
+      sessionId: 's-registry', updatedAt: 1, running: false, blank: true,
+    }])
     await flushMicrotasks()
     expect(sessions.binding('s-registry' as never)).toBeDefined()
     const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
@@ -145,12 +169,9 @@ describe('runtime client apply', () => {
     rebuild.mockRestore()
   })
 
-  it('stops the stream loop when the plugin fiber unloads', async () => {
+  it('does not own the Connection loop and closes its Remote streams on unload', 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
+    expect(bench.start).not.toHaveBeenCalled()
   })
 })

+ 1 - 1
packages/client/runtime/tests/conversation-registry.client.spec.ts

@@ -145,7 +145,7 @@ describe('Conversation registries', () => {
     api.onList = () => Promise.resolve(ok({
       items: [{ sessionId, updatedAt: 1, running: false, blank: true }],
     }) as never)
-    const sessions = new SessionRuntime(ctx, api, fakeRemote())
+    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
     await sessions.refresh()
     await Promise.resolve()
     sessions.scope(sessionId)

+ 309 - 132
packages/client/runtime/tests/fake-api.client.ts

@@ -1,14 +1,41 @@
 // 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.
+// deferred-controlled timing). Streams are hand pumps: pushFollow/pushControl/pushWorkspace.
 import type {
-  ClientResponse, HostFrame, IApiClient, ModelSelection, MuxFrame,
-  RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
+  IApiClient, ModelSelection,
+  RpcError, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
   WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-api-remotes/client'
+import type {
+  SessionAddress,
+  SessionControlBaseline,
+  SessionControlFrame,
+  SessionFollowFrame,
+  SessionFollowRequest,
+  SessionPage,
+  SessionPageRequest,
+  SessionRespondReceipt,
+  SessionRespondRequest,
+} from '@deepseek-ai/dsh-api-session-controller/types'
+import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { WorkspaceError, WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import {
+  RemoteStream,
+  type RemoteStreamOptions,
+} from '@deepseek-ai/dsh-api-gateway/client'
 import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
 import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
 
+const AVAILABLE_STREAM_CONNECTION = {
+  hostDescription: {
+    getSnapshot: () => ({
+      version: 'fixture', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
+    }),
+    subscribe: () => () => {},
+  },
+}
+
 /** Programmable-default workspace row (branded id, ISO-ish times). */
 function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView {
   return {
@@ -22,6 +49,16 @@ function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): Workspace
   }
 }
 
+function addressSessionId(address: SessionAddress): SessionId {
+  return address.kind === 'session' ? address.sessionId : address.childSessionId
+}
+
+function addressKey(address: SessionAddress): string {
+  return address.kind === 'session'
+    ? `session:${address.sessionId}`
+    : `subagent:${address.parentSessionId}:${address.childSessionId}:${address.mode}`
+}
+
 export interface Deferred<T> {
   promise: Promise<T>
   resolve(value: T): void
@@ -49,10 +86,28 @@ 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 }
+/** Successful generated Remote result for programmable domain fakes. */
+export function remoteOk<T>(value: T): RemoteResult<T> {
+  return { ok: true, value }
+}
+
+/** Workspace business failure returned by a generated Remote fake. */
+export function workspaceErr(error: WorkspaceError): RemoteResult<never> {
+  return { ok: false, error }
+}
+
+type ValueStreamItem<F> =
+  | { kind: 'frame'; value: F; delivered?: () => void }
+  | { kind: 'end' }
+  | { kind: 'fail'; error: unknown }
 
-interface StreamConn<F> {
-  feed(item: StreamItem<F>): void
+interface ValueStreamConn<F> {
+  feed(item: ValueStreamItem<F>): void
+}
+
+interface OpenValueStream<F> {
+  readonly values: AsyncGenerator<F>
+  dispose(): void
 }
 
 /**
@@ -60,13 +115,10 @@ interface StreamConn<F> {
  * a test that programs nothing sees an empty catalog and an unmatched line.
  * @returns the Remote namespaces the session cluster calls.
  */
-export function fakeRemote(): SessionRemotes {
-  return {
-    commands: {
-      list: () => Promise.resolve({ ok: true, value: [] }),
-      execute: () => Promise.resolve({ ok: true, value: undefined }),
-    },
-  }
+export type RuntimeRemotes = SessionRemotes & { readonly workspace: WorkspaceRemote }
+
+export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes {
+  return api.sessionRemotes()
 }
 
 export class FakeApiClient implements IApiClient {
@@ -82,7 +134,7 @@ export class FakeApiClient implements IApiClient {
   onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
   onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
   onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
-  => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
+  => Promise<RpcResponse<SessionPage>> =
     () => Promise.resolve(ok({ events: [], hasMore: false }))
 
   onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
@@ -131,37 +183,27 @@ export class FakeApiClient implements IApiClient {
   onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
     () => Promise.resolve(ok({ path: '/home/fake/new' }))
 
-  private readonly muxConns: StreamConn<MuxFrame>[] = []
-  private readonly hostConns: StreamConn<HostFrame>[] = []
-  lastSearchSignal: AbortSignal | undefined
-
-  // 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)),
-    search: (payload: unknown, signal?: AbortSignal) => {
-      this.lastSearchSignal = signal
-      return this.record('session.search', payload, this.onSearch(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)),
-    models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
-    selectModel: (payload: { provider: string; model: string }) =>
-      this.record('session.selectModel', payload, this.onSelectModel(payload)),
-    rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
-    fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
-    prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
-    attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
-    updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
-    cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
+  private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
+  private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
+  private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
+  private readonly openingPages = new Map<string, Promise<RemoteResult<SessionPage>>>()
+  /** Optional Host opening cursor override for stale-page and reconnect tests. */
+  followCursor: number | undefined
+  controlBaseline: SessionControlBaseline = {
+    queues: {},
+    jobs: {},
+    approvals: [],
+    questions: [],
+    projections: {},
   }
+  workspaceBaseline: Extract<WorkspaceFollowFrame, { type: 'baseline' }>['value'] = {
+    items: [],
+    archivedSessionIds: [],
+  }
+  lastSearchSignal: AbortSignal | undefined
 
   onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
     = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
-  onSubagentHistory: (payload: unknown) => Promise<RpcResponse<{ events: never[]; hasMore: boolean }>>
-    = () => Promise.resolve(ok({ events: [], hasMore: false }))
   onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
     = () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
 
@@ -170,7 +212,6 @@ export class FakeApiClient implements IApiClient {
 
   readonly subagents: IApiClient['subagents'] = {
     list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
-    history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
     prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
     interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
   }
@@ -183,44 +224,23 @@ export class FakeApiClient implements IApiClient {
     openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
   }
 
-  // The archive-set field defaults at the binding below so list stubs keep
-  // the pre-archive `{ items }` shape; a stub carrying the field wins.
-  onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[]; archivedSessionIds?: never[] }>> =
-    () => Promise.resolve(ok({ items: [] }))
-  onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
-    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
-
-  onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
-    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
-
-  onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
-    () => Promise.resolve(ok({ deleted: true }))
-
-  onWorkspaceInsertBefore: (payload: unknown) => Promise<RpcResponse<{ workspaceIds: WorkspaceId[] }>> =
-    () => Promise.resolve(ok({ workspaceIds: [] }))
-
-  onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
-    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
-
-  onWorkspaceArchiveSession: (payload: unknown) => Promise<RpcResponse<{ archivedSessionIds: SessionId[] }>> =
-    payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
-
-  readonly workspace: IApiClient['workspace'] = {
-    list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload).then(response => (
-      response.result.ok
-        ? { ...response, result: { ok: true as const, value: { archivedSessionIds: [] as never[], ...response.result.value } } }
-        : response
-    )) as ReturnType<IApiClient['workspace']['list']>),
-    create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
-    rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
-    delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
-    insertBefore: (payload: unknown) =>
-      this.record('workspace.insertBefore', payload, this.onWorkspaceInsertBefore(payload)),
-    insertSessionBefore: (payload: unknown) =>
-      this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
-    archiveSession: (payload: unknown) =>
-      this.record('workspace.archiveSession', payload, this.onWorkspaceArchiveSession(payload)),
-  }
+  onWorkspaceCreate: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView; created: boolean }>> =
+    () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true }))
+
+  onWorkspaceRename: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
+    () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') }))
+
+  onWorkspaceDelete: (payload: unknown) => Promise<RemoteResult<{ deleted: true }>> =
+    () => Promise.resolve(remoteOk({ deleted: true }))
+
+  onWorkspaceInsertBefore: (payload: unknown) => Promise<RemoteResult<{ workspaceIds: WorkspaceId[] }>> =
+    () => Promise.resolve(remoteOk({ workspaceIds: [] }))
+
+  onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
+    () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') }))
+
+  onWorkspaceArchiveSession: (payload: unknown) => Promise<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
+    payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
 
   // Payloads stay `unknown` (lint-lane note above); response rows are the real
   // wire shapes so cases can program requires-bearing catalogs and dual-address
@@ -278,51 +298,98 @@ export class FakeApiClient implements IApiClient {
     discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
   }
 
-  /** 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),
+  onRespond: (request: SessionRespondRequest) => Promise<RemoteResult<SessionRespondReceipt>> =
+    () => Promise.resolve({ ok: true, value: { accepted: true } })
+
+  /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */
+  sessionRemotes(): RuntimeRemotes {
+    return {
+      $stream: <Item>(options: RemoteStreamOptions<Item>) => (
+        new RemoteStream(AVAILABLE_STREAM_CONNECTION, options)
+      ),
+      commands: {
+        list: () => Promise.resolve({ ok: true, value: [] }),
+        execute: () => Promise.resolve({ ok: true, value: undefined }),
+      },
+      session: {
+        list: payload => this.remoteResult('session.list', payload, this.onList(payload)),
+        search: (payload, signal) => {
+          this.lastSearchSignal = signal
+          return this.remoteResult('session.search', payload, this.onSearch(payload))
+        },
+        create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)),
+        models: payload => this.remoteResult('session.models', payload, this.onModels(payload)),
+        selectModel: payload => this.remoteResult('session.selectModel', payload, this.onSelectModel(payload)),
+        rename: payload => this.remoteResult('session.rename', payload, this.onRename(payload)),
+        fork: payload => this.remoteResult('session.fork', payload, this.onFork(payload)),
+        prompt: payload => this.remoteResult('session.prompt', payload, this.onPrompt(payload)),
+        attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)),
+        updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)),
+        cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)),
+        page: request => this.page(request),
+        follow: (request, signal) => this.openFollow(request, signal),
+        control: signal => this.openControl(signal),
+        respond: request => this.record('session.respond', request, this.onRespond(request)),
+      },
+      workspace: {
+        create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
+        rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
+        delete: payload => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
+        insertBefore: payload => this.record(
+          'workspace.insertBefore',
+          payload,
+          this.onWorkspaceInsertBefore(payload),
+        ),
+        insertSessionBefore: payload => this.record(
+          'workspace.insertSessionBefore',
+          payload,
+          this.onWorkspaceInsertSessionBefore(payload),
+        ),
+        archiveSession: payload => this.record(
+          'workspace.archiveSession',
+          payload,
+          this.onWorkspaceArchiveSession(payload),
+        ),
+        follow: signal => this.openWorkspace(signal),
+      },
+    }
   }
 
-  onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
-
-  respond(message: ClientResponse): Promise<RpcReceipt> {
-    return this.record('respond', message, this.onRespond(message))
+  /** Push one live Session event to every follower of that Session. */
+  async pushFollow(
+    sessionId: SessionId,
+    frame: Extract<SessionFollowFrame, { type: 'event' }>,
+  ): Promise<void> {
+    await Promise.all([...(this.followConns.get(sessionId) ?? [])].map(conn => new Promise<void>((resolve) => {
+      conn.feed({ kind: 'frame', value: frame, delivered: resolve })
+    })))
   }
 
-  /** 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 } })
+  /** Push one Host-wide control update. */
+  pushControl(frame: Exclude<SessionControlFrame, { type: 'baseline' }>): void {
+    for (const conn of [...this.controlConns]) conn.feed({ kind: 'frame', value: 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 } })
+  /** Push one Workspace projection increment. */
+  pushWorkspace(frame: Exclude<WorkspaceFollowFrame, { type: 'baseline' }>): void {
+    for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'frame', value: 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' })
+    for (const conns of this.followConns.values()) {
+      for (const conn of [...conns]) conn.feed({ kind: 'end' })
+    }
+    for (const conn of [...this.controlConns]) conn.feed({ kind: 'end' })
+    for (const conn of [...this.workspaceConns]) 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
+    for (const conns of this.followConns.values()) {
+      for (const conn of [...conns]) conn.feed({ kind: 'fail', error })
+    }
+    for (const conn of [...this.controlConns]) conn.feed({ kind: 'fail', error })
+    for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'fail', error })
   }
 
   callsOf(method: string): unknown[] {
@@ -334,34 +401,144 @@ export class FakeApiClient implements IApiClient {
     return response
   }
 
-  private async *openStream<F>(registry: StreamConn<F>[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator<RpcRequest<F>> {
-    const inbox: StreamItem<F>[] = []
+  private async remoteResult<T>(
+    method: string,
+    payload: unknown,
+    response: Promise<RpcResponse<T>>,
+  ): Promise<RemoteResult<T>> {
+    return (await this.record(method, payload, response)).result
+  }
+
+  private page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
+    const key = addressKey(request.address)
+    if (request.beforeSeq === undefined && request.maxMessages === 50) {
+      const opening = this.openingPages.get(key)
+      if (opening !== undefined) {
+        this.openingPages.delete(key)
+        return opening
+      }
+    }
+    return this.fetchPage(request)
+  }
+
+  private fetchPage(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
+    const sessionId = addressSessionId(request.address)
+    const payload = request.address.kind === 'session'
+      ? {
+        sessionId,
+        ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
+        ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
+      }
+      : {
+        parentSessionId: request.address.parentSessionId,
+        childSessionId: request.address.childSessionId,
+        mode: request.address.mode,
+        ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
+        ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
+      }
+    const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history'
+    return this.remoteResult(method, payload, this.onHistory({
+      sessionId,
+      ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
+      ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
+    }))
+  }
+
+  private async *openFollow(
+    request: SessionFollowRequest,
+    signal: AbortSignal = new AbortController().signal,
+  ): AsyncGenerator<SessionFollowFrame> {
+    const sessionId = addressSessionId(request.address)
+    const key = addressKey(request.address)
+    const initialPage = this.fetchPage({ address: request.address, maxMessages: 50 })
+    this.openingPages.set(key, initialPage)
+    const conns = this.followConns.get(sessionId) ?? []
+    if (!this.followConns.has(sessionId)) this.followConns.set(sessionId, conns)
+    const stream = this.openValueStream(conns, signal)
+    try {
+      const page = await initialPage
+      const cursor = this.followCursor ?? (page.ok ? page.value.events.at(-1)?.event.seq ?? -1 : -1)
+      yield { type: 'opened', cursor }
+      yield* stream.values
+    } finally {
+      stream.dispose()
+      this.openingPages.delete(key)
+    }
+  }
+
+  private async *openControl(
+    signal: AbortSignal = new AbortController().signal,
+  ): AsyncGenerator<SessionControlFrame> {
+    const stream = this.openValueStream(this.controlConns, signal)
+    try {
+      yield { type: 'baseline', value: this.controlBaseline }
+      yield* stream.values
+    } finally {
+      stream.dispose()
+    }
+  }
+
+  private async *openWorkspace(
+    signal: AbortSignal = new AbortController().signal,
+  ): AsyncGenerator<WorkspaceFollowFrame> {
+    const stream = this.openValueStream(this.workspaceConns, signal)
+    try {
+      yield { type: 'baseline', value: this.workspaceBaseline }
+      yield* stream.values
+    } finally {
+      stream.dispose()
+    }
+  }
+
+  private openValueStream<F>(
+    registry: ValueStreamConn<F>[],
+    signal: AbortSignal,
+  ): OpenValueStream<F> {
+    const inbox: ValueStreamItem<F>[] = []
     let wake: (() => void) | null = null
-    const conn: StreamConn<F> = {
+    let inFlightDelivered: (() => void) | undefined
+    let disposed = false
+    const conn: ValueStreamConn<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
+    const dispose = (): void => {
+      if (disposed) return
+      disposed = true
+      inFlightDelivered?.()
+      for (const item of inbox) {
+        if (item.kind === 'frame') item.delivered?.()
       }
-    } finally {
-      registry.splice(registry.indexOf(conn), 1)
+      const index = registry.indexOf(conn)
+      if (index >= 0) registry.splice(index, 1)
+      wake?.()
     }
+    const values = (async function* (): AsyncGenerator<F> {
+      try {
+        while (!signal.aborted && !disposed) {
+          while (inbox.length > 0) {
+            const item = inbox.shift() as ValueStreamItem<F>
+            if (item.kind === 'end') return
+            if (item.kind === 'fail') throw item.error
+            inFlightDelivered = item.delivered
+            yield item.value
+            inFlightDelivered?.()
+            inFlightDelivered = undefined
+          }
+          await new Promise<void>((resolve) => {
+            wake = resolve
+            signal.addEventListener('abort', () => { resolve() }, { once: true })
+          })
+          wake = null
+        }
+      } finally {
+        dispose()
+      }
+    })()
+    return { values, dispose }
   }
+
 }

Разлика између датотеке није приказан због своје велике величине
+ 256 - 322
packages/client/runtime/tests/manager.client.spec.ts


+ 28 - 35
packages/client/runtime/tests/projection-store.client.spec.ts

@@ -4,7 +4,7 @@
  * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
  * newer push frame; a replayed frame cannot regress), capability absence as
  * undefined, generation truncation, and the Session/manager wiring (tail-page
- * seeding, session/projection frame routing pre- and post-instantiation, the
+ * seeding, control-stream projection routing pre- and post-instantiation, the
  * list rows' title projection).
  */
 import { describe, expect, it } from 'vitest'
@@ -103,7 +103,7 @@ describe('ProjectionValueStore semantics', () => {
 describe('Session tail-page seeding', () => {
   it('seeds the store from a history response carrying a projections block', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote())
+    const session = new Session(SID, api, fakeRemote(api))
     api.onHistory = () => Promise.resolve(ok({
       events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
       projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
@@ -114,7 +114,7 @@ describe('Session tail-page seeding', () => {
 
   it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote())
+    const session = new Session(SID, api, fakeRemote(api))
     api.onHistory = () => Promise.resolve(ok({
       events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
       projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
@@ -127,7 +127,7 @@ describe('Session tail-page seeding', () => {
 
   it('treats a blockless response as no reset: pushed values survive', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote())
+    const session = new Session(SID, api, fakeRemote(api))
     api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
     await session.open()
     session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
@@ -139,41 +139,41 @@ describe('Session tail-page seeding', () => {
 describe('manager frame routing', () => {
   const sid = (s: string): SessionId => s as SessionId
 
-  it('lands session/projection frames before instantiation and the Session adopts the same store', async () => {
+  it('lands projection frames before instantiation and the Session adopts the same store', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote())
-    manager.handleMuxEnvelope({
-      rpcId: 'p1' as never,
-      payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never,
+    const manager = new SessionManager(api, fakeRemote(api))
+    manager.handleControlFrame({
+      type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7,
     })
     const session = manager.get(sid('s1'))
     expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
     // Frames after instantiation land in the same store.
-    manager.handleMuxEnvelope({
-      rpcId: 'p2' as never,
-      payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9 } as never,
+    manager.handleControlFrame({
+      type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9,
     })
     expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
   })
 
-  it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => {
+  it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote())
+    const manager = new SessionManager(api, fakeRemote(api))
     api.onList = () => Promise.resolve(ok({
       items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
     }) as never)
     await manager.refreshList()
-    manager.handleMuxEnvelope({
-      rpcId: 't1' as never,
-      payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4 } as never,
+    manager.handleControlFrame({
+      type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4,
     })
     await Promise.resolve()
     expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
     // The durable baseline says the host only knows up to seq 2: the row rode
     // lost state and must drop (the un-flushed title precedent).
-    manager.handleMuxEnvelope({
-      rpcId: 'sub' as never,
-      payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 2 } as never,
+    manager.handleControlFrame({
+      type: 'baseline',
+      value: {
+        queues: {}, jobs: {}, approvals: [], questions: [],
+        projections: { [sid('s1')]: { asOfSeq: 2, values: {} } },
+      },
     })
     await Promise.resolve()
     expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
@@ -181,7 +181,7 @@ describe('manager frame routing', () => {
 
   it('projects every retained value into list rows with stable snapshot identity', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote())
+    const manager = new SessionManager(api, fakeRemote(api))
     api.onList = () => Promise.resolve(ok({
       items: [{
         sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
@@ -196,12 +196,9 @@ describe('manager frame routing', () => {
     expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
     expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
 
-    manager.handleMuxEnvelope({
-      rpcId: 'p2' as never,
-      payload: {
-        type: 'session/projection', sessionId: sid('s1'), key: 'test/marks',
-        value: { marks: ['live'] }, seq: 3,
-      } as never,
+    manager.handleControlFrame({
+      type: 'projection', sessionId: sid('s1'), key: 'test/marks',
+      value: { marks: ['live'] }, seq: 3,
     })
     await Promise.resolve()
     expect(manager.getListSnapshot().items[0]?.projectionValues)
@@ -211,19 +208,15 @@ describe('manager frame routing', () => {
 
   it('drops the projection store with the removed session', async () => {
     const api = new FakeApiClient()
-    const manager = new SessionManager(api, fakeRemote())
+    const manager = new SessionManager(api, fakeRemote(api))
     api.onList = () => Promise.resolve(ok({
       items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
     }) as never)
     await manager.refreshList()
-    manager.handleMuxEnvelope({
-      rpcId: 't1' as never,
-      payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4 } as never,
-    })
-    manager.handleHostEnvelope({
-      rpcId: 'rm' as never,
-      payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
+    manager.handleControlFrame({
+      type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4,
     })
+    manager.handleSessionRemoved(sid('s1'))
     expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
   })
 })

+ 80 - 54
packages/client/runtime/tests/queue-store.client.spec.ts

@@ -3,11 +3,15 @@
  * change, reconnect re-baselining, pre-instantiation buffering, editable-text
  * projection, and snapshot reference stability.
  */
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import type {
+  SessionControlFrame,
+  SessionInteractionId,
+} from '@deepseek-ai/dsh-api-session-controller/types'
 import { Session } from '../src/client/sessions/session.ts'
 import { SessionManager } from '../src/client/sessions/manager.ts'
 import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
@@ -26,29 +30,43 @@ interface QueueFixture {
 }
 
 /** Build one authoritative queue snapshot. */
-function queueFrame(items: QueueFixture[]): MuxFrame {
+function queueFrame(items: QueueFixture[]): Extract<SessionControlFrame, { type: 'queue' }> {
   return {
-    type: 'session/queue',
+    type: 'queue',
     sessionId: SID,
     items: items.map(item => ({
       id: iid(item.id),
       placement: item.placement ?? 'queued',
-      message: item.message ?? createUserMessage({
+      message: (item.message ?? createUserMessage({
         content: item.content ?? text(item.body),
         source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
-      }),
+      })) as never,
     })),
   }
 }
 
 function makeSession(): Session {
-  return new Session(SID, new FakeApiClient(), fakeRemote())
+  return makeBench().session
+}
+
+function makeBench(): { api: FakeApiClient; session: Session } {
+  const api = new FakeApiClient()
+  return { api, session: new Session(SID, api, fakeRemote(api)) }
+}
+
+function makeManager(): SessionManager {
+  const api = new FakeApiClient()
+  return new SessionManager(api, fakeRemote(api))
+}
+
+function interactionId(value: string): SessionInteractionId {
+  return value as SessionInteractionId
 }
 
 describe('queue snapshot intake', () => {
   it('projects stable ids, flat previews, and complete text', () => {
     const session = makeSession()
-    session.handleMuxEnvelope(rid('env-1'), queueFrame([
+    session.handleControlFrame(queueFrame([
       { id: 'q-1', body: '第一条  排队\n消息' },
     ]))
     const queue = session.getSnapshot().queue
@@ -64,7 +82,7 @@ describe('queue snapshot intake', () => {
 
   it('marks mixed-content messages non-editable while retaining their preview', () => {
     const session = makeSession()
-    session.handleMuxEnvelope(rid('env-2'), queueFrame([{
+    session.handleControlFrame(queueFrame([{
       id: 'q-image',
       body: '',
       content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
@@ -83,7 +101,7 @@ describe('queue snapshot intake', () => {
   it('caps previews at 200 code points and preserves the full editable text', () => {
     const session = makeSession()
     const body = '长'.repeat(201)
-    session.handleMuxEnvelope(rid('env-3'), queueFrame([{ id: 'q-cap', body }]))
+    session.handleControlFrame(queueFrame([{ id: 'q-cap', body }]))
     const row = session.getSnapshot().queue[0]
     expect(Array.from(row?.preview ?? '')).toHaveLength(201)
     expect(row?.preview.endsWith('…')).toBe(true)
@@ -92,11 +110,11 @@ describe('queue snapshot intake', () => {
 
   it('replaces content, order, and membership from each authoritative frame', () => {
     const session = makeSession()
-    session.handleMuxEnvelope(rid('env-4'), queueFrame([
+    session.handleControlFrame(queueFrame([
       { id: 'q-1', body: 'one' },
       { id: 'q-2', body: 'two' },
     ]))
-    session.handleMuxEnvelope(rid('env-5'), queueFrame([
+    session.handleControlFrame(queueFrame([
       { id: 'q-2', body: 'two edited' },
     ]))
     const queue = session.getSnapshot().queue
@@ -108,13 +126,13 @@ describe('queue snapshot intake', () => {
         preview: 'two edited', text: 'two edited',
       },
     ])
-    session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
+    session.handleControlFrame(queueFrame([]))
     expect(session.getSnapshot().queue).toEqual([])
   })
 
   it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
     const session = makeSession()
-    session.handleMuxEnvelope(rid('env-7'), queueFrame([{ id: 'q-stable', body: '稳定' }]))
+    session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }]))
     const before = session.getSnapshot().queue
     session.handleAgentError('unrelated')
     expect(session.getSnapshot().queue).toBe(before)
@@ -122,7 +140,7 @@ describe('queue snapshot intake', () => {
 
   it('retains steering placement and complete content in the same authoritative snapshot', () => {
     const session = makeSession()
-    session.handleMuxEnvelope(rid('env-steering'), queueFrame([
+    session.handleControlFrame(queueFrame([
       { id: 'q-next', body: 'later' },
       { id: 's-now', body: 'interrupt now', placement: 'steering' },
     ]))
@@ -136,13 +154,13 @@ describe('queue snapshot intake', () => {
   })
 
   it('hands off exactly one current occurrence when live steering becomes durable', async () => {
-    const session = makeSession()
+    const { api, session } = makeBench()
     await session.open()
     const message = createUserMessage({
       content: text('same message'),
       source: { kind: 'user' },
     })
-    session.handleMuxEnvelope(rid('env-same-id'), queueFrame([
+    session.handleControlFrame(queueFrame([
       { id: 's-first', body: '', placement: 'steering', message },
       { id: 's-second', body: '', placement: 'steering', message },
     ]))
@@ -154,52 +172,53 @@ describe('queue snapshot intake', () => {
       data: message,
     } as SessionEvent
 
-    session.handleMuxEnvelope(rid('env-durable'), {
-      type: 'session/event', sessionId: SID, event: durable,
+    await api.pushFollow(SID, { type: 'event', event: durable as never })
+    await vi.waitFor(() => {
+      expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
     })
-    expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
 
-    session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
+    session.handleControlFrame(queueFrame([
       { id: 's-later', body: '', placement: 'steering', message },
     ]))
-    session.handleMuxEnvelope(rid('env-replayed-durable'), {
-      type: 'session/event', sessionId: SID, event: durable,
+    await api.pushFollow(SID, { type: 'event', event: durable as never })
+    await vi.waitFor(() => {
+      expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
     })
-    expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
   })
 
   it('hands off live steering when the agent claims it as a user message', async () => {
-    const session = makeSession()
+    const { api, session } = makeBench()
     await session.open()
     const message = createUserMessage({
       content: text('claimed steering'),
       source: { kind: 'user' },
     })
-    session.handleMuxEnvelope(rid('env-claimed'), queueFrame([
+    session.handleControlFrame(queueFrame([
       { id: 's-claimed', body: '', placement: 'steering', message },
     ]))
 
-    session.handleMuxEnvelope(rid('env-user-message'), {
-      type: 'session/event',
-      sessionId: SID,
+    await api.pushFollow(SID, {
+      type: 'event',
       event: {
         seq: 0,
         time: 1_700_000_000_000,
         type: 'user/message',
         surfaceOp: 'append',
         data: message,
-      },
+      } as never,
     })
 
-    expect(session.getSnapshot().queue).toEqual([])
+    await vi.waitFor(() => {
+      expect(session.getSnapshot().queue).toEqual([])
+    })
   })
 })
 
 describe('queue operation transport', () => {
   it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote())
-    session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
+    const session = new Session(SID, api, fakeRemote(api))
+    session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }]))
     const before = session.getSnapshot().queue
 
     await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
@@ -223,26 +242,26 @@ describe('queue operation transport', () => {
 })
 
 describe('queue reconnect semantics', () => {
-  it('session/subscribed clears stale state before the fresh snapshot lands', () => {
+  it('a control baseline clears stale state before a fresh update lands', () => {
     const session = makeSession()
-    session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-old', body: '旧连接' }]))
-    session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 })
+    session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }]))
+    session.replaceControl([], [])
     expect(session.getSnapshot().queue).toEqual([])
-    session.handleMuxEnvelope(rid('e3'), queueFrame([{ id: 'q-new', body: '新基线' }]))
+    session.handleControlFrame(queueFrame([{ id: 'q-new', body: '新基线' }]))
     expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
   })
 
   it('resync does not clear a baseline that raced ahead of the host connection signal', async () => {
     const session = makeSession()
-    session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
-    session.handleMuxEnvelope(rid('e2'), queueFrame([{ id: 'q-fresh', body: '新基线' }]))
+    await session.open()
+    session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }]))
     await session.resync()
     expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
   })
 
   it('running-status changes never guess at queue retirement', () => {
     const session = makeSession()
-    session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-live', body: '保留' }]))
+    session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }]))
     session.handleRunning(true)
     session.handleRunning(false)
     expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live'])
@@ -251,24 +270,31 @@ describe('queue reconnect semantics', () => {
 
 describe('manager buffering of queue snapshots', () => {
   it('replays only the latest snapshot for an uninstantiated session', () => {
-    const manager = new SessionManager(new FakeApiClient(), fakeRemote())
-    manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) })
-    manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) })
+    const manager = makeManager()
+    manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }]))
+    manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }]))
     expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
   })
 
-  it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => {
-    const manager = new SessionManager(new FakeApiClient(), fakeRemote())
-    manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) })
-    manager.handleMuxEnvelope({
-      rpcId: rid('g1b'),
-      payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' },
-    })
-    manager.handleMuxEnvelope({
-      rpcId: rid('g2a'),
-      payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 },
+  it('a control baseline replaces the prior queue and restores answerable interactions', () => {
+    const manager = makeManager()
+    manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }]))
+    const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items
+    manager.handleControlFrame({
+      type: 'baseline',
+      value: {
+        queues: { [SID]: nextQueue },
+        jobs: {},
+        approvals: [{
+          interactionId: interactionId('approval-1'),
+          sessionId: SID,
+          approvalId: 'ap-1' as never,
+          toolName: 'bash',
+        }],
+        questions: [],
+        projections: {},
+      },
     })
-    manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queueFrame([{ id: 'q-g2', body: '第二代' }]) })
     const snapshot = manager.get(SID).getSnapshot()
     expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2'])
     expect(snapshot.pending.map(pending => pending.kind)).toEqual(['approval'])

+ 191 - 112
packages/client/runtime/tests/session.client.spec.ts

@@ -7,9 +7,14 @@
  */
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type {} from '@deepseek-ai/dsh-commands/types'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import type {
+  SessionInteractionId,
+  SessionToolView,
+} from '@deepseek-ai/dsh-api-session-controller/types'
 import { Session } from '../src/client/sessions/session.ts'
 import type {
   ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
@@ -159,7 +164,23 @@ const TEST_CONVERSATION: ConversationRuntime = {
 }
 
 function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } {
-  return { api, session: new Session(SID, api, fakeRemote(), { conversation: TEST_CONVERSATION }) }
+  return { api, session: new Session(SID, api, fakeRemote(api), { conversation: TEST_CONVERSATION }) }
+}
+
+function interactionId(value: string): SessionInteractionId {
+  return value as SessionInteractionId
+}
+
+function follow(
+  api: FakeApiClient,
+  event: SessionEvent,
+  view?: SessionToolView,
+): Promise<void> {
+  return api.pushFollow(SID, {
+    type: 'event',
+    event: event as never,
+    ...(view === undefined ? {} : { view }),
+  })
 }
 
 function chatEvents(snapshot: ConversationSnapshot): readonly TestEventState[] {
@@ -234,14 +255,16 @@ describe('open', () => {
     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, '插进来的') })
+    const deliveries = [
+      follow(api, ev.turnStart(15, 1)),
+      follow(api, ev.user(16, '插进来的')),
+    ]
     gate.resolve(ok({
       events: entries(page) as never[],
       hasMore: false,
       modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
     }))
-    await opening
+    await Promise.all([opening, ...deliveries])
     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])
@@ -258,33 +281,32 @@ describe('live event path', () => {
   }
 
   it('drops replayed frames at or below the window tail', async () => {
-    const { session } = await opened()
+    const { api, session } = await opened()
     const before = session.getSnapshot()
-    session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(3, '重放') })
-    await Promise.resolve()
+    await follow(api, ev.user(3, '重放'))
     expect(session.getSnapshot().nodes).toEqual(before.nodes)
   })
 
   it('keeps the authoritative host blank bit across unrelated log events', async () => {
-    const { session } = await opened([])
+    const { api, session } = await opened([])
     session.handleBlank(true)
     expect(session.getSnapshot().composerPhase).toBe('blank')
-    const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
-    feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
-    feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access'))
+    await Promise.all([
+      follow(api, ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')),
+      follow(api, ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')),
+    ])
     const snapshot = session.getSnapshot()
     expect(chatSeqs(snapshot)).toEqual([0, 1])
     expect(snapshot.composerPhase).toBe('blank')
   })
 
   it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => {
-    const { session } = await opened([])
+    const { api, session } = await opened([])
     session.handleBlank(true)
-    const feed = (event: SessionEvent) => {
-      session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
-    }
-    feed(ev.commandRun(0, 'cmd-goal', 'goal', ' '))
-    feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.'))
+    await Promise.all([
+      follow(api, ev.commandRun(0, 'cmd-goal', 'goal', ' ')),
+      follow(api, ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.')),
+    ])
 
     expect(session.getSnapshot()).toMatchObject({
       blank: true,
@@ -301,26 +323,26 @@ describe('live event path', () => {
       frames.push(callback)
       return frames.length
     })
-    const { session } = await opened()
+    const { api, session } = await opened()
     const published: number[][] = []
     session.subscribe(() => {
       published.push(chatSeqs(session.getSnapshot()))
     })
-    const feed = (event: SessionEvent) => {
-      session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
-    }
-
-    feed(ev.chunkStart(6, 1))
-    feed(ev.chunkText(7, 1, '累'))
-    feed(ev.chunkText(8, 1, '计'))
+    await Promise.all([
+      follow(api, ev.chunkStart(6, 1)),
+      follow(api, ev.chunkText(7, 1, '累')),
+      follow(api, ev.chunkText(8, 1, '计')),
+    ])
     expect(published).toEqual([])
     expect(frames).toHaveLength(1)
 
     frames.shift()!(0)
     expect(published).toEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8]])
 
-    feed(ev.chunkText(9, 1, '完成'))
-    feed(ev.assistant(10, 1, '累计完成'))
+    await Promise.all([
+      follow(api, ev.chunkText(9, 1, '完成')),
+      follow(api, ev.assistant(10, 1, '累计完成')),
+    ])
     await Promise.resolve()
     expect(published).toEqual([
       [0, 1, 2, 3, 4, 5, 6, 7, 8],
@@ -343,17 +365,12 @@ describe('live event path', () => {
         entries: () => [testViewDefinition()],
       } as unknown as ConversationRuntime['views'],
     }
-    const session = new Session(SID, api, fakeRemote(), { conversation })
+    const session = new Session(SID, api, fakeRemote(api), { conversation })
     await session.open()
     const snapshots: ConversationSnapshot[] = []
     session.subscribe(() => { snapshots.push(session.getSnapshot()) })
 
-    session.handleMuxEnvelope('timeline' as never, {
-      type: 'session/event',
-      sessionId: SID,
-      event: ev.turnStart(0, 1),
-    })
-    await Promise.resolve()
+    await follow(api, ev.turnStart(0, 1))
 
     expect(snapshots).toHaveLength(1)
     expect(snapshots[0]?.chat.timeline.turns.get(1)?.status).toBe('open')
@@ -364,13 +381,14 @@ describe('live event path', () => {
     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 follow(api, 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
+    await vi.waitFor(() => {
+      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
+    })
   })
 })
 
@@ -448,7 +466,7 @@ describe('paging', () => {
 describe('prompt and cancel errors', () => {
   it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote(), {
+    const session = new Session(SID, api, fakeRemote(api), {
       address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
       parentAvailable: true,
     })
@@ -487,7 +505,7 @@ describe('prompt and cancel errors', () => {
     api.onSubagentInterrupt = () => Promise.resolve(err({
       code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
     }) as never)
-    const session = new Session(SID, api, fakeRemote(), {
+    const session = new Session(SID, api, fakeRemote(api), {
       address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
       parentAvailable: true,
     })
@@ -501,7 +519,7 @@ describe('prompt and cancel errors', () => {
 
   it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
     const api = new FakeApiClient()
-    const session = new Session(SID, api, fakeRemote(), {
+    const session = new Session(SID, api, fakeRemote(api), {
       address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
     })
     await session.open()
@@ -594,7 +612,9 @@ describe('rename', () => {
 
   it('returns the business error untouched and folds a transport throw to internal', async () => {
     const { api, session } = makeSession()
-    api.onRename = () => Promise.resolve(err({ code: 'title-invalid', message: 'empty', details: { sessionId: SID } }))
+    api.onRename = () => Promise.resolve(err({
+      code: 'title-invalid', message: 'empty', details: { sessionId: SID },
+    } as never))
     const rejected = await session.rename('   ')
     expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
     expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
@@ -607,17 +627,32 @@ describe('rename', () => {
 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: [] })
+    session.handleControlFrame({
+      type: 'approval/requested', interactionId: interactionId('ra'),
+      sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm',
+    })
+    session.handleControlFrame({
+      type: 'question/requested', interactionId: interactionId('rq'),
+      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' })
+    session.handleControlFrame({
+      type: 'approval/resolved', interactionId: interactionId('ra'),
+      sessionId: SID, approvalId: 'ap1' as never, outcome: 'allowed-once',
+    })
+    session.handleControlFrame({
+      type: 'question/resolved', interactionId: interactionId('rq'),
+      sessionId: SID, outcome: 'answered',
+    })
     expect(session.getSnapshot().pending).toEqual([])
   })
 
-  it('mints waits whose respond() backfills the requested rpcId into the client-response envelope', async () => {
+  it('mints waits whose respond() addresses the stable interaction id', async () => {
     const { api, session } = makeSession()
-    session.handleMuxEnvelope('rq-answer' as never, { type: 'question/requested', sessionId: SID, questions: [] })
+    session.handleControlFrame({
+      type: 'question/requested', interactionId: interactionId('rq-answer'),
+      sessionId: SID, questions: [],
+    })
     const wait = session.getSnapshot().pending[0]!
     expect(wait).toMatchObject({ kind: 'question', key: 'q:rq-answer', sessionId: SID, payload: { questions: [] } })
     const receipt = await wait.respond({
@@ -625,8 +660,8 @@ describe('pending interactions', () => {
       value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
     })
     expect(receipt).toEqual({ accepted: true })
-    expect(api.callsOf('respond')).toEqual([{
-      type: 'client-response', rpcId: 'rq-answer',
+    expect(api.callsOf('session.respond')).toEqual([{
+      interactionId: 'rq-answer',
       result: {
         ok: true,
         value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
@@ -636,13 +671,19 @@ describe('pending interactions', () => {
 
   it('settles the wait on the authoritative resolved frame: respond() then throws synchronously', async () => {
     const { api, session } = makeSession()
-    session.handleMuxEnvelope('rq1' as never, { type: 'question/requested', sessionId: SID, questions: [] })
+    session.handleControlFrame({
+      type: 'question/requested', interactionId: interactionId('rq1'),
+      sessionId: SID, questions: [],
+    })
     const wait = session.getSnapshot().pending[0]!
-    session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' })
+    session.handleControlFrame({
+      type: 'question/resolved', interactionId: interactionId('rq1'),
+      sessionId: SID, outcome: 'answered',
+    })
     expect(session.getSnapshot().pending).toEqual([])
     expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }))
       .toThrow('already settled')
-    expect(api.callsOf('respond')).toEqual([])
+    expect(api.callsOf('session.respond')).toEqual([])
   })
 })
 
@@ -711,7 +752,7 @@ describe('remaining branches', () => {
     expect(notified).toBe(seen)
   })
 
-  it('subscribed baseline past the window tail triggers the second stitch pull in doOpen', async () => {
+  it('an opening cursor 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
@@ -719,14 +760,13 @@ describe('remaining branches', () => {
       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 })
+    api.followCursor = 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 () => {
+  it('a failed opening repair rejects the incomplete window and reports the unresolved gap', async () => {
     const { api, session } = makeSession()
     let call = 0
     api.onHistory = () => {
@@ -735,30 +775,41 @@ describe('remaining branches', () => {
         ? 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 })
+    api.followCursor = 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
+    expect(snapshot.openState).toBe('error')
+    expect(snapshot.openError).toMatchObject({ code: 'internal', message: 'stitch pull down' })
+    expect(snapshot.nodes).toEqual([])
   })
 
   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: '危险',
+    session.handleControlFrame({
+      type: 'approval/requested', interactionId: interactionId('ra'),
+      sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm',
+      callId: 'c1' as never, reason: '危险',
     })
     expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', payload: { 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' })
+    session.handleControlFrame({
+      type: 'approval/resolved', interactionId: interactionId('ra'),
+      sessionId: SID, approvalId: 'ap2' as never, outcome: 'allowed-once',
+    })
+    session.handleControlFrame({
+      type: 'approval/resolved', interactionId: interactionId('ra'),
+      sessionId: SID, approvalId: 'ap2' as never, outcome: 'allowed-once',
+    })
+    session.handleControlFrame({
+      type: 'question/resolved', interactionId: interactionId('never-was'),
+      sessionId: SID, outcome: 'cancelled',
+    })
     expect(session.getSnapshot().pending).toEqual([])
   })
 
-  it('ignores unknown mux frame types and repeated running flips (documented defaults)', () => {
+  it('deduplicates repeated running flips and records removal', () => {
     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()
@@ -767,15 +818,31 @@ describe('remaining branches', () => {
 
   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, '冷态帧') })
+    await follow(api, 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, '错态帧') })
+    await follow(api, ev.user(0, '错态帧'))
     expect(session.getSnapshot().nodes).toEqual([])
   })
 
-  it('repairGap failure logs and clears stitching; concurrent gaps coalesce into one repair', async () => {
+  it('preserves a Host-reported failure that terminates the live source', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
+    await session.open()
+    const failure = {
+      code: 'session-not-found',
+      message: 'session disappeared',
+      details: { sessionId: SID },
+    }
+
+    api.failStreams(new RemoteStreamError(failure.code, failure.message, failure.details))
+    await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
+
+    expect(session.getSnapshot().openError).toEqual(failure)
+  })
+
+  it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => {
     const { api, session } = makeSession()
     api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
     await session.open()
@@ -785,18 +852,16 @@ describe('remaining branches', () => {
       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()
-    }
+    const deliveries = Promise.all([
+      follow(api, ev.user(9, '洞一')),
+      follow(api, ev.user(10, '洞二')),
+    ])
+    await vi.waitFor(() => { expect(repairs).toBe(1) })
+    gate.reject(new Error('repair wire down'))
+    await deliveries
+    await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
+    expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'repair wire down' })
+    expect(session.getSnapshot().nodes).toHaveLength(2)
   })
 
   it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => {
@@ -837,7 +902,7 @@ describe('remaining branches', () => {
       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 })
+    api.followCursor = 11
     const opening = session.open() // triggers the second pull, which parks
     await vi.waitFor(() => { expect(call).toBe(2) })
     const resynced = session.resync()
@@ -856,7 +921,8 @@ describe('remaining branches', () => {
     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
+    const delivery = follow(api, ev.user(9, '洞'))
+    await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) })
     api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
     const resynced = session.resync() // bumps the generation
     repairPull.resolve(ok({
@@ -864,7 +930,7 @@ describe('remaining branches', () => {
       hasMore: false,
       modelSelection: { provider: 'deepseek-official', model: 'stale' },
     })) // repair result: stale, dropped
-    await resynced
+    await Promise.all([delivery, resynced])
     expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
   })
 
@@ -882,7 +948,7 @@ describe('remaining branches', () => {
     expect(() => { session.dispose() }).not.toThrow()
   })
 
-  it('carries history-entry and mux-frame views into the business-neutral Event input', async () => {
+  it('carries history-entry and follow-frame views into the business-neutral Event input', async () => {
     const { api, session } = makeSession()
     const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
     api.onHistory = () => Promise.resolve(ok({
@@ -899,17 +965,19 @@ describe('remaining branches', () => {
       callView,
       { for: 'result', view: { card: 'generic', title: '历史果' } },
     ])
-    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)
+    await follow(
+      api,
+      ev.toolCall(8, 2, 'l1', 'write', '{}'),
+      { for: 'call', view: { card: 'generic', title: '直播卡' } },
+    )
     expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({
       for: 'call', view: { card: 'generic', 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)
+    await follow(
+      api,
+      ev.toolResult(9, 2, 'l1', 'ok'),
+      { for: 'result', view: { card: 'generic', title: '直播果' } },
+    )
     expect(chatEvents(session.getSnapshot()).at(-1)?.view).toEqual({
       for: 'result', view: { card: 'generic', title: '直播果' },
     })
@@ -917,16 +985,19 @@ describe('remaining branches', () => {
 })
 
 describe('resync', () => {
-  it('rebuilds the window and clears pending; cold instances no-op', async () => {
+  it('rebuilds the window without clearing control state; 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' })
+    session.handleControlFrame({
+      type: 'approval/requested', interactionId: interactionId('ra'),
+      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.pending).toHaveLength(1)
     expect(snapshot.nodes).toHaveLength(4)
 
     const cold = makeSession()
@@ -934,20 +1005,22 @@ describe('resync', () => {
     expect(cold.api.calls).toEqual([]) // never opened: no traffic
   })
 
-  it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => {
+  it('re-mints a control-baseline interaction with the same key (old reference superseded)', async () => {
     const { api, session } = makeSession()
     api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
     await session.open()
-    session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
+    const request = {
+      interactionId: interactionId('rq-replay'), sessionId: SID, questions: [],
+    }
+    session.handleControlFrame({ type: 'question/requested', ...request })
     const before = session.getSnapshot().pending[0]!
-    await session.resync()
-    session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
+    session.replaceControl([], [request])
     const after = session.getSnapshot().pending[0]!
     expect(after).not.toBe(before)
     expect(after.key).toBe(before.key)
     // Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host.
     await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
-    expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }])
+    expect(api.callsOf('session.respond')).toMatchObject([{ interactionId: 'rq-replay' }])
   })
 
   it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
@@ -977,7 +1050,7 @@ describe('reference stability (the memo contract)', () => {
     const secondKey = before.chat.order[1]!
     const first = before.chat.nodes.get(firstKey)
     const second = before.chat.nodes.get(secondKey)
-    session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(6, '追加') })
+    await follow(api, ev.user(6, '追加'))
     const after = session.getSnapshot()
     expect(after).not.toBe(before) // top-level swap on change
     expect(after.chat.nodes.get(firstKey)).toBe(first)
@@ -991,26 +1064,32 @@ describe('reference stability (the memo contract)', () => {
     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.stepStart(7, 1))
-    feed(ev.toolCall(8, 1, 'c1', 'echo', '{}'))
-    session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
+    await Promise.all([
+      follow(api, ev.turnStart(6, 1)),
+      follow(api, ev.stepStart(7, 1)),
+      follow(api, ev.toolCall(8, 1, 'c1', 'echo', '{}')),
+    ])
+    session.handleControlFrame({
+      type: 'approval/requested', interactionId: interactionId('ra'),
+      sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm',
+    })
     const before = session.getSnapshot()
     const settledKey = before.chat.order[0]!
     const settledNode = before.chat.nodes.get(settledKey)
-    feed(ev.chunkStart(9, 1))
-    feed(ev.chunkText(10, 1, '与工具无关的流式'))
+    await Promise.all([
+      follow(api, ev.chunkStart(9, 1)),
+      follow(api, ev.chunkText(10, 1, '与工具无关的流式')),
+    ])
     const after = session.getSnapshot()
     expect(after).not.toBe(before)
     expect(after.runningCalls).toBe(before.runningCalls)
     expect(after.pending).toBe(before.pending)
     expect(after.chat.nodes.get(settledKey)).toBe(settledNode)
-    feed(ev.toolResult(11, 1, 'c1', 'ECHO'))
+    await follow(api, ev.toolResult(11, 1, 'c1', 'ECHO'))
     const resolved = session.getSnapshot()
     expect(resolved.pending).toBe(after.pending)
     expect(resolved.chat.nodes.get(settledKey)).toBe(settledNode)
-    feed(ev.assistant(12, 1, '完成'))
+    await follow(api, ev.assistant(12, 1, '完成'))
     expect(session.getSnapshot()).not.toBe(resolved)
   })
 })

+ 17 - 26
packages/client/runtime/tests/sessions-service.client.spec.ts

@@ -23,7 +23,7 @@ interface Bench {
 function bench(): Bench {
   const ctx = new Context()
   const api = new FakeApiClient()
-  const svc = new SessionRuntime(ctx, api, fakeRemote())
+  const svc = new SessionRuntime(ctx, api, fakeRemote(api))
   return { ctx, api, svc }
 }
 
@@ -55,9 +55,8 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
 describe('list store projection', () => {
   it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
     const b = bench()
-    b.svc.handleMuxEnvelope({
-      rpcId: 'title' as never,
-      payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2 } as never,
+    b.svc.handleControlFrame({
+      type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2,
     })
     await feedList(b, [
       { id: 's1', cwd: '/home/u/proj-a/' },
@@ -90,7 +89,9 @@ describe('list store projection', () => {
   it('reflects live increments (host stream via manager) into the store', async () => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
-    b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', blank: true, sessionId: sid('s2') } as never })
+    b.svc.handleSessionAdded({
+      sessionId: sid('s2'), updatedAt: 2, running: false, blank: true,
+    })
     await Promise.resolve()
     expect(b.svc.list.getSnapshot().ids).toContain('s2')
   })
@@ -528,9 +529,8 @@ describe('fork', () => {
     ['计划 (9)', '计划 (10)'],
   ])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => {
     const b = bench()
-    b.svc.handleMuxEnvelope({
-      rpcId: 'source-title' as never,
-      payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never,
+    b.svc.handleControlFrame({
+      type: 'projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2,
     })
     await feedList(b, [{ id: 'source', cwd: '/work' }])
     b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
@@ -578,15 +578,14 @@ describe('fork', () => {
 
   it('rejects when child rename fails while keeping the published child addressable', async () => {
     const b = bench()
-    b.svc.handleMuxEnvelope({
-      rpcId: 'source-title' as never,
-      payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never,
+    b.svc.handleControlFrame({
+      type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2,
     })
     await feedList(b, [{ id: 'source' }])
     b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
     b.api.onRename = () => Promise.resolve(err({
       code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') },
-    }))
+    } as never))
 
     await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
       .rejects.toThrow('fork child rename failed: title-invalid: rejected')
@@ -599,18 +598,14 @@ describe('scope lifecycle rides the list mirror (entity parity: no client-side p
     const b = bench()
     await feedList(b, [])
     expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions
-    b.svc.handleHostEnvelope({
-      rpcId: 'add' as never,
-      payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never,
+    b.svc.handleSessionAdded({
+      sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
     })
     await Promise.resolve()
     const scoped = b.svc.scope(sid('s-new'))
     expect(scoped).toBeDefined()
     expect(scopeOf(scoped as Context)).toBe('s-new')
-    b.svc.handleHostEnvelope({
-      rpcId: 'rm' as never,
-      payload: { type: 'host/session-removed', sessionId: sid('s-new') },
-    })
+    b.svc.handleSessionRemoved(sid('s-new'))
     await Promise.resolve()
     expect(b.svc.scope(sid('s-new'))).toBeUndefined()
   })
@@ -621,10 +616,7 @@ describe('blank mirror', () => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true }])
     expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
-    b.svc.handleHostEnvelope({
-      rpcId: 'st' as never,
-      payload: { type: 'host/session-status', sessionId: sid('s1'), running: true },
-    })
+    b.svc.handleSessionStatus(sid('s1'), true)
     await Promise.resolve()
     expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true })
     // The instantiated Session mirrors the same flip.
@@ -669,9 +661,8 @@ describe('blank mirror', () => {
   it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => {
     const b = bench()
     await feedList(b, [])
-    b.svc.handleHostEnvelope({
-      rpcId: 'add' as never,
-      payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never,
+    b.svc.handleSessionAdded({
+      sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
     })
     await Promise.resolve()
     expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: true })

+ 0 - 135
packages/client/runtime/tests/wire-events.client.spec.ts

@@ -1,135 +0,0 @@
-/**
- * Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to
- * the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is
- * api-gateway's own coverage); each established connection generation emits
- * `connection/reset` for generation-scoped cache invalidation.
- */
-import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it } from 'vitest'
-import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-api-remotes/client'
-import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
-// Type-only: the api-remotes facade carries both the allowlist's selection seat
-// and the owner packages' `./types` declarations, which together give `$on` its
-// key face and per-event listener signatures.
-import type {} from '@deepseek-ai/dsh-api-remotes/client'
-import * as RuntimeClient from '../src/client/index.ts'
-import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
-
-/**
- * Compile-time face of `ctx.remote.$on`, asserted by type-checking this file
- * rather than by running it: the allowlist narrows the key set, and each
- * listener's parameters come from the owner package's own cordis `Events`
- * declaration (so a brand cannot be flattened on the way to a consumer).
- * @param ctx - any client Context carrying the Remote service.
- */
-function forwardedEventContracts(ctx: Context): void {
-  ctx.remote.$on('settings/document-updated', (namespace, source) => {
-    // @ts-expect-error -- the brand survives the wire: a bare string is not a SettingsNamespace
-    const bare: typeof namespace = 'plain-string'
-    void bare; void namespace; void source
-  })
-  ctx.remote.$on('credentials/reference-updated', () => {})
-  ctx.remote.$on('commands/change', () => {})
-  ctx.remote.$on('llm/adapters-updated', () => {})
-  ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
-    void sessionId; void agentPreset
-  })
-  // @ts-expect-error -- client-local event outside the allowlist
-  ctx.remote.$on('slots/changed', () => {})
-  // @ts-expect-error -- declared host event the allowlist does not select
-  ctx.remote.$on('skills/change', () => {})
-}
-void forwardedEventContracts
-
-interface Bench {
-  ctx: Context
-  sinks: ConnectionSinks | undefined
-  /** Every `$dispatch` the runtime made, as `[event, ...args]`. */
-  dispatched: unknown[][]
-}
-
-async function mount(): Promise<Bench> {
-  const ctx = new Context()
-  await ctx.plugin(TypertRegistry)
-  const api = new FakeApiClient()
-  const bench: Bench = { ctx, sinks: undefined, dispatched: [] }
-  // Stands in for api-gateway's Remote service: this spec owns the carrier's
-  // handoff, not the fan-out behind it.
-  ctx.reflect.provide('remote', {
-    $dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) },
-  })
-  const handle: ConnectionHandle = {
-    api,
-    isLoopback: true,
-    hostDescription: {
-      getSnapshot: () => undefined,
-      subscribe: () => () => {},
-    },
-    rpc: {
-      call: () => Promise.reject(new Error('unexpected generic RPC call')),
-    },
-    start: (sinks) => {
-      bench.sinks = sinks
-      return { stop: () => {} }
-    },
-  }
-  ctx.reflect.provide('connection', handle)
-  ctx.reflect.provide('remote.commands', fakeRemote().commands)
-  await ctx.plugin(RuntimeClient).await()
-  return bench
-}
-
-describe('wire event bridge', () => {
-  it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => {
-    const bench = await mount()
-    const seen = bench.dispatched
-    bench.sinks?.onHostEnvelope?.({
-      rpcId: 'r1' as never,
-      payload: { type: 'host/remote-event', event: 'commands/change', args: [] },
-    })
-    expect(seen).toEqual([['commands/change']])
-
-    bench.sinks?.onHostEnvelope?.({
-      rpcId: 'r2' as never,
-      payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
-    })
-    expect(seen).toEqual([['commands/change']])
-  })
-
-  it('carries each forwarded event name with its own argument list, unfiltered', async () => {
-    const bench = await mount()
-    const seen = bench.dispatched
-
-    bench.sinks?.onHostEnvelope?.({
-      rpcId: 'r3' as never,
-      payload: { type: 'host/remote-event', event: 'settings/document-updated', args: ['llm-pi-ai', 7] },
-    })
-    bench.sinks?.onHostEnvelope?.({
-      rpcId: 'r4' as never,
-      payload: { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] },
-    })
-    // The carrier does not second-guess the name: selecting what a consumer can
-    // receive is the allowlist's job, and dropping an unsubscribed name is the
-    // Remote service's. This plugin republishes whatever the frame carried.
-    bench.sinks?.onHostEnvelope?.({
-      rpcId: 'r5' as never,
-      payload: { type: 'host/remote-event', event: 'nobody/listening', args: ['ignored'] },
-    })
-
-    expect(seen).toEqual([
-      ['settings/document-updated', 'llm-pi-ai', 7],
-      ['credentials/reference-updated', 'OPENAI_API_KEY'],
-      ['nobody/listening', 'ignored'],
-    ])
-  })
-
-  it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
-    const bench = await mount()
-    let resets = 0
-    bench.ctx.on('connection/reset', () => { resets++ })
-    const description = { version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }
-    bench.sinks?.onConnected?.(description)
-    bench.sinks?.onConnected?.(description) // second generation after a reconnect
-    expect(resets).toBe(2)
-  })
-})

+ 12 - 14
packages/client/ui-agent-preset/tests/apply.client.spec.ts

@@ -80,9 +80,7 @@ async function bench() {
   const locale = new LocaleRuntime(ctx)
   locale.setLocale('zh')
   ctx.provide('locale', locale)
-  // The plugins inject `remote`; forwarded events reach them through the
-  // same `$dispatch` handoff the connection sink makes.
-  new TestRemote(ctx)
+  const remote = new TestRemote(ctx)
   const calls: string[] = []
   ctx.provide('connection', {
     api: {
@@ -120,7 +118,7 @@ async function bench() {
     },
   } as never)
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
-  return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault }
+  return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote }
 }
 
 function declareRoot(slots: SlotRegistry): () => void {
@@ -255,18 +253,18 @@ describe('ui-agent-preset apply', () => {
   })
 
   it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
-    const { ctx, slots, calls } = await bench()
+    const { ctx, slots, calls, remote } = await bench()
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
     const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
     await section.load()
     const before = calls.length
 
-    ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
+    remote.emit('settings/document-updated', ['agent-presets', 1])
     await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
     const afterRelevant = calls.length
 
-    ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
+    remote.emit('settings/document-updated', ['llm-deepseek', 1])
     await Promise.resolve()
 
     // Both surfaces re-read on their own namespace; an unrelated one moves
@@ -289,12 +287,12 @@ describe('ui-agent-preset apply', () => {
   })
 
   it('leaves the section alone until it has been opened once', async () => {
-    const { ctx, slots, calls } = await bench()
+    const { ctx, slots, calls, remote } = await bench()
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
     const before = calls.length
 
-    ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
+    remote.emit('settings/document-updated', ['agent-presets', 1])
     await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
 
     // Only the General row reloads: a section nobody opened has nothing to
@@ -325,7 +323,7 @@ describe('ui-agent-preset apply', () => {
   })
 
   it('moves the chip when the default changes on the settings surface', async () => {
-    const { ctx, slots, moveDefault } = await bench()
+    const { ctx, slots, moveDefault, remote } = await bench()
     declareRoot(slots)
     const conversation = declareConversation(slots)
     ctx.provide('conversation', {} as never)
@@ -345,11 +343,11 @@ describe('ui-agent-preset apply', () => {
     // An unrelated namespace moves nothing: the chip re-reads on its own
     // setting, not on every settings write in the process.
     moveDefault()
-    ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1])
+    remote.emit('settings/document-updated', ['llm-deepseek', 1])
     await Promise.resolve()
     expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
 
-    ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1])
+    remote.emit('settings/document-updated', ['agent-presets', 1])
     await vi.waitFor(() => {
       expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
     })
@@ -357,7 +355,7 @@ describe('ui-agent-preset apply', () => {
   })
 
   it('folds a remote preset commit into the shared session row', async () => {
-    const { ctx, slots } = await bench()
+    const { ctx, slots, remote } = await bench()
     declareRoot(slots)
     declareConversation(slots)
     ctx.provide('conversation', {} as never)
@@ -369,7 +367,7 @@ describe('ui-agent-preset apply', () => {
     ctx.provide('workspaces', workspacesDouble() as never)
     await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
 
-    ctx.remote.$dispatch('agent-preset/selected', ['s1', 'minimal'])
+    remote.emit('agent-preset/selected', ['s1', 'minimal'])
 
     expect(state.byId.s1.agentPreset).toBe('minimal')
   })

+ 7 - 18
packages/client/ui-commands/tests/service.client.spec.ts

@@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
 import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
 import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
 import type { CommandDescriptor } from '../src/client/directory.ts'
@@ -112,19 +113,7 @@ async function bench(opts: BenchOptions = {}) {
       ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
       : undefined,
   })
-  const forwarded = new Map<string, Array<(...args: never[]) => void>>()
-  ctx.provide('remote', {
-    commands: commandsRemote,
-    $on: (event: string, listener: (...args: never[]) => void) => {
-      const listeners = forwarded.get(event) ?? []
-      listeners.push(listener)
-      forwarded.set(event, listeners)
-      return () => { forwarded.set(event, listeners.filter(entry => entry !== listener)) }
-    },
-    $dispatch: (event: string, args: readonly unknown[]) => {
-      for (const listener of forwarded.get(event) ?? []) listener(...args as never[])
-    },
-  })
+  const remote = Object.assign(new TestRemote(ctx), { commands: commandsRemote })
   ctx.provide('remote.commands', commandsRemote)
   const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = []
   ctx.on('command/executed', (sessionId, name, result) => {
@@ -155,7 +144,7 @@ async function bench(opts: BenchOptions = {}) {
   const warm = async (session: ClientSessionContext) => {
     await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal })
   }
-  return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices }
+  return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, remote }
 }
 
 function menuPick(source: InputTriggerSource, name: string, session: ClientSessionContext, end?: number) {
@@ -760,7 +749,7 @@ describe('popupFor', () => {
 describe('directory invalidation events', () => {
   it('commands/change repulls in the background while the old snapshot serves', async () => {
     let round = 0
-    const { ctx, source, warm } = await bench({
+    const { source, warm, remote } = await bench({
       commands: () => {
         round += 1
         return Promise.resolve({
@@ -771,7 +760,7 @@ describe('directory invalidation events', () => {
       },
     })
     await warm(proj('s1'))
-    ctx.remote.$dispatch('commands/change', [])
+    remote.emit('commands/change', [])
     await new Promise(resolve => setTimeout(resolve, 0))
     expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
     expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
@@ -779,7 +768,7 @@ describe('directory invalidation events', () => {
 
   it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => {
     const rounds = new Map<SessionId, number>()
-    const { ctx, source, warm } = await bench({
+    const { source, warm, remote } = await bench({
       commands: (payload) => {
         const round = (rounds.get(payload.sessionId) ?? 0) + 1
         rounds.set(payload.sessionId, round)
@@ -794,7 +783,7 @@ describe('directory invalidation events', () => {
     await warm(proj('s2'))
     // A preset switch changes which commands one session's agent resolves;
     // every other session keeps the catalog its own composition serves.
-    ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal'])
+    remote.emit('agent-preset/selected', [sid('s1'), 'minimal'])
     await new Promise(resolve => setTimeout(resolve, 0))
     expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
     expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()

+ 1 - 1
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -675,7 +675,7 @@ export type ApprovalWait = PendingWait<'approval'>
 /**
  * Approval domain face over the carrier (the ui-user-questions PendingQuestion
  * pattern): render identity and question material forwarded transparently;
- * answer owns the wire encoding — the ApprovalResponsePayload value shape
+ * answer owns the Session Controller approval-response value
  * with the audit correlation the host reconciles — and turns a rejected
  * carrier receipt into a thrown error. Minted per carrier via useMemo.
  */

+ 1 - 1
packages/client/ui-conversation/src/client/input/contract.ts

@@ -223,7 +223,7 @@ export interface InputState {
   readonly occurrences: readonly Occurrence[]
   /** Live paste-match attempt (absent when no paste is matchable). */
   readonly paste?: PasteAttemptState
-  /** Read-only transient inbox projection (`session/queue`, including pending steering). */
+  /** Read-only transient inbox projection from Session control, including pending steering. */
   readonly queue: readonly QueuedMessage[]
 }
 

+ 3 - 3
packages/client/ui-conversation/tests/chat-view.client.spec.tsx

@@ -12,7 +12,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import {
   createSnapshotStore, EMPTY_CONVERSATION_VIEWS, PendingWait,
 } from '@deepseek-ai/dsh-client-runtime/client'
-import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
+import type { SessionInteractionId } from '@deepseek-ai/dsh-api-remotes/client'
 import type {
   ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -1342,9 +1342,9 @@ describe('ChatView', () => {
   it('pending waits leave the flow entirely — questions and approvals both take over the composer', () => {
     const h = makeHarness({
       pending: [
-        new PendingWait('approval', RpcId('r1'), SID,
+        new PendingWait('approval', 'r1' as SessionInteractionId, SID,
           { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()),
-        new PendingWait('question', RpcId('r2'), SID,
+        new PendingWait('question', 'r2' as SessionInteractionId, SID,
           { questions: [{ id: 'q1', question: '选择' }] }, vi.fn()),
       ],
     })

+ 6 - 4
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -14,7 +14,7 @@ import {
 import type {
   ConversationEventInput, ConversationLocationDataStore, ConversationMatch, ConversationNodeDefinition,
   ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition,
-  ConversationViewNode, ToolResultNode, TurnLocation,
+  ConversationViewNode, TurnLocation,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
 import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -123,10 +123,12 @@ function matched(input: ConversationEventInput, role: ConversationMatch['role'])
   return { ...input, role, location: { kind: 'unresolved' } }
 }
 
+type WireCallView = Extract<NonNullable<ConversationEventInput['view']>, { for: 'call' }>['view']
+
 function call(
   seq: number,
   callId: string,
-  view: ToolResultNode['callView'],
+  view: WireCallView | null,
   turn = 1,
 ): ConversationEventInput {
   return at(
@@ -148,7 +150,7 @@ function result(seq: number, callId: string, isError = false, turn = 1): Convers
   })
 }
 
-function diff(...paths: string[]): ToolResultNode['callView'] {
+function diff(...paths: string[]): WireCallView {
   return {
     card: 'diff', title: `Write ${paths[0] ?? ''}`,
     diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })),
@@ -156,7 +158,7 @@ function diff(...paths: string[]): ToolResultNode['callView'] {
   }
 }
 
-function edit(path: string): ToolResultNode['callView'] {
+function edit(path: string): WireCallView {
   return { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }
 }
 

+ 4 - 6
packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts

@@ -39,9 +39,7 @@ async function bench() {
   const locale = new LocaleRuntime(ctx)
   locale.setLocale('en')
   ctx.provide('locale', locale)
-  // The plugin injects `remote`; forwarded events reach it through the same
-  // `$dispatch` handoff the connection sink makes.
-  new TestRemote(ctx)
+  const remote = new TestRemote(ctx)
   ctx.slots.register({
     name: 'root',
     children: {
@@ -90,7 +88,7 @@ async function bench() {
   const fiber = ctx.plugin({ inject: [...inject], apply })
   await fiber.await()
   return {
-    ctx, fiber, values, commands,
+    ctx, fiber, values, commands, remote,
     setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
     decoration: () => decoration,
     permissionRow: () => ctx.slots.entries('settings.general.item')
@@ -164,8 +162,8 @@ describe('ui-permission browser plugin', () => {
   it('disposal removes the decoration (HMR safety)', async () => {
     const b = await bench()
     expect(b.decoration()).toBeDefined()
-    b.ctx.remote.$dispatch('settings/document-updated', ['another', 1])
-    b.ctx.remote.$dispatch('settings/document-updated', ['permission', 1])
+    b.remote.emit('settings/document-updated', ['another', 1])
+    b.remote.emit('settings/document-updated', ['permission', 1])
     b.ctx.emit('connection/reset')
     await b.fiber.dispose()
     expect(b.decoration()).toBeUndefined()

+ 8 - 10
packages/client/ui-settings-models/tests/apply.client.spec.ts

@@ -24,9 +24,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {}
   const locale = new LocaleRuntime(ctx)
   locale.setLocale('zh')
   ctx.provide('locale', locale)
-  // The plugins inject `remote`; forwarded events reach them through the
-  // same `$dispatch` handoff the connection sink makes.
-  new TestRemote(ctx)
+  const remote = new TestRemote(ctx)
   // Without a settings face the mirror's reads fail and stay contained; the
   // Models join itself never fetches until a section actually loads. The real
   // ui-settings apply also provides the settingsSchema service.
@@ -35,7 +33,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {}
     isLoopback,
   } as never)
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
-  return { ctx, slots: ctx.get('slots') as SlotRegistry, locale }
+  return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, remote }
 }
 
 function declare(slots: SlotRegistry): () => void {
@@ -176,9 +174,9 @@ describe('pushed invalidations', () => {
     declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     // The fake wire face has no methods: a fetch attempt would throw.
-    b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1])
-    b.ctx.remote.$dispatch('credentials/reference-updated', ['OPENAI_API_KEY'])
-    b.ctx.remote.$dispatch('llm/adapters-updated', [])
+    b.remote.emit('settings/document-updated', ['llm-pi-ai', 1])
+    b.remote.emit('credentials/reference-updated', ['OPENAI_API_KEY'])
+    b.remote.emit('llm/adapters-updated', [])
     b.ctx.emit('connection/reset')
   })
 
@@ -210,7 +208,7 @@ describe('pushed invalidations', () => {
     )()
     injected.controller.store.update((state) => { state.status = 'ready' })
     const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
-    b.ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY'])
+    b.remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY'])
     expect(load).toHaveBeenCalledTimes(1)
   })
 
@@ -252,7 +250,7 @@ describe('pushed invalidations', () => {
       expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false })
     })
     acknowledgement.current = WELCOME_NOTICE_VERSION
-    b.ctx.remote.$dispatch('settings/document-updated', ['ui-onboarding', 1])
+    b.remote.emit('settings/document-updated', ['ui-onboarding', 1])
     await vi.waitFor(() => {
       expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
     })
@@ -295,7 +293,7 @@ describe('pushed invalidations', () => {
     expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1)
 
     revision = 2
-    b.ctx.remote.$dispatch('settings/document-updated', ['llm-test', revision])
+    b.remote.emit('settings/document-updated', ['llm-test', revision])
 
     await vi.waitFor(() => {
       expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2)

+ 8 - 11
packages/client/ui-settings-plugins/tests/apply.client.spec.ts

@@ -42,10 +42,7 @@ async function bench(served?: string[]) {
         },
       },
     }))
-  // The section binds its scopes through the Settings surface's service, and
-  // forwarded Host events reach it through the same `$dispatch` handoff the
-  // connection sink makes.
-  new TestRemote(ctx)
+  const remote = new TestRemote(ctx)
   ctx.provide('connection', {
     isLoopback: true,
     api: {
@@ -54,7 +51,7 @@ async function bench(served?: string[]) {
     },
   } as never)
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
-  return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings }
+  return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote }
 }
 
 function declareRoot(slots: SlotRegistry): () => void {
@@ -148,13 +145,13 @@ describe('ui-settings-plugins apply', () => {
     // Which namespaces the Host serves is a registration fact the wire never
     // announces on its own, so the tab rides the invalidation that can
     // accompany a changed composition.
-    const { ctx, slots, describeSettings } = await bench(['bash'])
+    const { ctx, slots, describeSettings, remote } = await bench(['bash'])
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
     await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
     describeSettings.mockClear()
 
-    ctx.remote.$dispatch('settings/document-updated', ['bash', 1])
+    remote.emit('settings/document-updated', ['bash', 1])
 
     await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() })
   })
@@ -172,7 +169,7 @@ describe('ui-settings-plugins apply', () => {
   })
 
   it('re-reads the credential when the Host reports the watched reference changed', async () => {
-    const { ctx, slots, describeCredentials } = await bench()
+    const { ctx, slots, describeCredentials, remote } = await bench()
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
     await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
@@ -180,19 +177,19 @@ describe('ui-settings-plugins apply', () => {
 
     // A key written on another surface changes no settings section, so this
     // event is the only thing that reaches the card.
-    ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY'])
+    remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY'])
 
     await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
   })
 
   it('ignores a credential change for a reference no card watches', async () => {
-    const { ctx, slots, describeCredentials } = await bench()
+    const { ctx, slots, describeCredentials, remote } = await bench()
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
     await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
     describeCredentials.mockClear()
 
-    ctx.remote.$dispatch('credentials/reference-updated', ['SOME_OTHER_KEY'])
+    remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY'])
     await Promise.resolve()
 
     expect(describeCredentials).not.toHaveBeenCalled()

+ 6 - 6
packages/client/ui-settings/tests/plugin.client.spec.ts

@@ -15,8 +15,8 @@ function bench() {
     api: { settings: { describe: describeCall } },
     isLoopback: true,
   } as never)
-  new TestRemote(ctx)
-  return { ctx, describeCall, fiber: ctx.plugin({ inject: [...inject], apply }) }
+  const remote = new TestRemote(ctx)
+  return { ctx, describeCall, remote, fiber: ctx.plugin({ inject: [...inject], apply }) }
 }
 
 describe('settings domain base plugin', () => {
@@ -29,23 +29,23 @@ describe('settings domain base plugin', () => {
   })
 
   it('refreshes the mirror on document commits and connection resets, once each', async () => {
-    const { ctx, describeCall, fiber } = bench()
+    const { ctx, describeCall, remote, fiber } = bench()
     await fiber.await()
     await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) })
-    ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
+    remote.emit('settings/document-updated', ['ui-test', 0])
     await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(2) })
     ctx.emit('connection/reset')
     await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
   })
 
   it('fiber disposal retires the service and its invalidation subscriptions', async () => {
-    const { ctx, describeCall, fiber } = bench()
+    const { ctx, describeCall, remote, fiber } = bench()
     await fiber.await()
     await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(1) })
     await fiber.dispose()
     expect(ctx.get('settingsScope')).toBeUndefined()
     expect(ctx.get('settingsSchema')).toBeUndefined()
-    ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0])
+    remote.emit('settings/document-updated', ['ui-test', 0])
     ctx.emit('connection/reset')
     await Promise.resolve()
     expect(describeCall).toHaveBeenCalledTimes(1)

+ 4 - 4
packages/client/ui-skill/tests/browser-plugin.client.spec.ts

@@ -74,10 +74,10 @@ async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) {
       ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
       : undefined,
   })
-  new TestRemote(ctx)
+  const remote = new TestRemote(ctx)
   providePresentation(ctx)
   await ctx.plugin({ inject: [...inject], apply }).await()
-  return { ctx, source: captured! }
+  return { ctx, source: captured!, remote }
 }
 
 const CATALOG: SkillRow[] = [
@@ -269,13 +269,13 @@ describe('catalog cache', () => {
 
   it('agent-preset/selected clears only the recomposed session', async () => {
     const { list, payloads } = countingList()
-    const { ctx, source } = await bench(list)
+    const { source, remote } = await bench(list)
     await source.candidates(proj('s1'), req(''))
     await source.candidates(proj('s2'), req(''))
     expect(payloads).toHaveLength(2)
     // The catalog a preset supplies is the preset's; the other session's
     // composition did not change, so its cached catalog still holds.
-    ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal'])
+    remote.emit('agent-preset/selected', [sid('s1'), 'minimal'])
     await source.candidates(proj('s1'), req(''))
     await source.candidates(proj('s2'), req(''))
     expect(payloads).toHaveLength(3)

+ 7 - 8
packages/client/ui-theme/tests/apply.client.spec.ts

@@ -55,11 +55,10 @@ async function bench(isLoopback = true) {
     })
   })
   ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
-  // The settings transport and the forwarded-event port the plugin injects.
-  new TestRemote(ctx)
+  const events = new TestRemote(ctx)
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
   return {
-    ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate,
+    ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, events,
     setHostPreference: (next: string) => { preference = next },
   }
 }
@@ -131,18 +130,18 @@ describe('ui-theme apply', () => {
     // The shared mirror read once at bench time; a Host-side change reaches it
     // through the document invalidation, exactly as production announces one.
     b.setHostPreference('dark')
-    b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
+    b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
     declareItems(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     const theme = b.ctx.get('theme') as ThemeRuntime
     await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
     // The mirror refreshes on every document commit (ns-agnostic); the scope's
     // derived value only moves when its own namespace changed.
-    b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0])
+    b.events.emit('settings/document-updated', ['unrelated', 0])
     await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(3) })
     expect(theme.getTheme().preference).toBe('dark')
     b.setHostPreference('light')
-    b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
+    b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
     await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') })
     b.setHostPreference('dark')
     b.ctx.emit('connection/reset')
@@ -166,7 +165,7 @@ describe('ui-theme apply', () => {
     b.describe.mockImplementationOnce(() => pending.promise)
     // The refresh hangs on the wire; the mirror keeps serving the last good
     // answer, so activation never blocks on the settings transport.
-    b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
+    b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
     const fiber = b.ctx.plugin({ inject: [...inject], apply })
     await fiber.await()
     const theme = b.ctx.get('theme') as ThemeRuntime
@@ -179,7 +178,7 @@ describe('ui-theme apply', () => {
   it('ignores an invalid preference crossing the settings wire', async () => {
     const b = await bench()
     b.setHostPreference('sepia')
-    b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
+    b.events.emit('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0])
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     const theme = b.ctx.get('theme') as ThemeRuntime
     await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledTimes(2) })

+ 2 - 2
packages/client/ui-user-questions/src/client/contract/slots.ts

@@ -11,13 +11,13 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots
 // entry) into every program that sees this contract, so PropsRuntime resolves.
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
-import type { QuestionResponsePayload } from '@deepseek-ai/dsh-api-remotes/client'
+import type { SessionQuestionResponse } from '@deepseek-ai/dsh-api-remotes/client'
 
 /** The pending question carrier the owner dispatches into the composer slot. */
 export type QuestionWait = PendingWait<'question'>
 
 /** One structured answer batch covering every question of the request. */
-export type QuestionAnswer = QuestionResponsePayload['answer']
+export type QuestionAnswer = SessionQuestionResponse['answer']
 
 /** One question of the request, as the carrier payload carries it. */
 type QuestionItem = QuestionWait['payload']['questions'][number]

+ 15 - 8
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -5,8 +5,7 @@ import type {
   ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
-import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client'
-import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
+import type { SessionInteractionId } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { planReviewOf, type QuestionComposerProps, type QuestionWait } from '../src/client/contract/slots.ts'
 import { QuestionComposer } from '../src/client/QuestionComposer.tsx'
@@ -17,6 +16,8 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
 afterEach(cleanup)
 
 const SID = 's1' as SessionId
+const interactionId = (value: string): SessionInteractionId => value as SessionInteractionId
+type QuestionRespond = ConstructorParameters<typeof PendingWait<'question'>>[4]
 
 const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
   (key => dict[key] ?? common[key] ?? key)
@@ -52,15 +53,18 @@ const questions = (): QuestionWait['payload']['questions'] => [{
 /** Carrier fixture over a scripted respond carrier. */
 function wait(
   payload: QuestionWait['payload'] = { questions: questions() },
-  respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true })),
+  respond: QuestionRespond = vi.fn(() => Promise.resolve({
+    ok: true as const,
+    value: { accepted: true as const },
+  })),
 ) {
-  return { carrier: new PendingWait('question', RpcId('q-1'), SID, payload, respond), respond }
+  return { carrier: new PendingWait('question', interactionId('q-1'), SID, payload, respond), respond }
 }
 
-/** The client-response envelope respond must have received for a decision. */
+/** The Session Controller response request emitted for a decision. */
 function decidedEnvelope(label: string) {
   return {
-    type: 'client-response', rpcId: RpcId('q-1'),
+    interactionId: interactionId('q-1'),
     result: { ok: true, value: { sessionId: SID, answer: { answers: [{ id: 'plan-review', selected: [label] }] } } },
   }
 }
@@ -156,7 +160,7 @@ describe('PlanReviewPanel', () => {
 
     fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
     expect(respond).toHaveBeenCalledWith({
-      type: 'client-response', rpcId: RpcId('q-1'),
+      interactionId: interactionId('q-1'),
       result: {
         ok: false,
         error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
@@ -188,7 +192,10 @@ describe('PlanReviewPanel', () => {
   it('re-arms the actions and says why when the decision does not land', async () => {
     const { carrier, respond } = wait(
       { questions: questions() },
-      vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: false, reason: 'not-pending' })),
+      vi.fn(() => Promise.resolve({
+        ok: true as const,
+        value: { accepted: false as const, reason: 'not-pending' as const },
+      })),
     )
     render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
 

+ 23 - 16
packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx

@@ -5,8 +5,7 @@ import type {
   ConversationSnapshot, SessionId, SessionListState, WorkspaceListState,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
-import type { RpcReceipt } from '@deepseek-ai/dsh-api-remotes/client'
-import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
+import type { SessionInteractionId } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
 import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
@@ -17,6 +16,8 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
 afterEach(cleanup)
 
 const SID = 's1' as SessionId
+const interactionId = (value: string): SessionInteractionId => value as SessionInteractionId
+type QuestionRespond = ConstructorParameters<typeof PendingWait<'question'>>[4]
 
 const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
   (key => dict[key] ?? common[key] ?? key)
@@ -56,16 +57,22 @@ const QUESTIONS = [
 ]
 
 /** Carrier fixture: a real PendingWait over a scripted respond carrier. */
-function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))) {
+function wait(
+  id = 'question-1',
+  respond: QuestionRespond = vi.fn(() => Promise.resolve({
+    ok: true as const,
+    value: { accepted: true as const },
+  })),
+) {
   const carrier = new PendingWait(
-    'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond)
+    'question', interactionId(id), SID, { questions: QUESTIONS }, respond)
   return { carrier, respond }
 }
 
-/** The client-response envelope respond must have received for an answer batch. */
-function answeredEnvelope(rpcId: string, answers: object[]) {
+/** The Session Controller response request emitted for an answer batch. */
+function answeredEnvelope(id: string, answers: object[]) {
   return {
-    type: 'client-response', rpcId: RpcId(rpcId),
+    interactionId: interactionId(id),
     result: { ok: true, value: { sessionId: SID, answer: { answers } } },
   }
 }
@@ -123,7 +130,7 @@ describe('QuestionComposer', () => {
   it('renders plan detail through the shared assistant Markdown primitive', () => {
     const carrier = new PendingWait(
       'question',
-      RpcId('markdown-plan'),
+      interactionId('markdown-plan'),
       SID,
       {
         questions: [{
@@ -242,7 +249,7 @@ describe('QuestionComposer', () => {
 
   it('surfaces cancellation failures: rejected receipt text and raw transport reasons', async () => {
     const respond = vi.fn()
-      .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
+      .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'bad-response' } })
       .mockRejectedValueOnce(new Error('第二次取消失败'))
     const { carrier } = wait('question-1', respond)
     render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
@@ -288,9 +295,9 @@ describe('QuestionComposer', () => {
   })
 
   it('renders chrome copy through the English dictionary', () => {
-    const respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))
+    const respond = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
     const carrier = new PendingWait(
-      'question', RpcId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
+      'question', interactionId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
     render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
     expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
     expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
@@ -312,8 +319,8 @@ describe('QuestionComposer', () => {
 describe('PendingQuestion domain face', () => {
   it('encodes the answer batch into the ok envelope and throws on a rejected receipt', async () => {
     const respond = vi.fn()
-      .mockResolvedValueOnce({ accepted: true })
-      .mockResolvedValueOnce({ accepted: false, reason: 'not-pending' })
+      .mockResolvedValueOnce({ ok: true, value: { accepted: true } })
+      .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'not-pending' } })
     const question = new PendingQuestion(wait('rq', respond).carrier)
     const batch = { answers: [{ id: 'mode', selected: ['Fast'] }] }
     await expect(question.answer(batch)).resolves.toBeUndefined()
@@ -323,12 +330,12 @@ describe('PendingQuestion domain face', () => {
 
   it('encodes cancellation as the cancelled error envelope and throws on a rejected receipt', async () => {
     const respond = vi.fn()
-      .mockResolvedValueOnce({ accepted: true })
-      .mockResolvedValueOnce({ accepted: false, reason: 'bad-response' })
+      .mockResolvedValueOnce({ ok: true, value: { accepted: true } })
+      .mockResolvedValueOnce({ ok: true, value: { accepted: false, reason: 'bad-response' } })
     const question = new PendingQuestion(wait('rc', respond).carrier)
     await expect(question.cancel()).resolves.toBeUndefined()
     expect(respond).toHaveBeenCalledWith({
-      type: 'client-response', rpcId: RpcId('rc'),
+      interactionId: interactionId('rc'),
       result: {
         ok: false,
         error: { code: 'cancelled', message: 'the user closed this question request', details: {} },

+ 1 - 8
packages/client/web/src/boot.ts

@@ -100,15 +100,8 @@ export class AppWebEntry {
     await mounted
   }
 
-  /** Prefetch stage-one bundles; their import path owns any eventual failure. */
+  /** Prefetch stage-one bundles and their dynamic requests before concurrent plugin imports. */
   private async prefetchImmediateTier(): Promise<void> {
-    // A transport carrying loadBundle owns the bundle bytes; HTTP prefetch
-    // against its static deployment answers nothing. A transport without
-    // loadBundle leaves bundles on HTTP, prefetch included.
-    const transport = (globalThis as {
-      __DSH_TRANSPORT__?: { loadBundle?: unknown }
-    }).__DSH_TRANSPORT__
-    if (transport?.loadBundle !== undefined) return
     await Promise.all(this.manifest.plugins
       .filter(row => row.immediately)
       .map(row => this.modules.prefetch(row.id).catch((_prefetchError: unknown) => {

+ 69 - 0
packages/client/web/tests/boot.client.spec.ts

@@ -9,13 +9,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { AppWebEntry } from '../src/boot.ts'
 
 const MODULES_ID = '@deepseek-ai/dsh-client-modules'
+const PROVIDER_CLIENT_ID = 'provider/client'
+const RUNTIME_CLIENT_ID = 'runtime/client'
 const win = globalThis as DshWindow
+const transportGlobal = globalThis as {
+  __DSH_TRANSPORT__?: { loadBundle(url: string): Promise<void> }
+}
 const moduleFace = modulesClient as unknown as Record<string, unknown>
 
 afterEach(() => {
   vi.restoreAllMocks()
   delete win.__DSH_BOOT__
   delete win.__ModuleLoader__
+  delete transportGlobal.__DSH_TRANSPORT__
   document.body.innerHTML = ''
 })
 
@@ -80,6 +86,69 @@ describe('bootstrap failure rendering', () => {
 })
 
 describe('plugin activation', () => {
+  it('prefetches a parser-loaded immediate row through the injected bundle transport', async () => {
+    const container = document.createElement('div')
+    document.body.append(container)
+    const target = installFacade()
+    const entries: WebBootEntry[] = [
+      { id: 'consumer', url: '/consumer.js', rev: '1' },
+      {
+        id: 'runtime',
+        url: '/runtime.js',
+        rev: '1',
+        external: [PROVIDER_CLIENT_ID],
+        immediately: true,
+      },
+      { id: 'provider', url: '/provider.js', rev: '1' },
+      { id: 'renderer', url: '/renderer.js', rev: '1' },
+    ]
+    win.__DSH_BOOT__ = { rev: 'graph', entries }
+    target.load({
+      id: 'runtime',
+      factory: require => ({
+        apply: () => {},
+        marker: (require(PROVIDER_CLIENT_ID) as { marker: string }).marker,
+      }),
+    })
+    const loaded: string[] = []
+    const registrations = new Map<string, ClientBundleRegistration>([
+      ['/consumer.js', {
+        id: 'consumer',
+        factory: require => ({
+          apply: () => {
+            expect((require(RUNTIME_CLIENT_ID) as { marker: string }).marker).toBe('provider')
+          },
+        }),
+      }],
+      ['/provider.js', {
+        id: 'provider',
+        factory: () => ({ apply: () => {}, marker: 'provider' }),
+      }],
+      ['/renderer.js', {
+        id: 'renderer',
+        factory: () => ({
+          apply: (ctx: Context) => {
+            ctx.reflect.provide('uiRenderer', { mount: () => () => {} })
+          },
+        }),
+      }],
+    ])
+    transportGlobal.__DSH_TRANSPORT__ = {
+      loadBundle: async (url) => {
+        loaded.push(url)
+        const registration = registrations.get(url)
+        if (registration === undefined) throw new Error(`missing fixture registration ${url}`)
+        target.load(registration)
+      },
+    }
+
+    const entry = new AppWebEntry(container)
+    await entry.run()
+
+    expect(loaded).toEqual(['/provider.js', '/consumer.js', '/renderer.js'])
+    await entry.dispose()
+  })
+
   it('allows a modules-dependent row to be created before the modules row', async () => {
     const events: string[] = []
     const container = document.createElement('div')

+ 14 - 20
packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts

@@ -16,7 +16,7 @@ import type {
 } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
 import type { DynamicCordisInvokeResult } from '@deepseek-ai/dsh-api-remotes/client'
-// Type-only: resolves `ctx.remote` and with it the `$on`/`$dispatch` surface.
+// Type-only: resolves the `ctx.remote.$on` surface.
 import type {} from '@deepseek-ai/dsh-api-gateway/client'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
 import * as NodeHalf from '../src/index.ts'
@@ -31,15 +31,6 @@ const USER_RUN = {
   agentId: AGENT, pluginId: PLUGIN, packageId: PACKAGE, mode: 'run' as const, hasClientHalf: true,
 }
 
-/**
- * Deliver one forwarded Host event the way the runtime's frame bridge does: the
- * bridge hands `host/remote-event` to the Remote service, which fans it out to
- * `$on` subscribers with the Host's own argument list.
- */
-function forward(ctx: Context, event: string, payload: object): void {
-  ctx.remote.$dispatch(event, [payload])
-}
-
 interface Bench {
   ctx: Context
   /** Source the host hands over for the next run. */
@@ -67,6 +58,8 @@ interface Bench {
   }[]
   /** Whether the namespace refuses the next render-failure report. */
   reportRefused: { current: boolean }
+  /** Drive one forwarded Host event through the test-owned subscription table. */
+  forward: (event: string, payload: object) => void
   /**
    * Report one entry crash the way the renderer's boundary does. Production calls
    * this from ui-renderer's boundary through the render host; a test has no React
@@ -161,6 +154,11 @@ async function boot(): Promise<Bench> {
   // this plugin's subscriptions, so registration order and delivery are all the
   // stub owes (api-gateway covers isolation and disposal on the real one).
   const listeners = new Map<string, ((...args: never[]) => void)[]>()
+  const forward = (event: string, payload: object): void => {
+    for (const listener of [...listeners.get(event) ?? []]) {
+      (listener as (...args: readonly unknown[]) => void)(payload)
+    }
+  }
   const remote = {
     dynamicCordisRunner: namespace,
     $on: (event: string, listener: (...args: never[]) => void) => {
@@ -172,11 +170,6 @@ async function boot(): Promise<Bench> {
         if (at >= 0) bucket.splice(at, 1)
       }
     },
-    $dispatch: (event: string, args: readonly unknown[]) => {
-      for (const listener of [...listeners.get(event) ?? []]) {
-        (listener as (...a: readonly unknown[]) => void)(...args)
-      }
-    },
   }
   ctx.reflect.provide('remote', remote)
   ctx.reflect.provide('remote.dynamicCordisRunner', namespace)
@@ -191,6 +184,7 @@ async function boot(): Promise<Bench> {
     invokeThrow,
     renderFailures,
     reportRefused,
+    forward,
     crash: (slot, entry, abdicate, error) => {
       const core = (ctx.slots as unknown as {
         _core: { reportEntryError(key: string, entry: unknown, error: unknown, info: { abdicate: boolean }): void }
@@ -213,7 +207,7 @@ describe('browser half', () => {
     const bench = await boot()
     await bench.ctx.dynamicCordisRunner.startUserRun(USER_RUN)
     expect(bench.ctx.dynamicCordisRunner.isLoaded(PLUGIN)).toBe(true)
-    forward(bench.ctx, 'cordis/dynamic-retract', {
+    bench.forward('cordis/dynamic-retract', {
       pluginId: PLUGIN, packageId: PACKAGE, pluginRunId: RUN,
     })
     await bench.settle()
@@ -350,7 +344,7 @@ describe('browser half', () => {
   it('answers a run request after the surface approves it', async () => {
     const bench = await boot()
     const request = 'rr-1' as ApprovalRequestId
-    forward(bench.ctx, 'cordis/request-run', {
+    bench.forward('cordis/request-run', {
       requestId: request,
       agentId: AGENT,
       pluginId: PLUGIN,
@@ -383,7 +377,7 @@ describe('browser half', () => {
   it('drops the affordance when another page answers the request', async () => {
     const bench = await boot()
     const request = 'rr-2' as ApprovalRequestId
-    forward(bench.ctx, 'cordis/request-run', {
+    bench.forward('cordis/request-run', {
       requestId: request,
       agentId: AGENT,
       pluginId: PLUGIN,
@@ -394,7 +388,7 @@ describe('browser half', () => {
       requiresApproval: true,
     })
     await bench.settle()
-    forward(bench.ctx, 'cordis/request-run-resolved', {
+    bench.forward('cordis/request-run-resolved', {
       requestId: request, outcome: 'approved',
     })
     await bench.settle()
@@ -407,7 +401,7 @@ describe('browser half', () => {
   it('exposes the refusal and the load observer on the face', async () => {
     const bench = await boot()
     const request = 'rr-3' as ApprovalRequestId
-    forward(bench.ctx, 'cordis/request-run', {
+    bench.forward('cordis/request-run', {
       requestId: request,
       agentId: AGENT,
       pluginId: PLUGIN,

Разлика између датотеке није приказан због своје велике величине
+ 44 - 1455
packages/host/apiproxy/src/api-proxy.ts


+ 1 - 1
packages/host/apiproxy/src/api/agent-presets.schema.ts

@@ -6,7 +6,7 @@
 import { z } from 'zod'
 import type { RequestPayload, ResponseValue } from './rpc-map.ts'
 import type { Wire } from './rpc.schema.ts'
-import { sessionIdSchema } from './sessions.schema.ts'
+import { sessionIdSchema } from './ids.schema.ts'
 import type { AgentPresetEntry } from './agent-presets.ts'
 
 /** AgentPresetEntry row of agentPreset.list. */

+ 2 - 3
packages/host/apiproxy/src/api/downloads.schema.ts

@@ -2,13 +2,12 @@
  * downloads domain zod schemas. The download surface has no wire
  * envelope: the request arrives as query parameters (all strings), so its
  * request schema parses the raw query-parameter object into the method's
- * exact request shape. SessionId brand cast point: sessionIdSchema, and only
- * there (hosted in sessions.schema like every other cast).
+ * exact request shape.
  */
 
 import { z } from 'zod'
 import type { DownloadsApi } from './downloads.ts'
-import { sessionIdSchema } from './sessions.schema.ts'
+import { sessionIdSchema } from './ids.schema.ts'
 
 /**
  * session.export query params → the sessionLog request. `includeDescendants`

+ 2 - 3
packages/host/apiproxy/src/api/downloads.ts

@@ -1,7 +1,6 @@
 /**
- * downloads domain contract: host-only download surfaces — the GET-download
- * channel family, the mirror of the SSE-stream `events` domain. No wire
- * envelope: the carrier's GET routes answer these directly, and the browser
+ * downloads domain contract: Host-only GET download surfaces with no wire
+ * envelope. Carrier routes answer these directly, and the browser
  * `IApiClient` never exposes them.
  */
 

+ 0 - 93
packages/host/apiproxy/src/api/events.schema.ts

@@ -1,93 +0,0 @@
-/**
- * events domain zod schemas: MuxFrame / HostFrame unions (discriminatedUnion('type')).
- * A frame is the payload slot of the ServerRequest full form; the SessionEvent inside
- * a session/event frame reuses sessions.schema's strict-envelope + wide-data passthrough branch.
- */
-
-import { z } from 'zod'
-import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types'
-import type { HostFrame, MuxFrame } from './events.ts'
-import type { Wire } from './rpc.schema.ts'
-import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
-import { approvalRequestIdSchema } from './approvals.schema.ts'
-import {
-  contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
-} from './sessions.schema.ts'
-import { taskViewSchema } from './jobs.schema.ts'
-import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
-
-/** Question fields validated strictly against core dsh-user-questions. */
-export const askUserQuestionItemSchema = z.object({
-  id: z.string(),
-  question: z.string(),
-  header: z.string().optional(),
-  detail: z.string().optional(),
-  options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
-  multiSelect: z.boolean().optional(),
-  // Presentation intent: a tagged union on the wire, so an unknown tag is a
-  // rejected frame rather than a silently generic render.
-  intent: z.discriminatedUnion('kind', [
-    z.object({ kind: z.literal('plan-review'), approve: z.string() }),
-  ]).optional(),
-}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
-
-/** Unified message envelope carried by transient queue frames. */
-const messageSchema = z.object({
-  id: z.string().min(1),
-  role: z.union([z.literal('system'), z.literal('user'), z.literal('assistant')]),
-  content: z.array(contentBlockSchema),
-  source: z.looseObject({ kind: z.string() }),
-})
-
-/** MuxFrame union (payload slot of a mux-stream ServerRequest). */
-export const muxFrameSchema = z.discriminatedUnion('type', [
-  z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
-  z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
-  z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
-  z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
-  // Non-empty by wire contract: the user-questions service rejects empty
-  // batches at ask() (EMPTY_QUESTIONS), so an empty frame is host breakage
-  // and must fail loud here, not reach the composer.
-  z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
-  z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
-  z.object({
-    type: z.literal('session/queue'),
-    sessionId: sessionIdSchema,
-    items: z.array(z.object({
-      id: messageIdSchema,
-      placement: z.union([z.literal('queued'), z.literal('steering'), z.literal('context')]),
-      message: messageSchema,
-    })),
-  }),
-  z.object({ type: z.literal('session/jobs'), sessionId: sessionIdSchema, jobs: z.array(taskViewSchema) }),
-  // value stays wide: it already passed its unit's own schema on the host,
-  // and deep-validating here would import every domain's schema into the carrier.
-  z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),
-  z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
-]) as unknown as z.ZodType<MuxFrame>
-
-/** HostFrame union (payload slot of a host-stream ServerRequest). */
-export const hostFrameSchema = z.discriminatedUnion('type', [
-  z.object({
-    type: z.literal('host/session-added'),
-    sessionId: sessionIdSchema,
-    blank: z.boolean(),
-    parentSessionId: sessionIdSchema.optional(),
-    origin: z.literal('subagent').optional(),
-    cwd: z.string().optional(),
-    agentPreset: z.string().optional(),
-  }),
-  z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
-  z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
-  z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
-  z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
-  z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
-  z.object({ type: z.literal('host/workspace-order-changed'), workspaceIds: z.array(workspaceIdSchema) }),
-  z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
-  // args stays wide, the same posture as session/projection's value: the frame
-  // arrives from JSON.parse, so every element is already a JSON value, and the
-  // structural contract belongs to the owner package's cordis `Events`
-  // declaration — the host validated JSON-safety before forwarding.
-  z.object({ type: z.literal('host/remote-event'), event: z.string().min(1), args: z.array(z.unknown()) }),
-  z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
-]) as unknown as z.ZodType<HostFrame>

+ 0 - 155
packages/host/apiproxy/src/api/events.ts

@@ -1,155 +0,0 @@
-/**
- * events domain contract: signatures and frame unions for the two logical
- * streams. Four-quadrant: streams yield the narrow form `RpcRequest<Frame>` (server-request
- * view) — rpcId must be exposed to the business layer, because responses to answerable frames
- * (approval/question requested) echo it; for pure pushes it identifies that one push.
- * signal is a local stream-control parameter, independent of the request (never on the wire).
- */
-
-import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions/types'
-import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
-import type { Message } from '@deepseek-ai/dsh-llm/types'
-import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
-import type { CallId } from '@deepseek-ai/dsh-llm/brand'
-import type { JsonValue, SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
-import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
-import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
-import type { JobView } from './jobs.ts'
-import type { WorkspaceView } from './workspace.ts'
-
-// Client-side consumers take the render-intent vocabulary from the contract;
-// dsh-tools remains its owner.
-export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
-
-/**
- * Host-computed render intent accompanying a `tool/call` or `tool/result`
- * event. A pure derivation of args/result through the presenter registered at
- * emission time — never persisted (the session log carries only the event), so
- * the same event may carry a different view (or none) on a later delivery.
- * `for` names which vocabulary applies without re-inspecting the event type.
- * An absent view means the client's documented default (generic JSON card).
- */
-export type ToolEventView =
-  | { for: 'call'; view: ToolCallView }
-  | { for: 'result'; view: ToolResultView }
-
-/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */
-export interface QueuedInboxItem {
-  /** Message identity used by inbox mutations. */
-  id: MessageId
-  /** Agent-resolved FIFO placement; queued and steering items render on different surfaces, context items stay invisible until claimed. */
-  placement: 'queued' | 'steering' | 'context'
-  /** Complete pending message; it is not durable until the Agent claims it. */
-  message: Message
-}
-
-/** Streaming face of the contract: the two logical stream openers (mux + host). */
-export interface EventsApi {
-  /**
-   * All-session aggregated mux stream. On open, emits a subscribed control frame for every
-   * attached session, then replays each session's still-pending approval/question requested
-   * frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the
-   * generic projection pair (history-tail projections block + session/projection frames).
-   * since: resume hook, unimplemented in v1 (ignored if passed); reconnection = reopen the
-   * stream + refetch history.
-   */
-  mux(request: RpcRequest<{ since?: Record<SessionId, number> }>, signal: AbortSignal): AsyncIterable<RpcRequest<MuxFrame>>
-
-  /**
-   * Host-level info stream: session create/destroy, running-status flips, and
-   * agent failures with no turn position. Empty payload uses `{}`.
-   */
-  host(request: RpcRequest<{}>, signal: AbortSignal): AsyncIterable<RpcRequest<HostFrame>>
-}
-
-/**
- * Mux stream frames: raw session-event passthrough + control frames +
- * approval/question frames (requested = answerable server-request, the rest are pure pushes).
- */
-export type MuxFrame =
-  | { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
-  | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
-  | { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
-  | { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
-  | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
-  | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
-  /**
-   * Complete transient inbox state after every enqueue, mutation, claim, or
-   * discard. Pending work is not model-visible and therefore has no durable
-   * session event; the whole snapshot makes edit, deletion, cancel, and
-   * reconnect converge through one authoritative signal. `session/queue`
-   * covers both resolved placements: queued items render
-   * in QueueDock, while pending steering renders at the conversation tail.
-   */
-  | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
-  /**
-   * Complete set of background jobs this session can see, after every registry
-   * commit that changes it: registration, the stopping transition, settlement,
-   * and owner-disposal removal. The registry is process-local and holds no
-   * durable event, so — exactly like `session/queue` — the whole snapshot is
-   * what makes a start, a kill, a reconnect, and a second tab converge on one
-   * authoritative value.
-   *
-   * Sent as a subscription baseline only for a session that currently has
-   * tasks; an absent key means an empty set. A change that empties the set
-   * still sends `[]`, since that transition is the only one absence cannot
-   * express.
-   */
-  | { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] }
-  /**
-   * One projection unit's finished value changed (session-projection RFC).
-   * Live push state, never logged — replay recomputes on the host (the
-   * tool-view posture). `value` is the unit's schema-validated view output;
-   * `seq` is the unit's watermark at emission. Clients keep one generic
-   * per-session value store under higher-seq-wins, seeded by the history
-   * tail page's projections block.
-   */
-  | { type: 'session/projection'; sessionId: SessionId; key: string; value: unknown; seq: number }
-  | { type: 'stream/error'; error: RpcError }
-
-/**
- * Host stream frames. session-added carries the lineage anchor, product
- * origin, project cwd, and blank bit (the list-summary fields a client cannot
- * wait for a refresh to learn); the frame fires at session/created, so blank is
- * constantly true — clients flip it on the session's first
- * `host/session-status(running:true)` (a blank session never runs), and a
- * reconnecting client takes `session.list`'s summary.blank as authoritative.
- * agent-error is the only outlet for live failures with no turn position;
- * workspace-changed pushes the full new snapshot after every durable
- * workspace mutation (create/attach/order change — the client upserts, while
- * `workspace.list` provides the reconnect baseline); workspace-removed is the
- * committed registration-deletion increment and never implies directory or
- * session-log deletion; workspace-order-changed pushes the complete durable
- * registry order after a reorder; archived-sessions-changed pushes the full registry
- * archive set after every durable change (same full-snapshot posture as
- * workspace-changed — `workspace.list` re-baselines it on reconnect).
- */
-export type HostFrame =
-  | {
-    type: 'host/session-added'
-    sessionId: SessionId
-    blank: boolean
-    parentSessionId?: SessionId
-    origin?: 'subagent'
-    cwd?: string
-    agentPreset?: string
-  }
-  | { type: 'host/session-removed'; sessionId: SessionId }
-  | { type: 'host/session-status'; sessionId: SessionId; running: boolean }
-  | { type: 'host/agent-error'; sessionId: SessionId; message: string }
-  | { type: 'host/workspace-changed'; workspace: WorkspaceView }
-  | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
-  | { type: 'host/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] }
-  | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] }
-  /**
-   * One allowlisted host cordis event forwarded verbatim. The allowlist is
-   * owned by `@deepseek-ai/dsh-api-remotes` (`API_REMOTE_FORWARDED_EVENTS`),
-   * which is also the only control point over what a consumer can receive.
-   * `event` is the host's own event name and `args` its argument list: this
-   * path applies no projection, no redaction, and no renaming, so the payload
-   * contract is the owner package's cordis `Events` declaration rather than
-   * anything stated here. Delivery lands on `ctx.remote.$on`, not on a
-   * per-event frame variant.
-   */
-  | { type: 'host/remote-event'; event: string; args: JsonValue[] }
-  | { type: 'stream/error'; error: RpcError }

+ 4 - 5
packages/host/apiproxy/src/api/goals.ts

@@ -2,11 +2,10 @@
  * goals domain contract. Method signatures are the source of truth:
  * unary methods take the RpcRequest<P> narrow form and the impl echoes rpcId.
  *
- * Mutations only: the read side is the 'goal' session projection (history
- * tail-page projections block + session/projection frames), so there is no
- * goal.get and no wire goal view — responses acknowledge with the new CAS
- * ref and never feed client state (the committed goal/change event reaches
- * every client through the mux stream carrying the same whole value).
+ * Mutations only: the read side is the `goal` Session projection carried by
+ * Session Controller history and control streams. There is no goal.get or
+ * separate wire goal view; responses acknowledge with the new CAS ref, and
+ * committed goal/change events update the projection.
  */
 
 import type { Branded } from '@deepseek-ai/dsh-brand'

+ 6 - 36
packages/host/apiproxy/src/api/index.ts

@@ -1,81 +1,58 @@
 /**
  * apiproxy contract-layer barrel. api/ has zero Node dependencies and is
- * importable from the browser; the TS interfaces are the authoritative contract, while HTTP,
- * WebSocket, and in-process SSE are merely physical channels (four-quadrant message model).
+ * importable from the browser; the TypeScript interfaces are authoritative,
+ * while HTTP supplies the carrier.
  */
 
-import type { SessionsApi } from './sessions.ts'
 import type { HostApi } from './host.ts'
-import type { WorkspaceApi } from './workspace.ts'
 import type { AgentPresetsApi } from './agent-presets.ts'
 import type { SkillsApi } from './skills.ts'
 import type { SubagentsApi } from './subagents.ts'
-import type { EventsApi } from './events.ts'
 import type { GoalsApi } from './goals.ts'
 import type { SettingsApi } from './settings.ts'
 import type { CredentialsApi } from './credentials.ts'
 import type { LlmApi } from './llm.ts'
 import type { DownloadsApi } from './downloads.ts'
-import type { ClientResponse, RpcReceipt } from './rpc.ts'
 
 /** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */
 export interface ApiProxy {
-  sessions: SessionsApi
   subagents: SubagentsApi
   host: HostApi
-  workspace: WorkspaceApi
   skills: SkillsApi
   agentPresets: AgentPresetsApi
-  events: EventsApi
   goals: GoalsApi
   settings: SettingsApi
   credentials: CredentialsApi
   llm: LlmApi
   /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
   downloads: DownloadsApi
-  /**
-   * Response entry for server requests; not a domain method.
-   * @param message - Client response carrying the server request's rpcId.
-   * @returns Transport receipt for the response delivery.
-   */
-  respond(message: ClientResponse): Promise<RpcReceipt>
 }
 
 // ---- Domain interfaces and payload entities ----
 export type {
-  HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
-  ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels,
-  SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
-} from './sessions.ts'
+  ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
+  ModelReasoningEffort, ModelSelection,
+} from '@deepseek-ai/dsh-api-session-controller/types'
 export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
 export type {
   SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
   SubagentPromptReceipt, SubagentsApi,
 } from './subagents.ts'
-export type { JobView } from './jobs.ts'
-export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
 export type { SkillsApi, SkillEntry } from './skills.ts'
 export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts'
-export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
 export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
 export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
 export type { CredentialsApi, CredentialView } from './credentials.ts'
 export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
 export type { DownloadsApi } from './downloads.ts'
-export type { ApprovalResponsePayload } from './approvals.ts'
-
-export type { QuestionResponsePayload } from './questions.ts'
 
 // ---- Message layer: narrow forms (domain-signature view) ----
 export type { RpcRequest, RpcResponse } from './rpc.ts'
 
-// ---- Message layer: the four wire full forms + carrier receipt ----
+// ---- Message layer: unary wire forms ----
 export type {
   ClientRequest,
-  ClientResponse,
   RpcMessage,
-  RpcReceipt,
-  ServerRequest,
   ServerResponse,
 } from './rpc.ts'
 
@@ -84,15 +61,8 @@ export { RpcId, transportError } from './rpc.ts'
 export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
 export {
   clientRequestSchema,
-  serverRequestSchema,
   serverResponseSchema,
 } from './rpc.schema.ts'
 
-// ---- Fixed session-search product bounds ----
-export {
-  SESSION_SEARCH_RESULT_LIMIT,
-  SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
-} from './session-search.ts'
-
 // ---- Method registry and derived generics ----
 export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts'

+ 42 - 1
packages/host/apiproxy/src/api/llm.schema.ts

@@ -7,7 +7,48 @@ import { z } from 'zod'
 import type { RequestPayload, ResponseValue } from './rpc-map.ts'
 import type { Wire } from './rpc.schema.ts'
 import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts'
-import { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts'
+import type {
+  ModelCatalogFailure,
+  ModelCatalogModel,
+  ModelProviderGroup,
+  ModelReasoning,
+  ModelReasoningEffort,
+} from '@deepseek-ai/dsh-api-session-controller/types'
+
+/** One adapter-owned reasoning effort. */
+const modelReasoningEffortSchema = z.object({
+  id: z.string().min(1),
+  name: z.string().min(1),
+  description: z.string().optional(),
+}) satisfies z.ZodType<Wire<ModelReasoningEffort>>
+
+/** Exact-model reasoning metadata. */
+const modelReasoningSchema = z.object({
+  efforts: z.array(modelReasoningEffortSchema).min(1),
+  defaultEffort: z.string().min(1).optional(),
+}) satisfies z.ZodType<Wire<ModelReasoning>>
+
+/** One advisory model entry inside a provider group. */
+const modelCatalogModelSchema = z.object({
+  id: z.string().min(1),
+  name: z.string().min(1),
+  description: z.string().optional(),
+  reasoning: modelReasoningSchema.optional(),
+}) satisfies z.ZodType<Wire<ModelCatalogModel>>
+
+/** One successfully loaded provider group. */
+const modelProviderGroupSchema = z.object({
+  id: z.string().min(1),
+  name: z.string().min(1),
+  models: z.array(modelCatalogModelSchema),
+}) satisfies z.ZodType<Wire<ModelProviderGroup>>
+
+/** One provider-local catalog failure. */
+const modelCatalogFailureSchema = z.object({
+  id: z.string().min(1),
+  name: z.string().min(1),
+  message: z.string(),
+}) satisfies z.ZodType<Wire<ModelCatalogFailure>>
 
 /** ConfigurableProviderView row of llm.providers. */
 export const configurableProviderViewSchema = z.object({

+ 5 - 3
packages/host/apiproxy/src/api/llm.ts

@@ -2,14 +2,16 @@
  * llm domain contract: host-scoped provider topology for configuration
  * surfaces. `llm.providers` merges the configurable-provider directory
  * (which providers CAN be configured, and where their settings live) with the
- * live route registry; `llm.models` is the session-independent model catalog
- * (the same groups as `session.models`, without a per-session selection).
+ * live route registry; `llm.models` is the session-independent model catalog.
  * Clients invalidate from the forwarded `llm/adapters-updated` and
  * `settings/document-updated` owner events.
  */
 
 import type { RpcRequest, RpcResponse } from './rpc.ts'
-import type { ModelCatalogFailure, ModelProviderGroup } from './sessions.ts'
+import type {
+  ModelCatalogFailure,
+  ModelProviderGroup,
+} from '@deepseek-ai/dsh-api-session-controller/types'
 
 /** Wire view of one configurable provider. */
 export interface ConfigurableProviderView {

+ 3 - 26
packages/host/apiproxy/src/api/rpc-map.ts

@@ -1,12 +1,9 @@
 /**
- * RPC method registry and signature-derived generics. The map
- * registers only client-request methods (respond is a client-response, so it is absent);
- * map keys are the wire path segments (POST /api/session.list).
+ * RPC method registry and signature-derived generics. Map keys are the wire
+ * path segments of API Proxy unary calls.
  */
 
-import type { SessionsApi } from './sessions.ts'
 import type { HostApi } from './host.ts'
-import type { WorkspaceApi } from './workspace.ts'
 import type { AgentPresetsApi } from './agent-presets.ts'
 import type { SkillsApi } from './skills.ts'
 import type { GoalsApi } from './goals.ts'
@@ -19,23 +16,10 @@ import type { RpcResponse } from './rpc.ts'
 /**
  * Method name → method signature. Signatures are the single source of truth; payload/value
  * types are always derived from here. A method may declare a trailing AbortSignal after the
- * request (command.execute): the carrier passes its request signal, never a wire field.
+ * request; the carrier passes its request signal, never a wire field.
  */
 export interface RpcMethodMap {
-  'session.list': SessionsApi['list']
-  'session.search': SessionsApi['search']
-  'session.create': SessionsApi['create']
-  'session.history': SessionsApi['history']
-  'session.models': SessionsApi['models']
-  'session.selectModel': SessionsApi['selectModel']
-  'session.rename': SessionsApi['rename']
-  'session.fork': SessionsApi['fork']
-  'session.prompt': SessionsApi['prompt']
-  'session.attachment': SessionsApi['attachment']
-  'session.updateQueue': SessionsApi['updateQueue']
-  'session.cancel': SessionsApi['cancel']
   'subagent.list': SubagentsApi['list']
-  'subagent.history': SubagentsApi['history']
   'subagent.prompt': SubagentsApi['prompt']
   'subagent.interrupt': SubagentsApi['interrupt']
   'host.describe': HostApi['describe']
@@ -43,13 +27,6 @@ export interface RpcMethodMap {
   'host.listDirectory': HostApi['listDirectory']
   'host.createDirectory': HostApi['createDirectory']
   'host.openPath': HostApi['openPath']
-  'workspace.list': WorkspaceApi['list']
-  'workspace.create': WorkspaceApi['create']
-  'workspace.rename': WorkspaceApi['rename']
-  'workspace.delete': WorkspaceApi['delete']
-  'workspace.insertBefore': WorkspaceApi['insertBefore']
-  'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
-  'workspace.archiveSession': WorkspaceApi['archiveSession']
   'skill.list': SkillsApi['list']
   'agentPreset.list': AgentPresetsApi['list']
   'agentPreset.select': AgentPresetsApi['select']

+ 6 - 44
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -1,14 +1,14 @@
 /**
- * Message-layer zod schemas: the four wire full forms + error body +
- * carrier receipt. The payload slot is unknown in the full-form schemas — business payloads
- * get a second parse dispatched by method (two-level parse discipline).
- * Brand cast point: rpcIdSchema, and only there.
+ * Message-layer zod schemas for API Proxy unary calls and Host pushes. The
+ * payload slot is unknown in the full-form schemas — business payloads get a
+ * second parse dispatched by method (two-level parse discipline). Brand cast
+ * point: rpcIdSchema, and only there.
  */
 
 import { z } from 'zod'
 import type { z as zCore } from 'zod'
 type ZodIssue = zCore.core.$ZodIssue
-import type { ClientRequest, ClientResponse, RpcError, RpcId, RpcReceipt, ServerRequest, ServerResponse } from './rpc.ts'
+import type { ClientRequest, RpcError, RpcId, ServerResponse } from './rpc.ts'
 
 /**
  * Wire widening of a contract type: widens every property (deeply) to `original | undefined`.
@@ -35,35 +35,20 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
   z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
   z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
   z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
-  z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }),
-  z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }),
   z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }),
-  z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }),
-  z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
-  z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
-  z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
-  z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
   z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }),
   z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }),
   z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
   z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
   z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
   z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }),
-  z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }),
   z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
   z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
   z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
-  z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }),
-  z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
-  z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
-  z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
-  z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
   z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
   z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
   z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
   z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),
-  z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
-  z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
   z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
   z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }),
   z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({
@@ -89,7 +74,7 @@ export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodUnion<readonly [z.
   ])
 }
 
-// ---- The four wire full-form schemas (payload/result.value slots stay wide — business layer does the second parse) ----
+// ---- Wire envelope schemas (payload/result.value stay wide for the second business parse) ----
 // The wide value slot is optional: a void business result serializes with no
 // `value` field at all. Each endpoint's own second parse still requires its
 // declared value, so absence never passes for a method that returns data.
@@ -109,31 +94,8 @@ export const serverResponseSchema = z.object({
   result: rpcResultSchema(z.unknown().optional()),
 }) as unknown as z.ZodType<ServerResponse>
 
-/** ServerRequest full form (payload stays wide). */
-export const serverRequestSchema = z.object({
-  type: z.literal('server-request'),
-  rpcId: rpcIdSchema,
-  method: z.string(),
-  payload: z.unknown(),
-}) as unknown as z.ZodType<ServerRequest>
-
-/** ClientResponse full form (result.value stays wide). */
-export const clientResponseSchema = z.object({
-  type: z.literal('client-response'),
-  rpcId: rpcIdSchema,
-  result: rpcResultSchema(z.unknown().optional()),
-}) as unknown as z.ZodType<ClientResponse>
-
 /** Wire full-form union (discriminated by type). */
 export const rpcMessageSchema = z.discriminatedUnion('type', [
   clientRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
   serverResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
-  serverRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
-  clientResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
 ])
-
-/** Carrier receipt schema. */
-export const rpcReceiptSchema = z.union([
-  z.object({ accepted: z.literal(true) }),
-  z.object({ accepted: z.literal(false), reason: z.union([z.literal('not-pending'), z.literal('bad-response')]) }),
-]) satisfies z.ZodType<Wire<RpcReceipt>>

+ 7 - 54
packages/host/apiproxy/src/api/rpc.ts

@@ -1,14 +1,12 @@
 /**
- * Four-quadrant RPC message model. Channels and messages are decoupled: HTTP,
- * WebSocket, and in-process SSE are physical carriers, while logical messages
- * are channel-independent and form a four-member discriminated union.
+ * API Proxy request and response message model. Logical messages remain
+ * independent of their physical carrier.
  * api/ contract layer: zero Node dependencies, importable from the browser.
  */
 
 import type { z as zCore } from 'zod'
 type ZodIssue = zCore.core.$ZodIssue
 import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
 /**
@@ -18,9 +16,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 export type RpcId = Branded<'rpc-id'>
 
 /**
- * Brands a string as RpcId (same precedent as core `SessionId()`). Minted by the initiator:
- * client-request → client mints; server-request → host mints (answerable frames get a stable
- * logical id, pure pushes mint a fresh one each time).
+ * Brands a string as RpcId (same precedent as core `SessionId()`). The Client
+ * mints each request id and the Host echoes it in the response.
  * @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures).
  * @returns The same string, branded (compile-time cast, zero runtime cost).
  */
@@ -33,31 +30,16 @@ export interface RpcErrorDetailsMap {
   'bad-request': { issues: ZodIssue[] }
   'cancelled': {}
   'session-not-found': { sessionId: SessionId }
-  'model-unavailable': { provider: string; model: string }
-  'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
   'invalid-time-zone': { value: string }
-  'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
-  'workspace-not-found': { workspaceId: string }
-  'workspace-invalid-path': { path: string }
-  'workspace-name-conflict': { name: string }
-  'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
   'directory-unreadable': { path: string }
   'directory-exists': { path: string }
   'directory-create-failed': { path: string }
   'directory-picker-unavailable': { capability: string }
   'agent-preset-read-only': { agentPreset: string; reason: string }
   'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
-  'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string }
-  'agent-preset-not-found': { agentPreset: string; available: string[] }
+  'agent-preset-not-found': { agentPreset: string; available: readonly string[] }
   'agent-preset-invalid': { agentPreset: string; reason: string }
   'agent-busy': { reason: string }
-  'attachment-error': { reason: string }
-  'queue-item-not-found': { itemId: MessageId }
-  'steer-unavailable': { itemId: MessageId }
-  /** A known slash command reported a usage/state error; the message is the command's own text. */
-  'command-error': {}
-  /** A leading-/ prompt named no registered command; the message names the token. */
-  'unknown-command': {}
   /**
    * A settings write was refused (schema validation, unknown namespace,
    * read-only provider, or storage failure); the message is the seam's text.
@@ -80,8 +62,6 @@ export interface RpcErrorDetailsMap {
    * details name the endpoint asked, never the credential offered.
    */
   'model-discovery-failed': { settingsNs: string; baseURL?: string }
-  'title-invalid': { sessionId: SessionId }
-  'fork-unavailable': { sessionId: SessionId }
   'subagent-parent-unavailable': { parentSessionId: SessionId }
   'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId }
   'subagent-catalog-diagnostic': {
@@ -139,7 +119,7 @@ export interface RpcResponse<T> {
   result: RpcResult<T>
 }
 
-// ---- Wire full forms: four named members of a discriminated union (discriminant = the four `type` literals) ----
+// ---- Wire full forms ----
 
 /** Call initiated by the client (wire carrier: POST /api/<method> body). */
 export interface ClientRequest {
@@ -156,32 +136,5 @@ export interface ServerResponse {
   result: RpcResult<unknown>
 }
 
-/**
- * Message initiated by the server (wire carrier: downstream stream frame). Answerable interactions
- * (approval/question requested — stable rpcId, reused on replay) and pure pushes
- * (session/event etc. — rpcId identifies that one push) share this shape; whether a
- * response is expected is determined statically by method (a strict dichotomy, no third kind).
- */
-export interface ServerRequest {
-  type: 'server-request'
-  rpcId: RpcId
-  method: string
-  payload: unknown
-}
-
-/** Response to a ServerRequest (wire carrier: POST /api/respond body); rpcId echoed, never minted anew. */
-export interface ClientResponse {
-  type: 'client-response'
-  rpcId: RpcId
-  result: RpcResult<unknown>
-}
-
 /** Authoritative wire full-form union; narrow via `switch (message.type)`. */
-export type RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse
-
-/**
- * Carrier receipt (not an RpcMessage — it belongs to the carrier layer, same
- * discipline as "HTTP status describes only the carrier"): the HTTP response
- * body of the POST carrying a client-response. Late/duplicate responses yield not-pending.
- */
-export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }
+export type RpcMessage = ClientRequest | ServerResponse

+ 1 - 1
packages/host/apiproxy/src/api/skills.schema.ts

@@ -6,7 +6,7 @@
 import { z } from 'zod'
 import type { RequestPayload, ResponseValue } from './rpc-map.ts'
 import type { Wire } from './rpc.schema.ts'
-import { sessionIdSchema } from './sessions.schema.ts'
+import { sessionIdSchema } from './ids.schema.ts'
 import type { SkillEntry } from './skills.ts'
 
 /** SkillEntry row of skill.list. */

+ 4 - 4
packages/host/apiproxy/src/api/skills.ts

@@ -22,10 +22,10 @@ export interface SkillEntry {
 
 /**
  * Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing
- * is the domain's only RPC: invocation itself is a plain `session.prompt`
- * whose leading `/name` token the host recognizes at the pre-step boundary
- * (`dsh-tool-skill` injects the rendered body there), so every client shares
- * one deterministic path with no dedicated invocation wire.
+ * is the domain's only RPC: invocation uses Session Controller's ordinary
+ * prompt Remote. The host recognizes its leading `/name` token at the pre-step
+ * boundary (`dsh-tool-skill` injects the rendered body there), so every client
+ * shares one deterministic path with no dedicated invocation method.
  */
 export interface SkillsApi {
   /** Lists the user-invocable skill catalog for the session's project. */

+ 3 - 19
packages/host/apiproxy/src/api/subagents.schema.ts

@@ -4,11 +4,11 @@ import { z } from 'zod'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { RequestPayload, ResponseValue } from './rpc-map.ts'
 import type { Wire } from './rpc.schema.ts'
-import {
-  contentBlockSchema, historyEntrySchema, sessionIdSchema, sessionProjectionsBlockSchema,
-} from './sessions.schema.ts'
+import { sessionIdSchema } from './ids.schema.ts'
 import type { SubagentListEntry } from './subagents.ts'
 
+const contentBlockSchema = z.looseObject({ type: z.string() })
+
 /** Healthy and diagnostic durable catalog rows. */
 export const subagentListEntrySchema = z.union([
   z.object({
@@ -45,22 +45,6 @@ export const subagentListValueSchema = z.object({
   parentAvailable: z.boolean(),
 }) satisfies z.ZodType<Wire<ResponseValue<'subagent.list'>>>
 
-/** subagent.history request payload. */
-export const subagentHistoryRequestSchema = z.object({
-  parentSessionId: sessionIdSchema,
-  childSessionId: sessionIdSchema,
-  mode: z.union([z.literal('one-shot'), z.literal('continuable')]),
-  beforeSeq: z.number().int().nonnegative().optional(),
-  maxMessages: z.number().int().positive().optional(),
-}) satisfies z.ZodType<Wire<RequestPayload<'subagent.history'>>>
-
-/** subagent.history response value. */
-export const subagentHistoryValueSchema = z.object({
-  events: z.array(historyEntrySchema),
-  hasMore: z.boolean(),
-  projections: sessionProjectionsBlockSchema.optional(),
-}) as unknown as z.ZodType<Wire<ResponseValue<'subagent.history'>>>
-
 /** subagent.prompt request payload. */
 export const subagentPromptRequestSchema = z.object({
   parentSessionId: sessionIdSchema,

+ 1 - 20
packages/host/apiproxy/src/api/subagents.ts

@@ -1,14 +1,9 @@
-/**
- * Browser-safe subagent domain contract. Persisted transcript reads never
- * activate an Agent, while continuable prompts route through the exact live
- * direct parent into the child's Agent inbox.
- */
+/** Browser-safe subagent catalog, continuation, and interrupt contract. */
 
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { RpcRequest, RpcResponse } from './rpc.ts'
-import type { HistoryEntry, SessionProjectionsBlock } from './sessions.ts'
 
 /** Complete durable direct-child catalog row. */
 export type SubagentListEntry =
@@ -74,20 +69,6 @@ export interface SubagentsApi {
     signal?: AbortSignal,
   ): Promise<RpcResponse<SubagentCatalog>>
 
-  /**
-   * Reads one healthy catalog child's transcript — the in-memory snapshot of
-   * a live child, the persisted log of a cold one — with ordinary
-   * message-aligned pagination and render intents, without Agent activation.
-   */
-  history(
-    request: RpcRequest<SubagentAddress & { beforeSeq?: number; maxMessages?: number }>,
-    signal?: AbortSignal,
-  ): Promise<RpcResponse<{
-    events: HistoryEntry[]
-    hasMore: boolean
-    projections?: SessionProjectionsBlock
-  }>>
-
   /**
    * Delivers human content to a continuable child through the exact live
    * parent's continuation owner. Success identifies the message accepted by

+ 8 - 182
packages/host/apiproxy/src/fetch/client.ts

@@ -1,46 +1,21 @@
 /**
- * Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting,
- * four-quadrant envelope wrap/unwrap, zod parsing, in-process SSE frame decoding, and the payload-direct
+ * Client side of the fetch carrier. AbstractApiClient holds request correlation,
+ * envelope wrap/unwrap, zod parsing, and the payload-direct
  * IApiClient domain methods (business code never mints). Platform differences ride two aspects:
  * abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched.
  */
 
 import type { z } from 'zod'
 import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
-import type { ApiProxy, HostFrame, MuxFrame } from '../api/index.ts'
 import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
-import type { ClientRequest, ClientResponse, RpcMessage, RpcReceipt, RpcRequest, RpcResponse, ServerRequest } from '../api/rpc.ts'
+import type { ClientRequest, RpcMessage, RpcResponse } from '../api/rpc.ts'
 import { RpcId } from '../api/rpc.ts'
 import type { Wire } from '../api/rpc.schema.ts'
-import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
-import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
+import { serverResponseSchema } from '../api/rpc.schema.ts'
 import {
   hostCreateDirectoryValueSchema, hostDescribeValueSchema,
   hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
 } from '../api/host.schema.ts'
-import {
-  sessionCancelValueSchema,
-  sessionAttachmentValueSchema,
-  sessionCreateValueSchema,
-  sessionForkValueSchema,
-  sessionHistoryValueSchema,
-  sessionListValueSchema,
-  sessionModelsValueSchema,
-  sessionPromptValueSchema,
-  sessionRenameValueSchema,
-  sessionSearchValueSchema,
-  sessionSelectModelValueSchema,
-  sessionUpdateQueueValueSchema,
-} from '../api/sessions.schema.ts'
-import {
-  workspaceArchiveSessionValueSchema,
-  workspaceCreateValueSchema,
-  workspaceDeleteValueSchema,
-  workspaceInsertBeforeValueSchema,
-  workspaceInsertSessionBeforeValueSchema,
-  workspaceListValueSchema,
-  workspaceRenameValueSchema,
-} from '../api/workspace.schema.ts'
 import { skillListValueSchema } from '../api/skills.schema.ts'
 import {
   agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
@@ -63,7 +38,6 @@ import {
 } from '../api/credentials.schema.ts'
 import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
 import {
-  subagentHistoryValueSchema,
   subagentInterruptValueSchema,
   subagentListValueSchema,
   subagentPromptValueSchema,
@@ -73,36 +47,17 @@ import {
  * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
  * methods take the business payload directly — the carrier mints the rpcId and wraps the
  * envelope. Business code needing the call's rpcId reads it from the RpcResponse echo.
- * Unary methods and respond accept an optional external AbortSignal as the last parameter.
+ * Unary methods accept an optional external AbortSignal as the last parameter.
  * Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls
  * carry only that external signal. In both cases the signal rides beside the request, never
  * on the wire, like the stream signatures.
- * Stream methods accept an optional onOpen callback: it fires once the physical transport is
- * readable (before any frame) — the "stream established" signal
- * connection controllers need for the readiness handshake. Generators are lazy, so the
- * underlying fetch (and therefore onOpen) only happens once iteration starts.
  * Relationship: ApiProxy is the narrow-form signature contract the impl side implements;
  * IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two.
  * Derived per method key from RpcMethodMap so a map row addition updates this mechanically.
  */
 export interface IApiClient {
-  sessions: {
-    list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
-    search(payload: RequestPayload<'session.search'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.search'>>>
-    create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
-    history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
-    models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
-    selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
-    rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
-    fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.fork'>>>
-    prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
-    attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.attachment'>>>
-    updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
-    cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
-  }
   subagents: {
     list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
-    history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.history'>>>
     prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
     interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
   }
@@ -113,15 +68,6 @@ export interface IApiClient {
     createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.createDirectory'>>>
     openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
   }
-  workspace: {
-    list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
-    create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
-    rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
-    delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
-    insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertBefore'>>>
-    insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
-    archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.archiveSession'>>>
-  }
   skills: {
     list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
   }
@@ -133,10 +79,6 @@ export interface IApiClient {
     openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
     remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
   }
-  events: {
-    mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
-    host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
-  }
   goals: {
     create(payload: RequestPayload<'goal.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.create'>>>
     edit(payload: RequestPayload<'goal.edit'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'goal.edit'>>>
@@ -162,8 +104,6 @@ export interface IApiClient {
     models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.models'>>>
     discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.discoverModels'>>>
   }
-  /** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
-  respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
 }
 
 /**
@@ -171,20 +111,7 @@ export interface IApiClient {
  * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap).
  */
 const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
-  'session.list': sessionListValueSchema,
-  'session.search': sessionSearchValueSchema,
-  'session.create': sessionCreateValueSchema,
-  'session.history': sessionHistoryValueSchema,
-  'session.models': sessionModelsValueSchema,
-  'session.selectModel': sessionSelectModelValueSchema,
-  'session.rename': sessionRenameValueSchema,
-  'session.fork': sessionForkValueSchema,
-  'session.prompt': sessionPromptValueSchema,
-  'session.attachment': sessionAttachmentValueSchema,
-  'session.updateQueue': sessionUpdateQueueValueSchema,
-  'session.cancel': sessionCancelValueSchema,
   'subagent.list': subagentListValueSchema,
-  'subagent.history': subagentHistoryValueSchema,
   'subagent.prompt': subagentPromptValueSchema,
   'subagent.interrupt': subagentInterruptValueSchema,
   'host.describe': hostDescribeValueSchema,
@@ -192,13 +119,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
   'host.listDirectory': hostListDirectoryValueSchema,
   'host.createDirectory': hostCreateDirectoryValueSchema,
   'host.openPath': hostOpenPathValueSchema,
-  'workspace.list': workspaceListValueSchema,
-  'workspace.create': workspaceCreateValueSchema,
-  'workspace.rename': workspaceRenameValueSchema,
-  'workspace.delete': workspaceDeleteValueSchema,
-  'workspace.insertBefore': workspaceInsertBeforeValueSchema,
-  'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
-  'workspace.archiveSession': workspaceArchiveSessionValueSchema,
   'skill.list': skillListValueSchema,
   'agentPreset.list': agentPresetListValueSchema,
   'agentPreset.select': agentPresetSelectValueSchema,
@@ -248,7 +168,7 @@ export abstract class AbstractApiClient implements IApiClient {
   private flushScheduled = false
   private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>()
 
-  /** @param timeoutMs - timeout for bounded unary calls; user-paced calls and streams do not use it. */
+  /** @param timeoutMs - timeout for bounded unary calls; user-paced calls do not use it. */
   constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
 
   /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */
@@ -303,12 +223,12 @@ export abstract class AbstractApiClient implements IApiClient {
   }
 
   /**
-   * Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
+   * Shared POST leg of unary calls: JSON body,
    * optional default timeout merged with the caller's external signal, non-2xx → transport throw.
    */
   private async postJson(
     path: string,
-    body: ClientRequest | ClientResponse,
+    body: ClientRequest,
     signal: AbortSignal | undefined,
     timeoutPolicy: UnaryTimeoutPolicy = 'default',
   ): Promise<Response> {
@@ -351,84 +271,10 @@ export abstract class AbstractApiClient implements IApiClient {
     return { rpcId: full.rpcId, result: { ok: true, value } }
   }
 
-  /** Mux stream opener; virtual for the same override reason as callUnary. */
-  protected openMux(_payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>> {
-    return this.readSse('/api/events.mux', signal, muxFrameSchema, onOpen)
-  }
-
-  /** Host stream opener; virtual. */
-  protected openHost(_payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>> {
-    return this.readSse('/api/events.host', signal, hostFrameSchema, onOpen)
-  }
-
-  /**
-   * SSE protocol path: streaming fetch (not EventSource), '\n\n' framing, ServerRequest envelope +
-   * frame-schema parse, tap, narrow yield. onOpen fires once the response headers are in and the
-   * body is readable — the stream-established signal, before any frame arrives. A frame that fails
-   * either parse level is reported and skipped (one corrupt frame must not kill the stream; the
-   * client's gap detection covers whatever the frame carried).
-   */
-  protected async *readSse<F extends MuxFrame | HostFrame>(
-    path: string,
-    signal: AbortSignal,
-    frameSchema: z.ZodType<F>,
-    onOpen?: () => void,
-  ): AsyncGenerator<RpcRequest<F>> {
-    const response = await this.doFetch(new URL(path, this.resolveBase()), { signal })
-    if (!response.ok || response.body === null) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
-    onOpen?.()
-    const reader = response.body.getReader()
-    const decoder = new TextDecoder()
-    let buffer = ''
-    try {
-      while (true) {
-        const { done, value } = await reader.read()
-        if (done) return
-        buffer += decoder.decode(value, { stream: true })
-        let boundary: number
-        while ((boundary = buffer.indexOf('\n\n')) !== -1) {
-          const chunk = buffer.slice(0, boundary)
-          buffer = buffer.slice(boundary + 2)
-          const data = chunk.split('\n').filter(line => line.startsWith('data: ')).map(line => line.slice(6)).join('')
-          if (data === '') continue
-          let full: ServerRequest
-          let frame: F
-          try {
-            full = serverRequestSchema.parse(JSON.parse(data))
-            frame = frameSchema.parse(full.payload)
-          } catch (error) {
-            console.error(`[apiproxy] dropping malformed SSE frame on ${path}:`, error)
-            continue
-          }
-          this.onEnvelope(full)
-          yield { rpcId: full.rpcId, payload: frame }
-        }
-      }
-    } finally {
-      await reader.cancel().catch(() => undefined)
-    }
-  }
-
   // ---- IApiClient API (arrow properties so destructured/passed references stay bound) ----
 
-  readonly sessions: IApiClient['sessions'] = {
-    list: (payload, signal) => this.callUnary('session.list', payload, signal),
-    search: (payload, signal) => this.callUnary('session.search', payload, signal),
-    create: (payload, signal) => this.callUnary('session.create', payload, signal),
-    history: (payload, signal) => this.callUnary('session.history', payload, signal),
-    models: (payload, signal) => this.callUnary('session.models', payload, signal),
-    selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
-    rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
-    fork: (payload, signal) => this.callUnary('session.fork', payload, signal),
-    prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
-    attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal),
-    updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
-    cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
-  }
-
   readonly subagents: IApiClient['subagents'] = {
     list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
-    history: (payload, signal) => this.callUnary('subagent.history', payload, signal),
     prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
     interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal),
   }
@@ -445,16 +291,6 @@ export abstract class AbstractApiClient implements IApiClient {
     openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
   }
 
-  readonly workspace: IApiClient['workspace'] = {
-    list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
-    create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
-    rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
-    delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
-    insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal),
-    insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
-    archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal),
-  }
-
   readonly skills: IApiClient['skills'] = {
     list: (payload, signal) => this.callUnary('skill.list', payload, signal),
   }
@@ -502,16 +338,6 @@ export abstract class AbstractApiClient implements IApiClient {
     discoverModels: (payload, signal) => this.callUnary('llm.discoverModels', payload, signal),
   }
 
-  readonly events: IApiClient['events'] = {
-    mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
-    host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),
-  }
-
-  async respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt> {
-    this.onEnvelope(message)
-    const response = await this.postJson('/api/respond', message, signal)
-    return rpcReceiptSchema.parse(await response.json())
-  }
 }
 
 /**

+ 5 - 106
packages/host/apiproxy/src/fetch/handler.ts

@@ -6,43 +6,19 @@
  * business errors are always 200 + ServerResponse.
  */
 
-import { randomUUID } from 'node:crypto'
 import type { z } from 'zod'
-import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts'
+import type { ApiProxy } from '../api/index.ts'
 import { sessionLogQuerySchema } from '../api/downloads.schema.ts'
 import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
-import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts'
+import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerResponse } from '../api/rpc.ts'
 import { RpcId } from '../api/rpc.ts'
 import type { Wire } from '../api/rpc.schema.ts'
-import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts'
-import {
-  sessionCancelRequestSchema,
-  sessionAttachmentRequestSchema,
-  sessionCreateRequestSchema,
-  sessionForkRequestSchema,
-  sessionHistoryRequestSchema,
-  sessionListRequestSchema,
-  sessionModelsRequestSchema,
-  sessionPromptRequestSchema,
-  sessionRenameRequestSchema,
-  sessionSearchRequestSchema,
-  sessionSelectModelRequestSchema,
-  sessionUpdateQueueRequestSchema,
-} from '../api/sessions.schema.ts'
+import { clientRequestSchema } from '../api/rpc.schema.ts'
 import {
   hostCreateDirectoryRequestSchema, hostDescribeRequestSchema,
   hostListDirectoryRequestSchema, hostOpenPathRequestSchema,
   hostPickDirectoryRequestSchema,
 } from '../api/host.schema.ts'
-import {
-  workspaceArchiveSessionRequestSchema,
-  workspaceCreateRequestSchema,
-  workspaceDeleteRequestSchema,
-  workspaceInsertBeforeRequestSchema,
-  workspaceInsertSessionBeforeRequestSchema,
-  workspaceListRequestSchema,
-  workspaceRenameRequestSchema,
-} from '../api/workspace.schema.ts'
 import { skillListRequestSchema } from '../api/skills.schema.ts'
 import {
   agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema,
@@ -65,7 +41,6 @@ import {
 } from '../api/credentials.schema.ts'
 import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
 import {
-  subagentHistoryRequestSchema,
   subagentInterruptRequestSchema,
   subagentListRequestSchema,
   subagentPromptRequestSchema,
@@ -88,20 +63,7 @@ type UnaryRoutes = {
 }
 
 const UNARY_ROUTES: UnaryRoutes = {
-  'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
-  'session.search': { schema: sessionSearchRequestSchema, invoke: (api, r, signal) => api.sessions.search(r, signal) },
-  'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
-  'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
-  'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
-  'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
-  'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
-  'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) },
-  'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
-  'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) },
-  'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) },
-  'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
   'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
-  'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) },
   'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
   'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) },
   'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
@@ -109,13 +71,6 @@ const UNARY_ROUTES: UnaryRoutes = {
   'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },
   'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) },
   'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
-  'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
-  'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
-  'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
-  'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
-  'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(r) },
-  'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
-  'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) },
   'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
   'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
   'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) },
@@ -191,50 +146,6 @@ async function handleUnary<K extends keyof RpcMethodMap>(
   }
 }
 
-/** SSE frame: complete the narrow RpcRequest<frame> into a ServerRequest full form (method = frame type). */
-function fullFrame(narrow: RpcRequest<MuxFrame | HostFrame>): ServerRequest {
-  return { type: 'server-request', rpcId: narrow.rpcId, method: narrow.payload.type, payload: narrow.payload }
-}
-
-/**
- * Wrap a frame stream as an SSE Response; stops when req.signal aborts. An
- * impl throw mid-stream emits one stream/error frame and then closes.
- */
-function sseResponse(frames: AsyncIterable<RpcRequest<MuxFrame | HostFrame>>): Response {
-  const encoder = new TextEncoder()
-  const stream = new ReadableStream<Uint8Array>({
-    async start(controller) {
-      try {
-        // Send an SSE comment line on open so clients/proxies see a live channel (the host
-        // stream has no baseline frames and would otherwise emit zero bytes while idle;
-        // a comment line is not a frame, so client frame parsing skips it naturally).
-        controller.enqueue(encoder.encode(': connected\n\n'))
-        for await (const narrow of frames) {
-          controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame(narrow))}\n\n`))
-        }
-      } catch (error: unknown) {
-        // Mid-stream impl failure → one stream/error frame, then close: the client must see
-        // the failure instead of a silent end (which reads as a normal disconnect). A fresh
-        // rpcId is minted — this is a server-initiated push like any other frame.
-        const failure: MuxFrame | HostFrame = { type: 'stream/error', error: { code: 'internal', message: String(error), details: {} } }
-        try {
-          controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame({ rpcId: RpcId(randomUUID()), payload: failure }))}\n\n`))
-        } catch {
-          // Consumer already cancelled the stream: enqueue-after-cancel is the
-          // only reachable error, and there is no one left to tell.
-        }
-      } finally {
-        try {
-          controller.close()
-        } catch { /* already cancelled by the consumer: a double close is the only reachable error */ }
-      }
-    },
-  })
-  return new Response(stream, {
-    headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
-  })
-}
-
 /**
  * Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient).
  * @param api - the host-side ApiProxy implementation.
@@ -249,14 +160,8 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
       const url = new URL(req.url)
       const path = url.pathname
 
-      // No-envelope read channels (SSE GET streams + host-only download):
+      // No-envelope Host-only download channel:
       // physical routes that answer directly, without a wire envelope.
-      if (path === '/api/events.mux' && req.method === 'GET') {
-        return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
-      }
-      if (path === '/api/events.host' && req.method === 'GET') {
-        return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
-      }
       if (path === '/api/session.export' && (req.method === 'GET' || req.method === 'HEAD')) {
         // Query params are a different boundary from the POST envelope, but
         // the request still casts its brands only through the domain schema.
@@ -277,7 +182,7 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
       // Cross-site write fence: browsers send "simple" POSTs (text/plain,
       // form encodings) without a CORS preflight, so a malicious page could
       // otherwise execute side-effectful RPCs blind — the response stays
-      // unreadable cross-origin, but session.prompt would still run. Only the
+      // unreadable cross-origin, but the requested mutation would still run. Only the
       // JSON media type is accepted; anything else is forced into a preflight
       // this server never answers. 415 = carrier layer, like the 400 below.
       const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
@@ -293,12 +198,6 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
         return new Response('body is not JSON', { status: 400 })
       }
 
-      if (path === '/api/respond') {
-        const parsed = clientResponseSchema.safeParse(body)
-        if (!parsed.success) return Response.json({ accepted: false, reason: 'bad-response' })
-        return Response.json(await api.respond(parsed.data))
-      }
-
       const method = methodFor(path.slice('/api/'.length))
       if (method === undefined) return new Response('not found', { status: 404 })
 

Неке датотеке нису приказане због велике количине промена