# DSH 0.1.5-rc.2 HTTP/WebSocket wire 细节（对照 0.1.1-rc.2）

> 这份笔记是**对宿主源码的核对记录**，不是本插件的使用文档：它把两代 dsh API 的
> HTTP/WebSocket 形状逐条摘出来，给 src/dsh-wire.js、src/mux-filter.js 的常量一个可回溯的依据。
> 所有「文件:行」都指 dsh 仓库里 tag 的路径与行号（可在 `git show dsh-v0.1.5-rc.2:<path>` 复现）。
>
> 重要结构变化：0.1.2-rc.1 起 dsh 删除了 packages/host/apiproxy，拆成 packages/api/gateway（传输/网关）
> + packages/api/{session,workspace,workspace-files,settings}-controller（业务 namespace）；
> 一元 URL 也从 `/api/<ns>.<method>` 变成 `/api/<ns>/<method>`，流式 RPC 改走 `/api/remote.mux`。
> 本插件 1.5.7 起同时认这两代形状（见 README「dsh 版本适配」一节）。

---

## 1. 一元 RPC：浏览器如何发一个 unary 调用

### 结论
1. URL 形式是 `POST /api/<namespace>/<method>`，**namespace 与 method 用斜杠分隔，不是点号、也不是 /api/<method> 的扁平形式**。例如 `POST /api/session/page`、`POST /api/workspace/follow`、`POST /api/workspaceFiles/read`。共享 channel 常量就是 `/api`。
2. 请求体信封：`{ "type":"client-request", "rpcId":"<uuid>", "method":"<namespace>/<method>", "payload": { "args": { ...业务参数 } } }`。`method` 必须与 URL 的 endpoint 完全一致；`payload` 必须**恰好只有一个** plain-object 的 `args` 字段。
3. 响应体信封：`{ "type":"server-response", "rpcId":"<同一个 rpcId>", "result": ... }`。
   - 成功：`result = { "ok": true, "value": <业务返回值> }`（`value` 为 undefined 时 JSON 中该键**缺失**）
   - 失败：`result = { "ok": false, "error": { "code": string, "message": string, "details": object } }`
4. HTTP 状态码只表达载体层：401/403（trust/auth 前置检查）、404（未认领 endpoint 或非 POST）、415（content-type 不是 application/json）、400（body 不是 JSON）、500（handler 自身抛错）。业务失败一律 HTTP 200 + `ok:false`。
5. 带点的 endpoint（如 `/api/workspace.follow`）在 0.1.5 **不会被网关认领**（网关要求恰好 2 段），会 404。段内字符允许 `[A-Za-z0-9_$.-]`。

### 证据

**channel 常量** — packages/client/connection/src/api-path.ts:1-7
```ts
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
export const API_PATH = '/api'
```

**请求信封类型** — packages/client/connection/src/rpc.ts:61-73
```ts
export interface ClientRequest {
  readonly type: 'client-request'
  readonly rpcId: RpcId
  readonly method: string
  readonly payload: unknown
}
export interface ServerResponse {
  readonly type: 'server-response'
  readonly rpcId: RpcId
  readonly result: ConnectionRpcResult<unknown>
}
```
结果联合 — packages/client/connection/src/rpc.ts:25-28
```ts
export type ConnectionRpcResult<T> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: ConnectionRpcFailure }
```
（`ConnectionRpcFailure` = `{ code: string; message: string; details: object }`，rpc.ts:15-19）

**浏览器发送端** — packages/client/connection/src/client/rpc.ts:34-58
```ts
async call(channel, endpoint, payload, signal) {
  assertTarget(channel, endpoint)
  const rpcId = RpcId(randomUuid())
  const message: ClientRequest = {
    type: 'client-request', rpcId, method: endpoint, payload,
  }
  const response = await send(new URL(`${channel}/${endpoint}`, resolveBase()), {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(message), ...
  })
  if (!response.ok) throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
  const full = parseConnectionResponse(await response.json())
  if (full.rpcId !== rpcId) throw new Error(`rpcId mismatch ...`)
  return full.result
}
```
（channel 段校验：CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/，ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/，rpc.ts(client):11-12）

**客户端实际调用点（业务参数如何变成 payload）** — packages/api/gateway/src/client/index.ts:443
```ts
const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal)
```
endpoint 拼接 — packages/api/gateway/src/client/index.ts:663-665（同一实现在 gateway/src/index.ts:1012-1014）
```ts
function endpointOf(descriptor: Pick<InvocationDescriptor, 'namespace' | 'method'>): string {
  return `${descriptor.namespace}/${descriptor.method}`
}
```

**服务端解析 payload** — packages/api/gateway/src/index.ts:936-951
```ts
function remoteRequest(endpoint: string, payload: unknown, signal: AbortSignal): InvokeRemoteRequest {
  const segments = endpoint.split('/')
  if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
    throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`)
  }
  const [namespace, method] = segments as [string, string]
  if (!isObject(payload) || !isPlainObject(payload)
    || Reflect.ownKeys(payload).length !== 1
    || !Object.hasOwn(payload, 'args')
    || !isObject(payload.args) || !isPlainObject(payload.args)) {
    throw new Error('Remote payload must contain exactly one plain-object args field')
  }
  return { namespace, method, args: payload.args, signal }
}
```
endpoint 认领规则（必须 2 段） — packages/api/gateway/src/index.ts:266-273
```ts
private claimsEndpoint(endpoint: string): boolean {
  if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true
  const segments = endpoint.split('/')
  if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false
  ...
}
```

**服务端 HTTP 处理（状态码语义）** — packages/client/connection/src/rpc-host.ts:209-253
```ts
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
if (request.method !== 'POST' || endpoint === undefined) return new Response('not found', { status: 404 })
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (mediaType !== 'application/json') return new Response('content type must be application/json', { status: 415 })
try { body = await request.json() } catch { return new Response('body is not JSON', { status: 400 }) }
const envelope = clientRequestSchema.safeParse(body)
if (!envelope.success) return invalidEnvelopeResponse(body, envelope.error.issues)   // 200 + ok:false
if (message.method !== endpoint) {
  return errorResponse(message.rpcId, {
    code: 'gateway/bad-request',
    message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
    details: { issues: [] },
  })
}
try { return fullResponse(message.rpcId, await handler(endpoint, message.payload, request.signal)) }
catch (error) { return new Response(`handler failure: ${String(error)}`, { status: 500 }) }
```
响应构造 — rpc-host.ts:281-284
```ts
function fullResponse(rpcId: RpcIdType, result: ConnectionRpcResult<unknown>): Response {
  const body: ConnectionServerResponse = { type: 'server-response', rpcId, result }
  return Response.json(body)
}
```
（`ConnectionServerResponse` 接口：rpc-host.ts:46-50；错误响应 = 同一个 fullResponse 传 `{ok:false,error}`，rpc-host.ts:277-279）

**前置 401/403（在 dispatch 之前）** — rpc-host.ts:97-100、166-176
```ts
requestRejection(request) {
  if (!isTrustedApiRequest(request, this.trustedHosts)) return 403
  return this.browserAuth.isAuthenticated(request) ? undefined : 401
}
...
const route: WebRoute = { kind: 'prefix', path: channel, handler: async (req, res) => {
  const rejection = this.requestRejection(req)
  if (rejection !== undefined) { res.writeHead(rejection); res.end(rejection === 401 ? 'unauthorized' : 'forbidden'); return }
  await bridge(req, res, fetchHandler)
} }
```

**注意（对反代很重要）**：`/api` 上还挂着若干**精确 Fetch 路由**，它们先于 RPC 分发被匹配，不遵循上面的信封
（rpc-host.ts:117-133：先查 `fetchRoutes.get(pathname)`，命中就走该 route，否则才 `endpointFromPath`）。
0.1.5 中已确认存在的精确路由：`/api/file`（session-controller/src/media-references.ts:70-71）、
`/api/session.export`（packages/session-query/session-log-export）、`/api/session/uploadFileBinary`（packages/client/file-upload/src/protocol.ts:2）、
`/api/present.open`、`/api/present.host`（packages/client/ui-deliverables/src/presented.ts:7,10）。

---

## 2. 流式 RPC：WebSocket /api/remote.mux

### 结论
- 路由常量：`REMOTE_STREAM_MUX_PATH = '/api/remote.mux'`；浏览器把它转成 `ws://` 或 `wss://`（同源 origin）。
- **只允许 JSON 文本帧**。客户端收到非 string 的 message 直接判为协议错误并 close(4002)；服务端收到二进制帧 close(1003, 'text messages required')。
- 每条消息都是 `JSON.stringify(...)` 的一个完整 WebSocket 文本消息（无分包协议、无长度前缀）。
- 客户端 → 服务端只有两种帧（key 必须**精确匹配**，不允许额外字段）：
  - 开流：`{"type":"open","streamId":"<uuid>","endpoint":"<namespace>/<method>","payload":{"args":{...}}}`（恰好 4 个 key）
  - 取消：`{"type":"cancel","streamId":"<uuid>"}`（恰好 2 个 key）
- 服务端 → 客户端只有三种帧：
  - `{"type":"item","streamId":"...","value":<业务项>}`（`value` 可缺省 = 3 个 key）
  - `{"type":"error","streamId":"...","error":{"code":"...","message":"...","details":{...}}}`
  - `{"type":"end","streamId":"..."}`
  - **业务数据就在 item 帧的 `value` 字段里**，没有任何额外包裹层（value 就是 Host 侧 AsyncIterable yield 的那个对象，例如 WorkspaceFollowFrame、SessionFollowFrame、RemoteEventDownlinkFrame）。
- 一条物理连接上可并发多条逻辑流（靠 streamId 复用；重复 streamId 会被判错）。服务端每 2s 发 WebSocket Ping（控制帧），连续 2 次未 pong 就 terminate。

### 证据

**协议常量与信封** — packages/api/gateway/src/stream-protocol.ts:5-6、242-263
```ts
/** Exact WebSocket route carrying every Typert Remote stream. */
export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux'
...
export type RemoteStreamClientMessage =
  | { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown }
  | { readonly type: 'cancel'; readonly streamId: string }

export interface RemoteStreamFailure {
  readonly code: string; readonly message: string; readonly details: object
}

export type RemoteStreamServerMessage =
  | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown }
  | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure }
  | { readonly type: 'end'; readonly streamId: string }
```

**严格校验（exactKeys）** — stream-protocol.ts:270-313（节选）
```ts
if (value.type === 'cancel' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) ...
if (value.type === 'open'
  && exactKeys(value, ['type', 'streamId', 'endpoint', 'payload'])
  && validId(value.streamId) && typeof value.endpoint === 'string' && value.endpoint.length > 0) ...
// server -> client
if (value.type === 'item'
  && (exactKeys(value, ['type', 'streamId']) || exactKeys(value, ['type', 'streamId', 'value']))
  && validId(value.streamId)) ...
if (value.type === 'end' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) ...
if (value.type === 'error' && exactKeys(value, ['type', 'streamId', 'error'])
  && exactKeys(value.error, ['code', 'message', 'details']) ...) ...
```
（`exactKeys` 定义 stream-protocol.ts:338-341；`parseMessage` 强制 JSON.parse 且结果必须是对象，stream-protocol.ts:315-324）

**浏览器开流与帧消费（value 即业务项）** — packages/api/gateway/src/client/stream-client.ts:80-120
```ts
const streamId = randomUUID()
const socket = await this.waitForSocket(signal)
this.streams.set(streamId, inbox)
this.send(socket, { type: 'open', streamId, endpoint, payload })
while (true) {
  const frame = await inbox.next()
  if (frame.type === 'item') { yield frame.value; continue }
  terminal = true
  if (frame.type === 'error') throw new RemoteError(frame.error.code, frame.error.message, frame.error.details)
  return
} ... finally { if (opened && !terminal) this.send(carrier, { type: 'cancel', streamId }) }
```
文本帧强制 — stream-client.ts:222-234
```ts
if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages')
const frame = parseRemoteStreamServerMessage(data)
...
socket.close(4002, 'invalid Remote stream frame')
```
WS URL 推导 — stream-client.ts:304-310
```ts
const url = new URL(REMOTE_STREAM_MUX_PATH, base)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
```
发送即 `socket.send(JSON.stringify(message))` — stream-client.ts:270-272

**服务端 pumping（item 的 value 来源）** — packages/api/gateway/src/stream-server.ts:155-178
```ts
const source = await this.open(endpoint, payload, active.abort.signal)
for await (const value of source) {
  await this.send({ type: 'item', streamId, value })
}
if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId })
} catch (error) {
  await this.send({ type: 'error', streamId, error: this.failure(error) })
}
```
二进制拒绝 — stream-server.ts:116-126
```ts
this.socket.on('message', (data, isBinary) => {
  if (isBinary) { this.socket.close(1003, 'text messages required'); return }
  try { this.receive(rawText(data)) } catch { this.socket.close(1008, 'invalid Remote stream request') }
})
```
心跳（默认 2000ms，2 次未 pong → terminate） — stream-server.ts:22、75-94（interval 来自 Config.websocketHeartbeatIntervalMs，gateway/src/index.ts:116-126）
就绪校验 401/403（HTTP 层拒绝 upgrade） — stream-server.ts:213-224 + gateway/src/index.ts:212-221

**流的业务语义（每个物理代次）**
- `RemoteStream`（单消费、跨代次重连） — gateway/src/client/remote-stream.ts:39-158；item 带 `generation/value/signal/accept()`（remote-stream.ts:8-17）
- `RemoteSnapshotStream`（每代次恰好一个 opening snapshot + 后续 delta） — gateway/src/client/snapshot-stream.ts:31-94
- `RemoteJournalStream`（session follow-before-page） — gateway/src/client/journal-stream.ts

注意：客户端把 `open` 的 payload 也包成 `{ args: ... }` — gateway/src/client/index.ts:468
```ts
const stream = this.openRemoteStream(endpoint, { args: prepared.args }, prepared.signal)
```

---

## 3. workspace.follow（namespace = workspace）

### 结论
- namespace 由 `WorkspaceController` 显式指定为 `workspace`（`super(ctx, 'workspaceController', { namespace: 'workspace' })`），方法 `follow` 是 **stream 模式**、**没有业务参数**（只有注入的 AbortSignal）。
- 因此线上是：`POST`… 不对 —— 是 WS 开流帧
  `{"type":"open","streamId":"<uuid>","endpoint":"workspace/follow","payload":{"args":{}}}`
- 帧类型（value）：先来 1 个 `baseline`，之后是不定的 `upsert` / `remove` / `order` / `archived`。
- 0.1.5 **没有** `workspace.list` 一元方法：列表只由 follow 的 baseline 提供（客户端模型 replaceBaseline）。
- 注意：`path`/工作区 id 字段名与 0.1.1 有差别 —— 0.1.5 的 WorkspaceView 用 **`workspaceId`**（不是 `id`），加 `path`、`title`、`sessionIds`、`createdAt`、`updatedAt`。

### WorkspaceFollowFrame 完整类型定义
packages/api/workspace-controller/src/types.ts:14-27（WorkspaceView）与 112-128（baseline/increment/frame）
```ts
/** One durable Workspace projected for browser consumers. */
export interface WorkspaceView {
  readonly workspaceId: WorkspaceId
  /** Canonical host directory path. */
  readonly path: string
  /** User-visible title. */
  readonly title: string
  /** Sessions accounted to this Workspace in manual order. */
  readonly sessionIds: readonly SessionId[]
  /** ISO-8601 creation instant. */
  readonly createdAt: string
  /** ISO-8601 last-mutation instant. */
  readonly updatedAt: string
}

/** Complete reconnect baseline for Workspace browser state. */
export interface WorkspaceBaseline {
  readonly items: readonly WorkspaceView[]
  readonly archivedSessionIds: readonly SessionId[]
}

/** One ordered Workspace change after a generation's baseline. */
export type WorkspaceFollowIncrement =
  | { readonly type: 'upsert'; readonly workspace: WorkspaceView }
  | { readonly type: 'remove'; readonly workspaceId: WorkspaceId }
  | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] }
  | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] }

/** Workspace state stream; every generation starts with exactly one baseline. */
export type WorkspaceFollowFrame =
  | { readonly type: 'baseline'; readonly value: WorkspaceBaseline }
  | WorkspaceFollowIncrement
```
即线上 JSON 依次是：
```json
{"type":"item","streamId":"...","value":{"type":"baseline","value":{"items":[{"workspaceId":"...","path":"C:/x","title":"x","sessionIds":["..."],"createdAt":"...","updatedAt":"..."}],"archivedSessionIds":[]}}}
{"type":"item","streamId":"...","value":{"type":"upsert","workspace":{ ...同 WorkspaceView... }}}
{"type":"item","streamId":"...","value":{"type":"remove","workspaceId":"..."}}
{"type":"item","streamId":"...","value":{"type":"order","workspaceIds":["...","..."]}}
{"type":"item","streamId":"...","value":{"type":"archived","archivedSessionIds":["..."]}}
{"type":"end","streamId":"..."}
```

### 证据
Remote 声明 — packages/api/workspace-controller/src/index.ts:34、42、117-120
```ts
export class WorkspaceController extends TypertRemoteService {
  static inject = ['typert', 'workspaceRegistry']
  constructor(ctx: Context) {
    super(ctx, 'workspaceController', { namespace: 'workspace' })
    ...
  }
  @Remote({ mode: 'stream' })
  follow(signal: AbortSignal): AsyncIterable<WorkspaceFollowFrame> {
    return this.feed.follow(signal)
  }
}
```
（其余一元方法：create/rename/delete/insertBefore/insertSessionBefore/archiveSession，index.ts:57-110；**没有 list**）

生成端 — packages/api/workspace-controller/src/feed.ts:82-93（baseline 先行）、95-131（增量判定）、23-32（workspaceView 投影，字段名 workspaceId/path/title/sessionIds/createdAt/updatedAt）
客户端消费 — packages/api/workspace-controller/src/client/index.ts:79-93、96-113（`remote.workspace.follow(signal)` 无参数）
Workspace 实体字段定义 — packages/workspace/workspace/src/types.ts:31-59
```ts
export interface Workspace {
  readonly id: WorkspaceId          // 注意：实体叫 id，wire 投影叫 workspaceId
  readonly path: string
  readonly title: string
  readonly createdAt: string
  readonly updatedAt: string
  readonly sessionIds: readonly SessionId[]
  ...
}
```
（实现 getter：packages/workspace/workspace/src/entity.ts:85-103）

---

## 4. session.page 与 session.follow

### 4.1 session.page（一元）
结论：endpoint `session/page`，`args` 形状
```ts
{ address: SessionAddress, throughSeq: number, beforeSeq?: number, maxMessages?: number }
```
返回 `{ records: SessionHistoryRecord[], hasMore: boolean }`。

证据 — packages/api/session-controller/src/types.ts:438-444、506-510
```ts
export interface SessionPageRequest {
  readonly address: SessionAddress
  /** Inclusive log cut obtained from the corresponding follow opening frame. */
  readonly throughSeq: number
  readonly beforeSeq?: number
  readonly maxMessages?: number
}
export interface SessionPage {
  readonly records: readonly SessionHistoryRecord[]
  readonly hasMore: boolean
}
```
`SessionAddress`（page/follow 共用，注意 0.1.5 **不再叫 sessionId** 而是 address 联合） — types.ts:382-390
```ts
export type SessionAddress =
  | { readonly kind: 'session'; readonly sessionId: SessionId }
  | { readonly kind: 'subagent'; readonly parentSessionId: SessionId
      readonly childSessionId: SessionId; readonly mode: 'one-shot' | 'continuable' }
```
Remote 声明 — session-controller/src/index.ts:387-390
客户端调用 — session-controller/src/client/transport.ts:228-231
```ts
const result = await this.remote.session.page({ address: this.address, throughSeq, ...request }, signal)
```

### 4.2 session.follow（流）
结论：endpoint `session/follow`，`args` 形状
```ts
{ address: SessionAddress, maxMessages?: number, assistantStream?: true }
```
返回帧（item.value）：`snapshot` → 逐条 `{type:'event', event: SessionWireEvent}` → 可选 `{type:'assistant-stream', frame}`。

证据 — types.ts:446-452、512-524
```ts
export interface SessionFollowRequest {
  readonly address: SessionAddress
  readonly maxMessages?: number
  /** Include process-local assistant presentation frames for the Web client. */
  readonly assistantStream?: true
}

export type SessionFollowFrame =
  | { readonly type: 'snapshot'
      readonly header: SessionWireHeader
      readonly cursor: number
      readonly records: readonly SessionHistoryRecord[]
      readonly hasMore: boolean
      readonly projections: SessionProjectionBaseline
      readonly assistantStream?: SessionAssistantStreamBaseline }
  | SessionEventEntry                                        // { type:'event'; event: SessionWireEvent }
  | { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame }
```
（`SessionEventEntry` = `{ readonly type: 'event'; readonly event: SessionWireEvent }`，types.ts:393-396；
 `SessionWireEvent` = `{ type, seq, time, data, ignorable?, sourceEventSeqs?, surfaceOp? }`，types.ts:425-435；
 `SessionHistoryRecord = SessionEventEntry`，types.ts:417-418）
Remote 声明 — session-controller/src/index.ts:399-402；客户端带参调用 — transport.ts:179-183

### 4.3 “sandbox 档位”在 wire 上出现的位置（重点）
0.1.5 上**没有任何名为 `sandboxMode`、`modes`、`settings` 的会话字段**。sandbox 档位分三处出现：

**(a) 历史记录里的三条「旋钮事件」（log-only durable event，随 page.records / follow 的 event 帧原样下发）**
- `sandbox/mode` → `event.data.mode`，枚举 `'read-only' | 'workspace-write' | 'danger-full-access'`
- `permission/preset` → `event.data.preset`（字符串，如 `'workspace-write'` / `'danger-full-access'`）
- `approval/policy` → `event.data.policy`（`'ask' | 'never'`）

证据 — packages/sandbox/sandbox-policy/src/session-mode.ts:33-42
```ts
'sandbox/mode': {
  mode: SandboxMode
  /** Marks an override seeded into a child at delegation. */
  source?: 'delegation'
}
...
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
```
SandboxMode 类型 — packages/sandbox/sandbox/src/index.ts:29
```ts
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
```
折叠与枚举校验 — packages/interaction/permission-presets/src/index.ts:97-134
```ts
const permissionStateSchema = zod.object({
  preset: zod.string().nullable(),
  sandbox: zod.union([zod.literal('read-only'), zod.literal('workspace-write'), zod.literal('danger-full-access')]).nullable(),
  approval: zod.union([zod.literal('ask'), zod.literal('never')]).nullable(),
  seeded: zod.boolean(),
}).strict()
...
function applyPermissionEvent(state, event) {
  switch (event.type) {
    case 'permission/preset': return { ...state, preset: event.data.preset }
    case 'sandbox/mode':      return { ...state, sandbox: event.data.mode }
    case 'approval/policy':   return { ...state, approval: event.data.policy }
    case 'session/end-seed':  return { ...state, seeded: true }
    ...
  }
}
```
所以历史里一条记录长这样（反代要改写的目标）：
```json
{"type":"event","event":{"type":"sandbox/mode","seq":7,"time":1730000000000,"data":{"mode":"workspace-write"}}}
{"type":"event","event":{"type":"permission/preset","seq":8,"time":1730000000000,"data":{"preset":"workspace-write"}}}
```

**(b) 折叠后的 projection：`permissions`（session.follow 的 snapshot 帧 → projections.values.permissions）**
```ts
// packages/interaction/permission-presets/src/types.ts:13-32
export interface PresetOption { value: string; name: string; description?: string }
export interface PermissionSelect {
  options: PresetOption[]      // 可切换的预设（默认表：workspace-write、danger-full-access），当前为 custom 时追加 custom
  currentValue: string         // 生效值：预设表键名，或 'custom'
}
declare module '@deepseek-ai/dsh-session-projection/types' {
  interface SessionProjectionMap { permissions: PermissionSelect }
}
```
经 `SessionProjectionBaseline` 下发 — session-controller/src/types.ts:60-68
```ts
export interface SessionProjectionBaseline {
  readonly asOfSeq: number
  /** Provider-validated values; omitted keys are absent capabilities at this cut. */
  readonly values: SessionProjectionValues
}
export type SessionProjectionValues = Partial<SessionProjectionMap> & Readonly<Record<string, SessionProjectionValue>>
```
即 snapshot 帧里是 `projections.values.permissions.currentValue`（`'workspace-write'` / `'danger-full-access'` / `'custom'`）与 `projections.values.permissions.options[].value`。
默认预设表 — permission-presets/src/index.ts:170-179（workspace-write → sandbox workspace-write + approval ask；danger-full-access → sandbox danger-full-access + approval never）。
实时变更走 session.control 流：`{type:'projection', sessionId, key:'permissions', value, seq}` — types.ts:557-570。

**(c) 部署级默认（settings 命名空间 `permission`）**
```ts
// packages/interaction/permission-presets/src/index.ts:75-76, 136-140
export const PERMISSION_SETTINGS_NAMESPACE = 'permission'
export interface PermissionSettings { defaultPreset: string }
```
它通过 settings-controller 的 `settings/describe`、`settings/update` 等一元方法读写，而不是通过 session 帧。

**(d) 明确“不存在”的字段**：`sandboxMode` / `approvalPolicy` 是**已退役的 session header 字段**，0.1.5 会直接拒绝：
packages/session/session-persistence-jsonl/src/format.ts:99-109
```ts
export function assertNoRetiredHeaderFields(value: unknown): void {
  if (typeof value !== 'object' || value === null) return
  if (Object.hasOwn(value, 'sandboxMode') || Object.hasOwn(value, 'approvalPolicy')) {
    throw new Error('session header uses retired policy baseline fields')
  }
}
```
（`SessionWireHeader` 里也没有这些字段，只有 version/id/createdAt/cwd/parentSession/isSeeded/origin/delegationDepth/agentPreset — types.ts:399-410）

---

## 5. 审批 / 问答的客户端应答：POST /api/$events/result

### 结论
审批与问答**不是** HTTP 长轮询，而是复用同一条 `$events` 逻辑流（在 /api/remote.mux 上）下发，客户端再用一元 RPC 应答：
- 下发（Host → Client，作为 mux item 的 `value`）：
  - 首帧：`{"type":"ready","clientId":"<uuid>","host":{"home":"C:/Users/x"}}`
  - 通知：`{"type":"emit","event":"api-session/added","args":[...]}`
  - **可应答的 waterfall（审批/问答就走这条）**：
    `{"type":"waterfall","event":"approval/request","eventId":"<id>","agentId":"<id>","request":{ ...业务请求字段... }}`
  - 撤销：`{"type":"cancel","eventId":"<id>"}`
- 应答（Client → Host）：`POST /api/$events/result`，标准一元信封，`payload.args` 就是结果对象本身：
```json
{"type":"client-request","rpcId":"<uuid>","method":"$events/result",
 "payload":{"args":{"clientId":"<ready 帧里的 clientId>","eventId":"<waterfall 帧里的 eventId>",
                    "outcome":{"kind":"result","value":<答案>}}}} 
```
`outcome` 三种：`{"kind":"next"}`（不认领，继续下一个 listener）、`{"kind":"result"}`（可带/不带 value）、`{"kind":"rejected","error":{"name","message","code?","details?"}}`。
应答成功返回 `result.ok === true` 且 `value` 为 undefined（该键在 JSON 中缺失）。
- 事件 id 是 `eventId`（不是 `id`）；结果里**必须同时带 `clientId`**（绑定到具体一次事件流代次）。
- 0.1.5 中 `/api/$events/result` 是一个普通一元 endpoint（只是名字里带 `$`），所以 URL 是 2 段，网关认领（index.ts:266-267 有专门特判）。

### 证据
常量 — packages/api/gateway/src/stream-protocol.ts:8-18
```ts
export const REMOTE_EVENT_STREAM_ENDPOINT = '$events'
export const REMOTE_EVENT_RESULT_ENDPOINT = '$events/result'
export const REMOTE_EVENT_STREAM_PAYLOAD = { args: {} } as const
export const REMOTE_EVENT_STREAM_READY = { type: 'ready' } as const
```
下行帧类型 — stream-protocol.ts:26-70
```ts
export interface RemoteEventHostInfo { readonly home: string }
export interface RemoteEventReadyFrame { readonly type: 'ready'; readonly clientId: RemoteEventClientId; readonly host: RemoteEventHostInfo }
export interface RemoteEventEmitFrame { readonly type: 'emit'; readonly event: string; readonly args: readonly unknown[] }
export interface RemoteEventInvocationFrame {
  readonly type: 'waterfall'; readonly event: string; readonly eventId: RemoteEventId
  readonly agentId: RemoteEventAgentId; readonly request: Readonly<Record<string, unknown>>
}
export interface RemoteEventCancellationFrame { readonly type: 'cancel'; readonly eventId: RemoteEventId }
export type RemoteEventDownlinkFrame = RemoteEventReadyFrame | RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame
```
应答体类型 — stream-protocol.ts:86-94
```ts
export interface RemoteEventResult {
  readonly clientId: RemoteEventClientId
  readonly eventId: RemoteEventId
  readonly outcome:
    | { readonly kind: 'next' }
    | { readonly kind: 'result'; readonly value?: unknown }
    | { readonly kind: 'rejected'; readonly error: RemoteEventRejection }
}
```
解析（严格 exactKeys，不允许多余字段） — stream-protocol.ts:101-137
客户端发送 — packages/api/gateway/src/client/remote-events.ts:207-220
```ts
const result: RemoteEventResult = {
  clientId, eventId: frame.eventId,
  outcome: outcome.kind === 'result' && outcome.value === undefined ? { kind: 'result' } : outcome,
}
const response = await this.connection.rpc.call('/api', REMOTE_EVENT_RESULT_ENDPOINT, { args: result }, signal)
if (!response.ok) throw new Error(response.error.message)
```
服务端接收 — packages/api/gateway/src/index.ts:357-371 + 926-934
```ts
if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) {
  try {
    const result = parseRemoteEventResultPayload(payload)     // payload 必须恰好 { args: ... }
    const client = this.remoteEventClients.get(result.clientId)
    if (client === undefined) throw new Error('typert gateway: Remote event result identifies no active event stream')
    this.receiveRemoteEventResult(client, result)
    return { ok: true, value: undefined }
  } catch (error) { return rpcFailure(error) }
}
```
`$events` 开流 payload 校验（必须恰好 `{args:{}}`） — index.ts:391-399
白名单事件与模式 — packages/api/remotes/src/remote-events.ts:16-36
```ts
export const API_REMOTE_FORWARDED_EVENTS = [
  { event: 'agent-preset/selected', mode: 'emit' },
  { event: 'approval/request', mode: 'waterfall' },       // 审批
  ...
  { event: 'user-questions/request', mode: 'waterfall' }, // 问答
] as const
```
请求体（`frame.request`）里能看到的业务字段：
- 审批（packages/interaction/user-approval/src/types.ts:62-74）：
```ts
export interface ApprovalRequestEvent {
  readonly agent: Agent          // 在传输中被投影为 agentId
  readonly toolName: string
  readonly callId?: ToolCallId
  readonly reason?: string
  readonly signal?: AbortSignal  // 传输时剥离
}
```
- 问答：`user-questions/request` 的 ask request（packages/interaction/user-questions/src/types.ts:32+、index.ts:131-139）
客户端解析下行帧 — remote-events.ts:139-167、277-309；首帧必须是 ready — remote-events.ts:260-274

---

## 6. workspaceFiles 命名空间

### 结论
- wire namespace 就是 **`workspaceFiles`**（服务 key，未显式改名），方法 URL 为 `POST /api/workspaceFiles/<method>`（不是 `/api/workspaceFiles.read`）。
- 每个方法都带一个 **lookup 参数**：源码参数名 `workspaceFileScope`（类型 `WorkspaceFileScope`），但在 **wire 上的 `args` 键名是 `workspaceFileScopeId`**，值是 `SessionId` 字符串。
- `WorkspaceFileScope` 是 **Host 侧**（不进 wire）的解析结果：
```ts
export interface WorkspaceFileScope {
  readonly sessionId: SessionId
  readonly workspaceRoot: string   // live session header.cwd ?? sandboxPolicy.workspaceRoot
}
```
- wire `args` 形状（精确键名）：
  | 方法 | args |
  |---|---|
  | `workspaceFiles/read` | `{ workspaceFileScopeId, path, range: { offset?, limit? } }` |
  | `workspaceFiles/readBytes` | `{ workspaceFileScopeId, path, range: { offset?, length? } }` |
  | `workspaceFiles/readAll` | `{ workspaceFileScopeId, path }` |
  | `workspaceFiles/readRelated` | `{ workspaceFileScopeId, path, relativePath }` |
  | `workspaceFiles/stat` | `{ workspaceFileScopeId, path }` |
  | `workspaceFiles/list` | `{ workspaceFileScopeId, path }` |
  | `workspaceFiles/changes`（stream） | `{ workspaceFileScopeId }` |
- 返回值：
  - read → `WorkspaceFileText` = `{ absolutePath, version, bytes?, offset, text, lines, eof }`
  - readBytes/readAll/readRelated → `WorkspaceFileBytes` = `{ absolutePath, version, bytes?, offset, data(base64), eof }`
  - stat → `WorkspaceFileStat` = `{ absolutePath, version, bytes? }`
  - list → `WorkspaceDirectoryListing` = `{ path, entries: [{name,type,size?}], truncated }`
  - changes（stream item.value）→ `{kind:'ready'}` 或 `{kind:'change', change:{absolutePath, version} | {absolutePath, absent:true}}`

### 证据
scope 类型与 lookup 注册 — packages/api/workspace-files/src/index.ts:53-66、201-220
```ts
/** Header-derived file resolution context for one Session identity. */
export interface WorkspaceFileScope {
  readonly sessionId: SessionId
  readonly workspaceRoot: string
}
declare module '@deepseek-ai/dsh-typert-protocol' {
  interface TypertLookupMap {
    workspaceFileScope: TypertLookup<WorkspaceFileScope, SessionId>
  }
}
...
scope.typert.lookups.register('workspaceFileScope', {
  parameter: 'workspaceFileScope',
  wire: 'workspaceFileScopeId',
  hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope',
  wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
  resolve: async (sessionId) => { ... return { sessionId, workspaceRoot: header.cwd ?? scope.sandboxPolicy.workspaceRoot } },
})
```
wire 字段名来自 lookup provider（`wire` = 替换 Host 对象参数的线上字段） — packages/typert/protocol/src/types.ts:335-351、233-246；SRC 派生逻辑 — packages/api/gateway/src/index.ts:689-709
```ts
const parameter: InvocationParameterDescriptor = match === undefined
  ? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
  : { name, wire: match.wire, source: 'lookup', lookup: match.key, codec: { mode: 'src-json' } }
```
方法签名（参数顺序） — workspace-files/src/index.ts:231-365
```ts
@Remote
async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>   // :231-237
@Remote
async readBytes(workspaceFileScope, path: string, range: WorkspaceByteRange, signal): Promise<WorkspaceFileBytes>                              // :256-262
@Remote
async readAll(workspaceFileScope, path: string, signal): Promise<WorkspaceFileBytes>                                                          // :277-278
@Remote
async readRelated(workspaceFileScope, path: string, relativePath: string, signal): Promise<WorkspaceFileBytes>                                // :299-305
@Remote
async stat(workspaceFileScope, path: string, signal): Promise<WorkspaceFileStat>                                                              // :323-324
@Remote
async list(workspaceFileScope, path: string, signal): Promise<WorkspaceDirectoryListing>                                                      // :336-337
@Remote({ mode: 'stream' })
changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>                                  // :364-367
```
namespace 由 service key 决定（未传 namespace 选项） — TypertRemoteService 构造：packages/typert/protocol/src/index.ts:153-167（`const namespace = options.namespace ?? serviceKey`，:147）
客户端调用形态 — packages/api/workspace-files/README.md:28（`remote.workspaceFiles.read(sessionId, path, range, signal)` …）；实现见 src/client/provider.ts:61、src/client/change-feed.ts:124
wire 返回值类型 — workspace-files/src/types.ts:22-35（Stat）、41-46（Range）、49-62（Text）、65-83（ByteRange/Bytes）、86-115（DirectoryEntry/Listing）、122-143（WatchFrame）
lookup 注册断言（含 wire 名） — workspace-files/tests/scope.spec.ts:51-57

---

## 7. 对照 dsh-v0.1.1-rc.2

0.1.1 的对应物是 **packages/host/apiproxy**（0.1.5 已删除）。它用的是“四象限消息模型 + `/api/<method>` 扁平点号方法名 + SSE 下行 + POST /api/respond”的旧协议。

### 7.1 一元 RPC：`POST /api/<method>`，method 带**点号**
- URL：`POST /api/session.list`、`/api/workspace.list`、`/api/session.prompt`。
  - rpc-map.ts:1-5：`map keys are the wire path segments (POST /api/session.list)`
  - fetch/client.ts:339-341：`const message: ClientRequest = { type:'client-request', rpcId, method, payload }; postJson(`/api/${method}`, message, ...)`
  - fetch/handler.ts:145-148：`methodFor(path)` 直接用整段路径查表
- 请求体信封（与 0.1.5 同形）：api/rpc.ts:144-150
```ts
export interface ClientRequest { type: 'client-request'; rpcId: RpcId; method: string; payload: unknown }
```
  但 **payload 就是业务请求对象本身，没有 `{args:...}` 包装**（见 7.2/7.3 的 schema）。
- 响应体信封：api/rpc.ts:152-157
```ts
export interface ServerResponse { type: 'server-response'; rpcId: RpcId; result: RpcResult<unknown> }
// RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }   (rpc.ts:110)
```
- 业务错误码是**闭集**（`RpcErrorCode`，rpc.ts:99），`details` 按码定形（`RpcErrorDetailsMap`，rpc.ts:32-96），例如 `'workspace-not-found': { workspaceId: string }`、`'bad-request': { issues: ZodIssue[] }`。
- 下行流用 **SSE**：`GET /api/events.mux`、`GET /api/events.host`（fetch/handler.ts:254-259）；
  帧体是 `ServerRequest` 信封化的 mux/host frame（**method = frame.type**）：fetch/handler.ts:194-197、203-236
```ts
function fullFrame(narrow: RpcRequest<MuxFrame | HostFrame>): ServerRequest {
  return { type: 'server-request', rpcId: narrow.rpcId, method: narrow.payload.type, payload: narrow.payload }
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(fullFrame(narrow))}\n\n`))
```
  SSE 头：`content-type: text/event-stream`, `cache-control: no-cache`（handler.ts:233-235），开流先发一行注释 `: connected\n\n`。
- mux 帧类型（对应 0.1.5 的各种 follow/事件流） — api/events.ts:69-108：`session/event`、`session/subscribed`、`approval/requested`、`approval/resolved`、`question/requested`、`question/resolved`、`session/queue`、`session/jobs`、`session/projection`、`stream/error`。
- host 帧 — api/events.ts:127-155：`host/session-added|removed|status`、`host/agent-error`、`host/workspace-changed|removed|order-changed`、`host/archived-sessions-changed`、`host/remote-event`、`stream/error`。
  工作区变更走 `host/workspace-changed`（单行 upsert）+ `host/workspace-removed` + `host/workspace-order-changed` + `host/archived-sessions-changed`；0.1.5 把这些合并进 `workspace/follow` 的 upsert/remove/order/archived 帧。

### 7.2 workspace.list
请求/响应体 — packages/host/apiproxy/src/api/workspace.ts:39-46
```ts
export interface WorkspaceApi {
  list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[]; archivedSessionIds: SessionId[] }>>
```
- **数组放在 `items`**，归档集合是 `archivedSessionIds`。
- 请求 payload 是**空对象 `{}`**。
- 每一项的字段 — workspace.ts:20-36：
```ts
export interface WorkspaceView {
  workspaceId: WorkspaceId
  /** Canonical directory path (host-side realpath canon). */
  path: string
  title: string
  sessionIds: SessionId[]
  createdAt: string
  updatedAt: string
}
```
→ **path 字段名就是 `path`**；id 字段名是 `workspaceId`（与 0.1.5 一致，但 0.1.5 没有 list 方法）。
线上完整样例（`POST /api/workspace.list`）：
```json
请求 {"type":"client-request","rpcId":"<uuid>","method":"workspace.list","payload":{}}
响应 {"type":"server-response","rpcId":"<uuid>","result":{"ok":true,"value":{"items":[{"workspaceId":"...","path":"C:\\x","title":"x","sessionIds":[],"createdAt":"...","updatedAt":"..."}],"archivedSessionIds":[]}}}
```
- 方法注册 — api/rpc-map.ts:46（`'workspace.list': WorkspaceApi['list']`）。

### 7.3 session.history（0.1.5 对应物 = session.page + session.follow）
请求/响应体 — api/sessions.ts:286-287
```ts
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
```
schema — api/sessions.schema.ts:141-146、238-243
```ts
export const sessionHistoryRequestSchema = z.object({
  sessionId: sessionIdSchema,
  beforeSeq: z.number().int().nonnegative().optional(),
  maxMessages: z.number().int().positive().optional(),
})
export const sessionHistoryValueSchema = z.object({
  events: z.array(historyEntrySchema),
  hasMore: z.boolean(),
  projections: sessionProjectionsBlockSchema.optional(),   // 仅 tail page（无 beforeSeq）
})
```
条目形状 — api/sessions.ts:68-71：`{ event: SessionEvent; view?: ToolEventView }`（0.1.5 是 `{type:'event', event}`）
projection 块 — api/sessions.ts:83-88：`{ asOfSeq: number; values: Partial<SessionProjectionMap> }`
→ 0.1.1 的 sandbox 档位同样**不在** history 的专门字段里，而是 `events[].event` 里的 `sandbox/mode` / `permission/preset` 事件 + `projections.values` 里的权限投影（注意 0.1.1 尚无 `permissions` 投影单元，权限投影是 0.1.5 引入的；0.1.1 的 projection key 只有 `sessionListMetadata`、`imageLimits` 等，见 README.md:29）。
线上样例：
```json
请求 {"type":"client-request","rpcId":"<uuid>","method":"session.history","payload":{"sessionId":"s1","maxMessages":30}}
响应 {"type":"server-response","rpcId":"<uuid>","result":{"ok":true,"value":{"events":[{"event":{"type":"sandbox/mode","seq":7,"time":...,"data":{"mode":"workspace-write"}}}],"hasMore":true,"projections":{"asOfSeq":7,"values":{}}}}}
```

### 7.4 /api/respond（审批与问答的客户端应答）
- 端点：`POST /api/respond`，body 是 `ClientResponse`（**不是** ClientRequest 信封） — api/rpc.ts:172-177
```ts
export interface ClientResponse { type: 'client-response'; rpcId: RpcId; result: RpcResult<unknown> }
```
- rpcId 必须**回显** server-request 帧（approval/question requested）的 rpcId，不新铸 — fetch/handler.ts:296-300 + api-proxy.ts:3594-3597
- HTTP 响应体是载体回执 `RpcReceipt` — api/rpc.ts:182-187
```ts
export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }
```
- 审批应答 payload（`result.value`） — api/approvals.ts:17-21 + approvals.schema.ts:17-21
```ts
export interface ApprovalResponsePayload {
  sessionId: SessionId
  approvalId: ApprovalRequestId
  outcome: 'allowed-once' | 'rejected'
}
```
- 问答应答 payload（`result.value`） — api/questions.ts:16-19 + questions.schema.ts:14-26
```ts
export interface QuestionResponsePayload { sessionId: SessionId; answer: AskUserQuestionAnswer }
// answer = { answers: [{ id: string; selected: string[]; custom?: string }] }
```
- 取消问答：`result.ok=false` 且 `error.code === 'cancelled'`（api-proxy.ts:3611-3618），其余错误分支一律 `bad-response`。
- 路由规则：先查审批表（校验 `approvalId`/`sessionId` 与待决条目一致），再查问答表；`rpcId` 都不在 → `{accepted:false, reason:'not-pending'}`（api-proxy.ts:3594-3640）。
- 审批/问答的**下发**是 SSE mux 帧（不是 0.1.5 的 waterfall）：api/events.ts:72-75
```ts
| { 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' }
```
线上样例（审批）：
```json
下行 SSE data: {"type":"server-request","rpcId":"<批准请求的id>","method":"approval/requested","payload":{"type":"approval/requested","sessionId":"s1","approvalId":"a1","toolName":"bash","reason":"..."}}
上行 POST /api/respond {"type":"client-response","rpcId":"<同一个id>","result":{"ok":true,"value":{"sessionId":"s1","approvalId":"a1","outcome":"allowed-once"}}}
响应体          {"accepted":true}
```

### 7.5 0.1.1 → 0.1.5 关键差异速查
| 主题 | 0.1.1-rc.2 | 0.1.5-rc.2 |
|---|---|---|
| 一元 URL | `POST /api/<ns>.<method>`（点号，apis 全在 rpc-map.ts） | `POST /api/<ns>/<method>`（斜杠） |
| 一元 payload | 业务对象本身 | `{ args: { ... } }` 包装 |
| 错误信封 | `{ok:false,error:{code,message,details}}`，code 是闭集 | 同形，但 code 是开放式字符串（`gateway/*`、`session/*`、`workspace/*`… 由 typert 的 RemoteErrorDetailsMap 合并） |
| 下行流 | SSE `GET /api/events.mux`、`/api/events.host`，帧 = `ServerRequest{method=frame.type}` | WS `/api/remote.mux`，帧 = `{type:'item',streamId,value}` 等多路复用 |
| 工作区列表 | `workspace.list` 返回 `{items, archivedSessionIds}` | 无 list；`workspace/follow` 的 `baseline` 帧提供 |
| 工作区增量 | 4 个 host 帧（changed/removed/order-changed/archived-sessions-changed） | 1 条流里的 upsert/remove/order/archived |
| 会话历史 | `session.history` 一元分页 `{events, hasMore, projections?}` | `session/page`（`{records, hasMore}`）+ `session/follow` 流（snapshot + event 帧） |
| 审批/问答应答 | `POST /api/respond`（ClientResponse + RpcReceipt） | `POST /api/$events/result`（一元 client-request，args = RemoteEventResult） |
| 审批/问答下发 | SSE mux 的 `approval/requested` / `question/requested` 帧 | WS `$events` 流里的 `waterfall` 帧（带 eventId/agentId/request） |
| 会话地址 | `sessionId` | `address` 联合（session 或 subagent） |
| 工作区 id 字段 | `workspaceId`（项内） | `workspaceId`（WorkspaceView 内）/ `workspaceIds`（order 帧） |
| sandbox 档位 | history event `sandbox/mode`.`data.mode` | 同上，**外加** `projections.values.permissions`（PermissionSelect） |
