Преглед на файлове

fix(host): preserve subagent continuation ownership

Dudu-0223 преди 1 месец
родител
ревизия
9a7be21b7f
променени са 29 файла, в които са добавени 512 реда и са изтрити 99 реда
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md
  7. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml
  8. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md
  9. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
  11. 8 3
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
  12. 8 3
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml
  14. 1 1
      .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md
  15. 1 1
      .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md
  16. 2 2
      .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml
  17. 1 1
      .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
  18. 1 1
      .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md
  19. 2 2
      packages/host/apiproxy/README.i18n.yaml
  20. 6 4
      packages/host/apiproxy/README.md
  21. 6 4
      packages/host/apiproxy/README.zh.md
  22. 156 24
      packages/host/apiproxy/src/api-proxy.ts
  23. 6 4
      packages/host/apiproxy/src/api/commands.ts
  24. 5 1
      packages/host/apiproxy/src/api/goals.ts
  25. 20 7
      packages/host/apiproxy/src/api/sessions.ts
  26. 178 11
      packages/host/apiproxy/tests/api-proxy-cold.spec.ts
  27. 12 1
      packages/host/apiproxy/tests/api-proxy-commands.spec.ts
  28. 45 2
      packages/host/apiproxy/tests/api-proxy-fork.spec.ts
  29. 38 11
      packages/host/apiproxy/tests/api-proxy-projections.spec.ts

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
-2026-07-19-gui-layering-and-rpc-protocol.md: b7081591cf7e5e3c586c74c5a71b4317376135cb
-2026-07-19-gui-layering-and-rpc-protocol.zh.md: 89557182ca7781f4fb59b8daf866aaca96cf20ee
+2026-07-19-gui-layering-and-rpc-protocol.md: 7ad2a2403eb9962b369b016070e8ca378ed55c60
+2026-07-19-gui-layering-and-rpc-protocol.zh.md: 90850c469f1444e7f6cd105551e6cc21920e91d9

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

@@ -184,7 +184,7 @@ The remaining frame types are not re-copied here; the full unions are `MuxFrame`
 - **History = event replay**: one fold (client side); history pagination and live increments share one code path; the server maintains no second materialized-snapshot system. History **page boundaries align to message boundaries** (never cut mid-message; chunks group with their finalized message), and the tail page includes the in-flight partial's chunks.
 - **Prompt correlation**: the prompt's rpcId rides MessageSource (`'user-rpc'`) into the `user/message` event; the client uses it to promote the optimistic echo.
 - **Reconnect = rebuild**: no resume cursor (`mux`'s `since` signature is a reserved seat, ignored if passed); on disconnect reopen the stream + refetch history; compare `subscribed.lastSeq` with the history tail seq and backfill once if there is a seam.
-- **Cold sessions resume implicitly**: when `history`/`prompt` hits an unattached session the impl auto-resumes, deduplicating concurrent triggers with an in-flight table; attachment status is not exposed to clients (`running` already covers it).
+- **Cold session handling follows ownership**: `session.history` and the source read for `session.fork` inspect persistence without an Agent, while Agent-bound ordinary-session methods such as `prompt` resume through a deduplicated in-flight table. Session-backed subagents reject that generic resume path, and attachment status is not exposed to clients (`running` already covers it).
 - **Approvals/questions**: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events `approval/asked`/`decided` continue through the durable log — frames = the live control plane, events = the durable audit. **Status**: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (`respond` in `api-proxy.ts` is a stub, always `not-pending`); PendingCard v1 is display-only.
 - **No protocol version**: client and host release bound together; `host.describe` has no protocolVersion field; introduce one when an independently released client appears.
 - **Reserved-seam discipline**: the map holds only implemented methods; an unknown method fails loud at envelope parse (`bad-request`) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair): `session.fork`, `prompt.mode` gaining `'inject'`, `task.list`, `host.listModels`, describe gaining `hostInstanceId`. (`session.rename` graduated from this list: it appends a user-source `session/title` event.)

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

@@ -182,7 +182,7 @@ export type ResponseValue<K> =
 - **历史 = 事件重放**:一套 fold(client 侧),历史分页与 live 增量同一条代码路径;server 不做物化快照第二套。history **页边界对齐消息边界**(绝不从消息中间截断;chunk 随定稿消息归组),尾页含进行中 partial 的 chunk。
 - **prompt 关联**:prompt 的 rpcId 经 MessageSource(`'user-rpc'`)透传进 `user/message` 事件,client 以此把乐观回显转正。
 - **重连 = 重建**:不做续传 cursor(`mux` 的 `since` 签名留座、传了忽略);断线重开流 + 重拉 history;`subscribed.lastSeq` 与 history 尾 seq 比对,有缝再补拉一次。
-- **冷 session 隐式 resume**:`history`/`prompt` 命中未 attach 的 session 时 impl 自动 resume,并发触发用在途表去重;attach 与否不对客暴露(`running` 已覆盖)。
+- **冷会话处理遵循所有权**:`session.history` 与 `session.fork` 的源端读取会在不获取 Agent 的情况下检查持久化存储,而绑定到 Agent 的普通会话方法(如 `prompt`)则通过在途表去重后恢复会话。由会话支撑的 subagent 会拒绝这条通用恢复路径,且附加状态不对客户端暴露(`running` 已经覆盖)。
 - **审批/问答**:requested 帧受理时 mint 稳定 rpcId;先到先赢,host 内存 pending 表(keyed by rpcId)是唯一裁判;mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧(rpcId 原样复用,刷新恢复)。审计事件 `approval/asked`/`decided` 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。**现状**:契约与帧类型已 shipped,host 侧 pending 表/wire answerer 未实现(`api-proxy.ts` 的 `respond` 是 stub,恒回 `not-pending`);PendingCard v1 只展示。
 - **不设协议版本**:client 与 host 绑定发布,`host.describe` 无 protocolVersion 字段;出现独立发布的 client 时再引入。
 - **预留接缝纪律**:map 只含已实现方法,未知 method 在信封 parse 即 fail loud(`bad-request`),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):`session.fork`、`prompt.mode` 加 `'inject'`、`task.list`、`host.listModels`、describe 加 `hostInstanceId`。(`session.rename` 已从本清单毕业:追加 user 来源的 `session/title` 事件。)

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md
-2026-07-30-session-end-seed-log-boundary.md: ca6145f3ce404b88d2a144483c6f1145fe0a9a7e
-2026-07-30-session-end-seed-log-boundary.zh.md: d492c4ac5f39328e75d991b285004e2d19c56283
+2026-07-30-session-end-seed-log-boundary.md: 9d0685876b4d1bac339961c67ab08f620e499464
+2026-07-30-session-end-seed-log-boundary.zh.md: 8fa9625ea6c58b0b07d964ef2580b670893a3d75

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md

@@ -20,7 +20,7 @@ A bracket owner reads it positionally: an unmatched opening marker before `sessi
 
 The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case that must be classifiable. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating.
 
-Two guards keep the marker precise. An omitted seed writes nothing because the session is fresh. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit.
+Two guards keep the marker precise. An omitted seed writes nothing because the session is fresh. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness: each Agent-bound pickup of a cold session passes through `agentFor()`, and without the guard repeated controls would grow the log even when they perform no work. The inspection-only `session.history` and `session.fork` source paths do not create this boundary in the source.
 
 ## Persistence needs no changes
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md

@@ -20,7 +20,7 @@ Status: implemented
 
 选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是必须可判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。
 
-两条守卫让这个标记保持精确。省略种子时不写入任何内容,因为这是全新会话。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件
+两条守卫让这个标记保持精确。省略种子时不写入任何内容,因为这是全新会话。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁:每次绑定到 Agent 的冷会话接手都会经过 `agentFor()`;没有这条守卫,重复的控件操作即使没有执行任何工作,也会让日志增长。只执行检查的 `session.history` 与 `session.fork` 源端路径不会在源会话中创建这条边界
 
 ## 持久化无需任何改动
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md
-2026-07-31-web-stop-preserves-queue.md: 943e95d6951a28929c4f8ce4d0b6e17224b08ede
-2026-07-31-web-stop-preserves-queue.zh.md: bbadd8adf8fd5bb0604ef87d322e48ce4c2ed759
+2026-07-31-web-stop-preserves-queue.md: b8bba45009c126495a8b03ee36d6eb2cccac9a93
+2026-07-31-web-stop-preserves-queue.zh.md: 0e1fffae8819bea81b91fae10c76dbadc78474fe

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md

@@ -12,7 +12,7 @@ The browser cannot repair that loss by resending visible rows. It does not own t
 
 ## Decision
 
-`session.cancel` is the Web Host API's active-turn stop. It calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending inbox work while cooperatively aborting the current turn. The underlying option preserves queued and steering entries; the Web Queue projection continues to expose only queued entries.
+`session.cancel` is the Web Host API's active-turn stop for ordinary sessions. It rejects session-backed subagents with `agent-busy`; otherwise it calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending inbox work while cooperatively aborting the current turn. The underlying option preserves queued and steering entries; the Web Queue projection continues to expose only queued entries.
 
 The AgentLoop starts no concurrent replacement turn. It closes and flushes the interrupted turn, reaches cancellation quiescence, and then claims the next waking queued occurrence through its existing FIFO driver. That claim emits `agent/inbox/dequeue`, so the Host's authoritative `session/queue` snapshot retires the claimed row and leaves the remaining tail visible. The browser neither resends nor promotes any row. Work that ignores cancellation delays this handoff until it settles.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md

@@ -12,7 +12,7 @@ Web 停止按钮调用 `session.cancel`,后者映射到广义 `agent.cancel({
 
 ## 决策
 
-`session.cancel` 是 Web Host API 的活动轮次停止操作。它调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 inbox 工作。底层选项会保留 queued 和 steering 入队项;Web Queue 投影继续只暴露 queued 入队项。
+`session.cancel` 是 Web Host API 面向普通会话的活动轮次停止操作。它会以 `agent-busy` 拒绝由会话支撑的 subagent;否则会调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 inbox 工作。底层选项会保留 queued 和 steering 入队项;Web Queue 投影继续只暴露 queued 入队项。
 
 AgentLoop 不会启动并发的替代轮次。它会关闭并 flush 被中断的轮次,达到取消的完全停稳,然后通过现有 FIFO 驱动器认领下一个可唤醒的 queued 入队项。该认领会发出 `agent/inbox/dequeue`,因此 Host 的权威 `session/queue` 快照会退役已认领行,并使剩余队尾保持可见。浏览器既不重发,也不提升任何行。忽略取消的工作会延迟这一交接,直到该工作结算。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
-2026-07-27-web-subagent-conversations.md: ffe248af94c08ea5dba7e47cdd1f70c56122d234
-2026-07-27-web-subagent-conversations.zh.md: 8d152cb954324c65c5662b0e2a960caf291409d1
+2026-07-27-web-subagent-conversations.md: d75bc1471815cb0da78cf078585022ca668ebb81
+2026-07-27-web-subagent-conversations.zh.md: 5cd2999cb6e8ad68bfeccd0f685dc6e4471b6091

+ 8 - 3
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md

@@ -6,7 +6,7 @@ English | [中文](2026-07-27-web-subagent-conversations.zh.md)
 
 ## Problem
 
-Session-backed subagents have durable identities, persisted transcripts, and a direct-child catalog, but the Web client otherwise sees only ordinary session lineage. It cannot distinguish a subagent from a fork, discover descriptor mode, or view a cold child without using the ordinary history path that resumes an Agent.
+Session-backed subagents have durable identities, persisted transcripts, and a direct-child catalog, but ordinary session lineage cannot distinguish them from forks or prove their descriptor mode and continuation authority. Generic Agent-bound Host operations can otherwise resume or drive a child outside its direct-parent continuation owner.
 
 The browser must preserve the [continuable subagent contract](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md): a continuable child has at most one process-local Activation, accepts later work only through the exact live direct parent, and uses the Agent inbox as its sole FIFO. Viewing history must not create an Activation. Once an inbox message is accepted, the HTTP caller neither owns its execution nor gains a cancellation handle.
 
@@ -18,6 +18,8 @@ The Web product exposes the selected session's direct session-backed subagents f
 
 Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt calls `ctx.subagents.followup()` and succeeds at inbox acceptance with `{ messageId }`; it does not steer an open turn, expose an Activation, wait for completion, or return an outcome.
 
+The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization.
+
 The ordinary Stop action is absent from addressed child conversations. `SubagentService.followup()` owns admission only until inbox acceptance and intentionally exposes no public child cancellation operation. A later cancellation design needs an explicit authority and lifecycle contract rather than falling through to `session.cancel`.
 
 This decision covers Web discovery, transcript viewing, and parent-authorized human continuation. It does not make a subagent independently user-owned; that product remains [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md).
@@ -43,7 +45,7 @@ Selecting a row records its exact address before opening the resident client `Se
 
 A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior.
 
-Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`, because either route would activate persisted child history outside the direct-parent continuation seam.
+Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`; the Host also rejects any accidental call instead of activating persisted child history outside the direct-parent continuation seam.
 
 ## Host adapter and wire contract
 
@@ -57,6 +59,8 @@ The gateway maps missing parent, missing or diagnostic catalog entries, not-resu
 
 Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`.
 
+The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` and `session.updateQueue` apply the same check directly because they intentionally query only attached Agents.
+
 The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [GUI RPC layering](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
 
 ## Client object layer and presentation
@@ -75,7 +79,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
 
 ## Alternatives considered
 
-**Reuse ordinary session APIs.** Rejected because ordinary history may resume the child and ordinary prompt drives it without direct-parent continuation authority.
+**Use ordinary session APIs for addressed children.** Rejected because generic history carries no catalog-mode verification, while Agent-bound generic controls deliberately reject subagents rather than granting direct-parent continuation authority.
 
 **Put the adapter in the webserver.** Rejected because catalog and continuation are channel-independent client capabilities; the webserver only carries validated messages.
 
@@ -96,6 +100,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
 ## Testing
 
 - Host protocol tests pin schemas, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping.
+- Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence.
 - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips, and membership refresh.
 - jsdom tests pin mixed-mode rows, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
 - The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only.

+ 8 - 3
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md

@@ -6,7 +6,7 @@ Status: implemented
 
 ## 问题
 
-由会话支撑的 subagent 具有持久化身份、持久化 transcript(文本记录)与直接 child 目录,但 Web 客户端除此之外只能看到普通会话谱系。它无法区分 subagent 与 fork、获知描述符 mode,或在不使用会恢复 agent(智能体)的普通历史路径的情况下查看冷态 child。
+由会话支撑的 subagent 具有持久化身份、持久化 transcript(文本记录)与直接 child 目录,但普通会话谱系无法将它们与 fork 区分开,也无法证明其描述符 mode 与继续执行授权。否则,绑定到 agent(智能体)的通用 Host 操作可能在其直接 parent 继续执行 owner 之外恢复或驱动 child。
 
 浏览器必须遵守[可继续 subagent 契约](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md):一个可继续 child 在进程内最多只能有一项 Activation,只能通过确切的存活直接 parent 接受后续工作,并将 agent inbox 用作唯一的 FIFO。查看历史不得创建 Activation。inbox 消息一经接受,HTTP 调用方既不拥有其执行过程,也不会获得取消句柄。
 
@@ -18,6 +18,8 @@ Web 产品通过页头操作公开选中会话中由会话支撑的直接 subage
 
 每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词操作会调用 `ctx.subagents.followup()`,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会 steer 打开的轮次、公开 Activation、等待完成或返回结果。
 
+通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。
+
 已寻址 child 对话不提供普通 Stop 操作。`SubagentService.followup()` 只负责消息被 inbox 接受前的准入,并有意不公开任何 child 取消操作。后续取消设计需要显式的授权与生命周期契约,而不能回退到 `session.cancel`。
 
 本决策涵盖 Web 端发现、transcript 查看与经 parent 授权的用户继续交互。它不会让 subagent 成为用户独立所有的对象;这类产品仍然属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md)。
@@ -43,7 +45,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
 
 one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。
 
-已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`,因为任一路径都会在直接 parent 继续执行 seam 之外激活持久化 child 历史。
+已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`;Host 也会拒绝任何意外调用,而不是在直接 parent 继续执行 seam 之外激活持久化 child 历史。
 
 ## 宿主适配器与协议契约
 
@@ -57,6 +59,8 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
 
 查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。
 
+普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 与 `session.updateQueue` 会直接执行同一检查,因为它们有意只查询已附加的 Agent。
+
 适配器仍位于 `dsh-host-apiproxy`;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包(package)导入契约,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。
 
 ## 客户端对象层与呈现
@@ -75,7 +79,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
 
 ## 备选方案
 
-**复用普通会话 API。** 不予采纳,因为普通历史可能恢复 child,而普通提示词会在缺少直接 parent 继续执行授权的情况下驱动它
+**对已寻址 child 使用普通会话 API。** 不予采纳,因为通用历史不携带目录 mode 校验,而绑定到 Agent 的通用控件会有意拒绝 subagent,不会授予直接 parent 继续执行授权
 
 **将适配器放入 webserver。** 不予采纳,因为目录与继续执行是通道无关的客户端功能;webserver 只承载已校验的消息。
 
@@ -96,6 +100,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
 ## 测试
 
 - 宿主协议测试固定 schema、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
+- 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转与成员刷新。
 - jsdom 测试固定混合 mode 行、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
 - 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md
-2026-07-29-addressable-queue-operations.md: 57527730d0f43a3a6c7801806fb9cc136daa5f14
-2026-07-29-addressable-queue-operations.zh.md: 65617e492c16e41d19ba296c7d117f8a2771a67d
+2026-07-29-addressable-queue-operations.md: 02519f8fe8be60823ac773ac4cceecb9f12f88b2
+2026-07-29-addressable-queue-operations.zh.md: 998fc49ae7289b07c97312d4814d85b5cc84387b

+ 1 - 1
.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md

@@ -16,7 +16,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M
 
 **The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. A synchronously re-entrant update or terminal event may reach the mirror before its outer enqueue listener; the mirror retains that unseen outcome for the current dispatch and folds it into the enqueue, so listener registration order cannot publish stale content or a ghost row. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes.
 
-**Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`.
+**Queue addresses require a live ordinary-session Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A session-backed subagent returns `agent-busy` before inbox access and retains its continuation owner; for ordinary sessions, a missing Agent and a driver-claimed occurrence both return `queue-item-not-found`.
 
 **Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `"<n> 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. The Web stop action preserves pending Queue work; AgentLoop claims the next waking occurrence only after the interrupted turn reaches quiescence, and its dequeue event retires that row without a browser resend.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md

@@ -16,7 +16,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行
 
 **实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。同步可重入的 update 或终态事件可能先于外层 enqueue 监听器到达镜像;镜像会在当前分发期间保留这一尚不可见的结果,并在处理 enqueue 时把它合并进去,因此监听器注册顺序不会导致系统发布陈旧内容或不存在的行。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。
 
-**Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。
+**Queue 寻址要求普通会话的 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。由会话支撑的 subagent 会在访问 inbox 前返回 `agent-busy`,并保留其继续执行 owner;对于普通会话,Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。
 
 **Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `"<n> 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。
 

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
-2026-07-29-durable-last-activity-index.md: 0938e7ea2aa817818ed552fd8b702795840b547a
-2026-07-29-durable-last-activity-index.zh.md: 647bce817444c77ac3cd6f51385472fff745212f
+2026-07-29-durable-last-activity-index.md: 0e441f54a719b29a1a450c133e08cdf7d2c82e9e
+2026-07-29-durable-last-activity-index.zh.md: 9557da0a186084499dfb0f3e8d36016d5b216ae7

+ 1 - 1
.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md

@@ -8,7 +8,7 @@ English | [中文](2026-07-29-durable-last-activity-index.zh.md)
 
 A cold (persisted, unattached) session has no stored answer to "when was this last worked in". `dsh-host-apiproxy`'s `summarizeCold()` therefore approximates it with the log file's mtime where one exists — `locate()` resolves a per-session artifact for JSONL and `undefined` for SQLite, whose cold sessions fall back to `createdAt` — and the web client sorts its session tree by the resulting `updatedAt`. The two backends are wrong in opposite directions: JSONL reads too new, SQLite too old.
 
-mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. "Touched" is broader than "resumed" — `dsh-host-apiproxy`'s `agentFor()` resumes a cold session on first touch, and `sessions.history` reaches it when the web client merely opens one, so ordinary browsing is enough.
+mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. `dsh-host-apiproxy` keeps `session.history` inspection-only, but any Agent-bound ordinary-session control resumes through `agentFor()` and is enough to promote the cold artifact.
 
 The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size.
 

+ 1 - 1
.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md

@@ -8,7 +8,7 @@ Status: proposed
 
 一个冷会话(已持久化、未附加)对「上次是什么时候在这里面工作过」没有任何已存储的答案。因此 `dsh-host-apiproxy` 的 `summarizeCold()` 在存在日志文件时用它的 mtime 来近似它——`locate()` 为 JSONL 解析出一个逐会话产物,为 SQLite 解析出 `undefined`,而 SQLite 的冷会话会回退到 `createdAt`——而 web 客户端就按由此得到的 `updatedAt` 为自己的会话树排序。这两个后端错的方向正好相反:JSONL 读出来偏新,SQLite 偏旧。
 
-mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。「触碰」比「恢复」的范围更宽——`dsh-host-apiproxy` 的 `agentFor()` 会在首次触碰时恢复一个冷会话,而 web 客户端仅仅打开一个会话时 `sessions.history` 就会到达它,因此普通的浏览就够了
+mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。`dsh-host-apiproxy` 让 `session.history` 保持只执行检查,但任何绑定到 Agent 的普通会话控件都会通过 `agentFor()` 恢复会话,足以把冷态产物排到前面
 
 已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。
 

+ 2 - 2
packages/host/apiproxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: 15a8a6f80dc3f4c37950e85a902f5c53e83cfcac
-README.zh.md: 6c61d8e2a4c44e9c1b8bbec1555c36f0cd39c1e2
+README.md: 45e79aea55665882faeee14201a2c2dda6fe9199
+README.zh.md: cb0cbe4fe53a3d328adbb57671ce6aa4a439470c

Файловите разлики са ограничени, защото са твърде много
+ 6 - 4
packages/host/apiproxy/README.md


Файловите разлики са ограничени, защото са твърде много
+ 6 - 4
packages/host/apiproxy/README.zh.md


+ 156 - 24
packages/host/apiproxy/src/api-proxy.ts

@@ -504,10 +504,10 @@ function historyPage(
  * registry). An absent registry means the deployment has no projection seam:
  * the whole block is absent and clients treat every key as capability-absent.
  */
-function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | undefined {
+function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
   const registry = ctx.get('sessionProjections')
   if (registry === undefined) return undefined
-  return registry.snapshot(agent.session)
+  return registry.snapshot(session)
 }
 
 /**
@@ -639,6 +639,13 @@ async function catalogChild(
  */
 class SessionNotFound extends Error {}
 
+/** Session identity whose lifecycle belongs to subagent routing, not generic Host resume. */
+class SubagentSessionOwnership extends Error {
+  constructor(readonly sessionId: SessionId) {
+    super(`session "${sessionId}" is a subagent session; use subagent delivery`)
+  }
+}
+
 /** Requested identity already belongs to a session with another project cwd. */
 class SessionCwdConflict extends Error {
   constructor(
@@ -1008,28 +1015,70 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
     })
   }
 
+  /** Whether the session's own suffix carries the durable subagent discriminator. */
+  function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
+    const ownStart = session.header.seedLength ?? 0
+    return session.events.slice(ownStart).some(event => event.type === 'subagent/descriptor')
+  }
+
   /**
-   * Gate the cold path on the store: an id absent from it, or naming a legacy
-   * log without a cwd (pre-release stance: not served, no compatibility), is
-   * not-found before any resume is attempted. With the gate passed, a later
-   * resume failure is genuinely internal. No persistence configured skips the
-   * gate — resume itself then fails loud with its own diagnostic.
+   * Generic Host interaction cannot claim a durably classified subagent or an
+   * Agent created through its live parent. The runtime-owner arm also covers
+   * descriptor-less child publication windows and older stored headers.
    */
-  async function assertServable(sessionId: SessionId): Promise<void> {
+  function hasSubagentOwner(
+    session: Pick<Session, 'events' | 'header'>,
+    agent: Agent | undefined,
+  ): boolean {
+    if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true
+    const parentId = session.header.parentSession
+    if (parentId === undefined || agent === undefined) return false
+    const parent = ctx.agents.get(parentId)
+    return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
+  }
+
+  /** Stable generic-Host error for an identity reserved to subagent routing. */
+  function subagentOwnershipError(sessionId: SessionId): RpcError {
+    return {
+      code: 'agent-busy',
+      message: `session "${sessionId}" is owned by subagent routing`,
+      details: { reason: 'use subagent delivery for this child session' },
+    }
+  }
+
+  /** Inspect one cold served session without repairing, resuming, or publishing it. */
+  async function inspectServable(sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
     const persistence = ctx.get('sessionPersistence')
-    if (persistence === undefined) return
+    if (persistence === undefined) {
+      throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
+    }
     const meta = (await persistence.list()).find(m => m.id === sessionId)
     if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
+    const inspected = await persistence.inspect(sessionId)
+    if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
+    return inspected
   }
 
   async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
+    const attached = ctx.sessions.get(sessionId)
     const live = ctx.agents.get(sessionId)
+    if (attached !== undefined && hasSubagentOwner(attached, live)) {
+      return { error: subagentOwnershipError(sessionId) }
+    }
     if (live !== undefined) return { agent: live }
     let resume = resumes.get(sessionId)
     if (resume === undefined) {
       resume = (async () => {
         try {
-          await assertServable(sessionId)
+          const inspected = await inspectServable(sessionId)
+          if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
+            throw new SubagentSessionOwnership(sessionId)
+          }
+          const publishedSession = ctx.sessions.get(sessionId)
+          const publishedAgent = ctx.agents.get(sessionId)
+          if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) {
+            throw new SubagentSessionOwnership(sessionId)
+          }
           const handle = await ctx.agents.resume({
             resumeSessionId: sessionId,
             agentOptions,
@@ -1048,27 +1097,77 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       if (error instanceof SessionNotFound) {
         return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
       }
+      if (error instanceof SubagentSessionOwnership) {
+        return { error: subagentOwnershipError(error.sessionId) }
+      }
       // The internal details slot is contractually {}; the reason rides the message.
       return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
     }
   }
 
+  type SessionReadState = {
+    id: SessionId
+    header: SessionHeader
+    events: SessionEvent[]
+  }
+
+  /** Read one stable session prefix without acquiring an Agent owner. */
+  async function readSessionState(sessionId: SessionId): Promise<SessionReadState> {
+    const attached = ctx.sessions.get(sessionId)
+    if (attached !== undefined) {
+      return {
+        id: attached.id,
+        header: attached.header,
+        events: [...attached.events],
+      }
+    }
+    const inspected = await inspectServable(sessionId)
+    return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
+  }
+
+  /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */
+  async function historyStateFor(
+    sessionId: SessionId,
+    includeProjections: boolean,
+  ): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
+    const attached = ctx.sessions.get(sessionId)
+    if (attached !== undefined) {
+      const events = [...attached.events]
+      const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
+      return { events, ...projections === undefined ? {} : { projections } }
+    }
+    const inspected = await inspectServable(sessionId)
+    const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
+    return {
+      events: inspected.events,
+      ...projections === undefined ? {} : { projections },
+    }
+  }
+
   /** Resolve one requested identity to a live agent, creating or resuming it once. */
   async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
     let creation = sessionCreations.get(sessionId)
     if (creation === undefined) {
       creation = (async () => {
+        const attached = ctx.sessions.get(sessionId)
         const live = ctx.agents.get(sessionId)
+        if (attached !== undefined && hasSubagentOwner(attached, live)) {
+          throw new SubagentSessionOwnership(sessionId)
+        }
         if (live !== undefined) return live
 
         const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
         const stored = persistence === undefined
           ? undefined
           : (await persistence.list()).find(header => header.id === sessionId)
-        if (stored !== undefined) {
+        if (persistence !== undefined && stored !== undefined) {
           if (stored.cwd !== cwd) {
             throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
           }
+          const inspected = await persistence.inspect(sessionId)
+          if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
+            throw new SubagentSessionOwnership(sessionId)
+          }
           return (await ctx.agents.resume({
             resumeSessionId: sessionId,
             agentOptions,
@@ -1091,7 +1190,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         // Another Host entry path may have published the same identity while
         // this operation crossed an asynchronous persistence/filesystem step.
         const live = ctx.agents.get(sessionId)
-        if (live !== undefined) return live
+        if (live !== undefined) {
+          if (hasSubagentOwner(live.session, live)) throw new SubagentSessionOwnership(sessionId)
+          return live
+        }
+        const attached = ctx.sessions.get(sessionId)
+        if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
+          throw new SubagentSessionOwnership(sessionId)
+        }
         throw error
       }).finally(() => {
         sessionCreations.delete(sessionId)
@@ -1099,6 +1205,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       sessionCreations.set(sessionId, creation)
     }
     const agent = await creation
+    if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId)
     if (agent.session.header.cwd !== cwd) {
       throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
     }
@@ -1498,6 +1605,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
               },
             })
           }
+          if (error instanceof SubagentSessionOwnership) {
+            return err(request, subagentOwnershipError(error.sessionId))
+          }
           return err(request, {
             code: 'internal',
             message: `failed to create session "${sessionId}": ${String(error)}`,
@@ -1520,18 +1630,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
 
       async history(request) {
         const { sessionId, beforeSeq, maxMessages } = request.payload
-        const found = await agentFor(sessionId)
-        if ('error' in found) return err(request, found.error)
-        // Everything below the resume above is synchronous: the page slice,
-        // the seq read, and the projection walk see one un-torn session state.
-        const page = historyPage(ctx, found.agent.session.events, beforeSeq, maxMessages)
-        // Baseline rider: tail page only — loadOlder (beforeSeq present) is
-        // the one path that never needs a fresh projection baseline.
-        const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
+        let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
+        try {
+          state = await historyStateFor(sessionId, beforeSeq === undefined)
+        } catch (error: unknown) {
+          if (error instanceof SessionNotFound) {
+            return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
+          }
+          return err(request, {
+            code: 'internal',
+            message: `history unavailable for session "${sessionId}": ${String(error)}`,
+            details: {},
+          })
+        }
+        const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
         return ok(request, {
           events: page.events,
           hasMore: page.hasMore,
-          ...projections === undefined ? {} : { projections },
+          ...state.projections === undefined ? {} : { projections: state.projections },
         })
       },
 
@@ -1606,9 +1722,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
 
       async fork(request) {
         const { sessionId, atSeq } = request.payload
-        const found = await agentFor(sessionId)
-        if ('error' in found) return err(request, found.error)
-        const source = found.agent.session
+        let source: SessionReadState
+        try {
+          source = await readSessionState(sessionId)
+        } catch (error: unknown) {
+          if (error instanceof SessionNotFound) {
+            return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
+          }
+          return err(request, {
+            code: 'internal',
+            message: `fork source unavailable for session "${sessionId}": ${String(error)}`,
+            details: {},
+          })
+        }
         const events = source.events
         // An in-log anchor belongs to the turn containing it and must never
         // clip backward to an earlier completed turn. Omitted and past-end
@@ -1694,6 +1820,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       updateQueue(request) {
         const { sessionId, itemId, action } = request.payload
         const agent = ctx.agents.get(sessionId)
+        if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
+          return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
+        }
         if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') {
           return Promise.resolve(err(request, {
             code: 'queue-item-not-found',
@@ -1714,6 +1843,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
             details: { sessionId },
           }))
         }
+        if (hasSubagentOwner(agent.session, agent)) {
+          return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
+        }
         agent.cancel({ kind: 'user' }, { keepInbox: true })
         return Promise.resolve(ok(request, { accepted: true as const }))
       },

+ 6 - 4
packages/host/apiproxy/src/api/commands.ts

@@ -1,8 +1,8 @@
 /**
  * commands domain contract: the web catalog/dispatch face of the host command
- * registry (`ctx.commands`). Both methods address one session's agent via
- * `sessionId` — every served session has an Agent (Session+Agent are born
- * together), so there is no agent-less surface on this wire.
+ * registry (`ctx.commands`). Both methods address an ordinary session's Agent
+ * via `sessionId`, resuming it when cold. Session-backed subagents reject with
+ * `agent-busy` and retain their dedicated continuation owner.
  */
 
 import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
@@ -27,7 +27,8 @@ export interface CommandDescriptor {
 export interface CommandsApi {
   /**
    * Lists the addressed agent's effective command catalog (name-sorted,
-   * globals plus its scoped shadows).
+   * globals plus its scoped shadows). Session-backed subagents reject with
+   * `agent-busy`.
    */
   list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ commands: readonly CommandDescriptor[] }>>
 
@@ -42,6 +43,7 @@ export interface CommandsApi {
    * pairing id, letting the issuing client correlate this acknowledgment
    * with that flow node. The signal rides beside the request, never on the
    * wire: the fetch carrier's request signal cancels the running handler.
+   * Session-backed subagents reject with `agent-busy` before dispatch.
    */
   execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
   Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>

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

@@ -22,7 +22,11 @@ export interface GoalRef {
   readonly revision: number
 }
 
-/** Goal-domain unary methods (every mutation resolves the session's agent and applies one CAS-guarded verb). */
+/**
+ * Goal-domain unary methods. Every mutation resolves an ordinary session's
+ * Agent and applies one CAS-guarded verb; session-backed subagents reject with
+ * `agent-busy`.
+ */
 export interface GoalsApi {
   /** Create and arm a goal. */
   create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>):

+ 20 - 7
packages/host/apiproxy/src/api/sessions.ts

@@ -219,17 +219,22 @@ export interface SessionsApi {
    * the client needs a fresh baseline already pulls the tail page, and
    * loadOlder (the only beforeSeq path) is the only path that never needs one.
    * A deployment without the registry serves histories without the block.
+   * Reading history uses an attached Session or persistence inspection and
+   * never resumes or publishes an Agent.
    */
   history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
   Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
 
-  /** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
+  /**
+   * Reads a fresh advisory model directory for an ordinary session. Provider
+   * lookups run independently; subagents reject with `agent-busy`.
+   */
   models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
 
   /**
    * Selects the complete target for this session. Exact model metadata
    * validates an optional reasoning effort, while catalog membership remains
-   * advisory.
+   * advisory. Session-backed subagents reject with `agent-busy`.
    */
   selectModel(request: RpcRequest<{
     sessionId: SessionId
@@ -245,6 +250,7 @@ export interface SessionsApi {
    * normalized accepted title and the title event's seq return so the caller
    * can settle its projection cell without waiting for the push frame. A
    * title that normalizes to empty fails with `title-invalid`.
+   * Session-backed subagents reject with `agent-busy`.
    */
   rename(request: RpcRequest<{ sessionId: SessionId; title: string }>):
   Promise<RpcResponse<{ title: string; seq: number }>>
@@ -265,23 +271,30 @@ export interface SessionsApi {
    * falls back to the source's last completed turn. An in-log anchor whose
    * turn is still open fails with `fork-unavailable` instead of clipping to
    * an earlier turn. The child inherits the source cwd, latest logged model
-   * target, workspace attachment, and `parentSessionId` lineage; the seed
-   * prefix carries the source title.
+   * target and `parentSessionId` lineage; the seed prefix carries the source
+   * title. Reading the source uses attached state or persistence inspection
+   * without acquiring an Agent. Workspace attachment follows the source
+   * directly when it belongs to one.
    */
   fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>):
   Promise<RpcResponse<{ sessionId: SessionId }>>
 
-  /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
+  /** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */
   prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
   Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
 
   /**
-   * Edits or removes one pending queued occurrence.
+   * Edits or removes one pending queued occurrence on an ordinary session.
+   * Session-backed subagents reject with `agent-busy`.
    */
   updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):
   Promise<RpcResponse<{ accepted: true }>>
 
-  /** Stops the active turn, preserving pending inbox work that resumes in FIFO order after cancellation settles. */
+  /**
+   * Stops an ordinary session's active turn, preserving pending inbox work
+   * that resumes in FIFO order after cancellation settles. Session-backed
+   * subagents reject with `agent-busy`.
+   */
   cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
 
 }

+ 178 - 11
packages/host/apiproxy/tests/api-proxy-cold.spec.ts

@@ -1,21 +1,19 @@
 /**
  * Cold-session and degenerate-composition paths of the host ApiProxy:
- * sessions.list merging persisted-but-unattached summaries (mtime source,
- * createdAt fallbacks, lineage projection), the resume error split when
- * the composition has no persistence gate and no agent factory, and the
- * agent-busy mapping of a synchronous prompt rejection.
+ * metadata-only listing, Agent-free history reads, subagent ownership
+ * isolation, and prompt failure mapping.
  */
 
 import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import SessionStore from '@deepseek-ai/dsh-session'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
-import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
@@ -115,8 +113,160 @@ describe('attached updatedAt excludes end-seed', () => {
   })
 })
 
+describe('subagent ownership fence', () => {
+  it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    const sessionId = sid('session-child')
+    const meta = header('session-child', 1000, {
+      parentSession: sid('session-parent'),
+      seedLength: 0,
+    })
+    const events = [
+      { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
+      {
+        type: 'user/message',
+        seq: 1,
+        time: 2,
+        data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } },
+        surfaceOp: 'append',
+      },
+      {
+        type: 'subagent/descriptor',
+        seq: 2,
+        time: 3,
+        data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
+      },
+      { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
+    ] as SessionEvent[]
+    const inspect = vi.fn(() => Promise.resolve({ meta, events }))
+    ctx.provide('sessionPersistence', {
+      list: () => Promise.resolve([meta]),
+      inspect,
+      locate: () => undefined,
+    } as never)
+    const resume = vi.spyOn(ctx.agents, 'resume')
+    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+    const history = await api.sessions.history(request({ sessionId }))
+    expect(history.result.ok).toBe(true)
+    if (history.result.ok) {
+      expect(history.result.value.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type))
+    }
+    expect(ctx.agents.get(sessionId)).toBeUndefined()
+
+    const prompt = await api.sessions.prompt(request({
+      sessionId,
+      mode: 'queue',
+      content: [{ type: 'text', text: 'follow up' }],
+    }))
+    expect(prompt.result.ok).toBe(false)
+    if (!prompt.result.ok) {
+      expect(prompt.result.error).toMatchObject({
+        code: 'agent-busy',
+        details: { reason: 'use subagent delivery for this child session' },
+      })
+    }
+
+    const create = await api.sessions.create(request({ sessionId, cwd: '/proj' }))
+    expect(create.result.ok).toBe(false)
+    if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy')
+    expect(resume).not.toHaveBeenCalled()
+    expect(ctx.agents.get(sessionId)).toBeUndefined()
+    expect(inspect).toHaveBeenCalledTimes(3)
+  })
+
+  it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
+    const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
+    ctx.agents.register(parent)
+
+    const originSession = ctx.sessions.create(sid('session-origin-child'), {
+      meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
+    })
+    const cancel = vi.fn()
+    const updateInbox = vi.fn(() => 'applied' as const)
+    const originChild = {
+      id: originSession.id,
+      session: originSession,
+      status: 'idle',
+      ctx,
+      cancel,
+      updateInbox,
+    } as unknown as Agent
+    ctx.agents.register(originChild)
+
+    const startingSession = ctx.sessions.create(sid('session-starting-child'), {
+      meta: { cwd: '/proj', parentSession: parent.id },
+    })
+    const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
+    ctx.agents.enter(startingChild, parent)
+    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+    const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
+    expect(stopped.result.ok).toBe(false)
+    if (!stopped.result.ok) expect(stopped.result.error.code).toBe('agent-busy')
+    expect(cancel).not.toHaveBeenCalled()
+
+    const queued = await api.sessions.updateQueue(request({
+      sessionId: originChild.id,
+      itemId: InboxItemId('queued-item'),
+      action: { kind: 'remove' },
+    }))
+    expect(queued.result.ok).toBe(false)
+    if (!queued.result.ok) expect(queued.result.error.code).toBe('agent-busy')
+    expect(updateInbox).not.toHaveBeenCalled()
+
+    const models = await api.sessions.models(request({ sessionId: startingChild.id }))
+    expect(models.result.ok).toBe(false)
+    if (!models.result.ok) expect(models.result.error.code).toBe('agent-busy')
+
+    const create = await api.sessions.create(request({ sessionId: originChild.id, cwd: '/proj' }))
+    expect(create.result.ok).toBe(false)
+    if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy')
+
+    const history = await api.sessions.history(request({ sessionId: originChild.id }))
+    expect(history.result.ok).toBe(true)
+    expect(ctx.agents.get(originChild.id)).toBe(originChild)
+  })
+
+  it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    const session = ctx.sessions.create(sid('session-ordinary-fork'), {
+      seed: [{
+        type: 'subagent/descriptor',
+        seq: 0,
+        time: 1,
+        data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' },
+      }],
+      meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 },
+    })
+    const followup = vi.fn()
+    const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
+    ctx.agents.register(agent)
+    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+    const response = await api.sessions.prompt(request({
+      sessionId: agent.id,
+      mode: 'queue',
+      content: [{ type: 'text', text: 'ordinary work' }],
+    }))
+    expect(response.result.ok).toBe(true)
+    expect(followup).toHaveBeenCalledOnce()
+  })
+})
+
 describe('degenerate composition (no persistence, no factory)', () => {
-  it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
+  it('list skips the cold merge and history reports missing persistence as internal', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await ctx.plugin(AgentRegistry)
@@ -127,15 +277,32 @@ describe('degenerate composition (no persistence, no factory)', () => {
     expect(listed.result.ok).toBe(true)
     if (listed.result.ok) expect(listed.result.value.items).toEqual([])
 
-    // No persistence → the servable gate passes silently; the factory-less
-    // registry then rejects resume, which is NOT a SessionNotFound.
+    // No persistence means cold history cannot inspect a transcript.
     const response = await api.sessions.history(request({ sessionId: sid('session-ghost') }))
     expect(response.result.ok).toBe(false)
     if (!response.result.ok) {
       expect(response.result.error.code).toBe('internal')
-      expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/)
+      expect(response.result.error.message).toMatch(/history unavailable for session "session-ghost"/)
     }
   })
+
+  it('maps a persistence catalog miss to session-not-found without inspection', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserInteractionService)
+    const inspect = vi.fn()
+    ctx.provide('sessionPersistence', {
+      list: () => Promise.resolve([]),
+      inspect,
+    } as never)
+    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+
+    const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
+    expect(response.result.ok).toBe(false)
+    if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
+    expect(inspect).not.toHaveBeenCalled()
+  })
 })
 
 describe('sessions.prompt synchronous rejection', () => {

+ 12 - 1
packages/host/apiproxy/tests/api-proxy-commands.spec.ts

@@ -99,6 +99,17 @@ describe('command.list', () => {
     expect(error.code).toBe('internal')
     expect(error.message).toContain('command registry')
   })
+
+  it('does not route a live subagent through the generic command domain', async () => {
+    const ctx = await harness()
+    const session = ctx.sessions.create(undefined, { meta: { cwd: '/proj', origin: 'subagent' } })
+    const agent = { id: session.id, session, status: 'idle', ctx } as Agent
+    ctx.agents.register(agent)
+    const api = createApiProxy(ctx, DEFAULTS)
+
+    const error = expectErr(await api.commands.list(request({ sessionId: agent.id })))
+    expect(error).toMatchObject({ code: 'agent-busy' })
+  })
 })
 
 describe('command.execute', () => {
@@ -142,7 +153,7 @@ describe('command.execute', () => {
     const api = createApiProxy(ctx, DEFAULTS)
     const missing = expectErr(await api.commands.execute(
       request({ sessionId: 'session-nope' as SessionId, line: '/x' }), new AbortController().signal))
-    expect(missing.code).toBe('internal') // no persistence configured: resume fails loud past the gate
+    expect(missing.code).toBe('internal') // Cold Agent-bound access fails loud when persistence is absent.
 
     const bare = await harness({ commands: false })
     const bareApi = createApiProxy(bare, DEFAULTS)

+ 45 - 2
packages/host/apiproxy/tests/api-proxy-fork.spec.ts

@@ -1,13 +1,13 @@
 /** Session-fork boundaries, lineage, and inherited model routing. */
 
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
 import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
-import type { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -94,6 +94,49 @@ describe('sessions.fork', () => {
     await ctx.fiber.dispose()
   })
 
+  it('forks a persisted subagent without resuming its Agent', async () => {
+    const ctx = await composed()
+    const sourceId = sid('session-cold-subagent')
+    const parentId = sid('session-cold-parent')
+    const header: SessionHeader = {
+      version: 0,
+      id: sourceId,
+      createdAt: 1,
+      cwd: '/proj',
+      parentSession: parentId,
+      origin: 'subagent',
+    }
+    const events = [
+      { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
+      {
+        type: 'user/message',
+        seq: 1,
+        time: 2,
+        data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
+        surfaceOp: 'append',
+      },
+      { type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
+    ] as SessionEvent[]
+    ctx.provide('sessionPersistence', {
+      list: () => Promise.resolve([header]),
+      inspect: () => Promise.resolve({ meta: header, events }),
+    } as never)
+    const resume = vi.spyOn(ctx.agents, 'resume')
+
+    const response = await api(ctx).sessions.fork(request({ sessionId: sourceId }))
+
+    expect(response.result.ok).toBe(true)
+    if (!response.result.ok) return
+    expect(resume).not.toHaveBeenCalled()
+    expect(ctx.agents.get(sourceId)).toBeUndefined()
+    expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
+      parentSession: sourceId,
+      cwd: '/proj',
+    })
+    expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
+    await ctx.fiber.dispose()
+  })
+
   it('uses the last completed turn only for omitted and past-end anchors', async () => {
     const ctx = await composed()
     const source = liveAgent(ctx, 'session-tail', 2, 'open')

+ 38 - 11
packages/host/apiproxy/tests/api-proxy-projections.spec.ts

@@ -1,20 +1,16 @@
 /**
- * Projection carrier paths of the host ApiProxy: the history tail page's
- * projections block reads the registry's watermark snapshot (asOfSeq = last
- * event seq, one consistent cut); loadOlder pages never carry the block; a
- * composition without the registry serves histories without it; a disposed
- * registration's key leaves subsequent responses; and every unit change is
- * pushed to mux consumers as a session/projection frame minted here.
+ * Projection carrier paths of the host ApiProxy: history tail pages snapshot
+ * attached state or fold one cold inspected prefix, loadOlder omits the block,
+ * and live unit changes push session/projection frames.
  */
 
 import { describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import { z } from 'zod'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
-import type { Agent } from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
-import type { Session } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -53,9 +49,6 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
   await ctx.plugin(AgentRegistry)
   if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
   const session = ctx.sessions.create()
-  // history resolves the agent first; a live structural stub is enough (only
-  // .session is read on this path).
-  ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
   return { ctx, session }
 }
 
@@ -87,6 +80,40 @@ describe('session.history projections block', () => {
     expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
   })
 
+  it('folds a cold inspected prefix without publishing an Agent', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(UserInteractionService)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(SessionProjectionRegistry)
+    ctx.sessionProjections.register(lastUserUnit())
+    const sessionId = SessionId('session-cold-history')
+    const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/tmp' }
+    const events = [{
+      type: 'user/message',
+      seq: 0,
+      time: 2,
+      data: createUserMessage({
+        content: [{ type: 'text', text: 'persisted' }],
+        source: { kind: 'user' },
+      }),
+      surfaceOp: 'append',
+    }] as SessionEvent[]
+    ctx.provide('sessionPersistence', {
+      list: () => Promise.resolve([meta]),
+      inspect: () => Promise.resolve({ meta, events }),
+    } as never)
+
+    const response = await api(ctx).sessions.history(request({ sessionId }))
+    expect(response.result.ok).toBe(true)
+    if (!response.result.ok) throw new Error('unreachable')
+    expect(response.result.value.projections).toEqual({
+      asOfSeq: 0,
+      values: { 'test/last-user': { text: 'persisted' } },
+    })
+    expect(ctx.agents.get(sessionId)).toBeUndefined()
+  })
+
   it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
     const { ctx, session } = await harness(true)
     ctx.sessionProjections.register(lastUserUnit())

Някои файлове не бяха показани, защото твърде много файлове са промени