Browse Source

fix(subagent): inherit parent policy overrides in continuable children

A continuable background child (the default backgroundMode for both
delegation tools) never received the parent session's explicit
sandbox/approval overrides: materialization applied only child
composition, so a danger-full-access parent produced workspace-write
children whose every out-of-workspace operation raised an approval
prompt.

Move the one-shot driver's capture/append pair into the shared
child-agent module (captureDelegatedPolicyOverrides /
appendDelegatedPolicyOverrides) and call it from both paths:
startContinuable captures before its first await, only fresh
materialization appends the source-tagged events (after any fork seed),
and a cold resume replays the persisted delegation events instead of
re-capturing the parent.

Adds the continuable inheritance unit suite, the ACP snapshot scenario
subagent-continuable-inheritance (fails without the fix), the
continuable policy-inheritance Agent Note, and the seam-level README
contract, with bilingual counterparts.

Fixes #1692
Hypatia May 1 month ago
parent
commit
64e0fbfd6d
36 changed files with 998 additions and 61 deletions
  1. 2 2
      .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml
  2. 2 2
      .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md
  3. 2 2
      .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml
  5. 29 0
      .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md
  6. 29 0
      .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md
  7. 2 2
      docs/event-producer-consumer.i18n.yaml
  8. 4 4
      docs/event-producer-consumer.md
  9. 4 4
      docs/event-producer-consumer.zh.md
  10. 2 2
      docs/subsystems/subagent.i18n.yaml
  11. 5 5
      docs/subsystems/subagent.md
  12. 5 5
      docs/subsystems/subagent.zh.md
  13. 46 0
      examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml
  14. 11 0
      examples/acp-agent/subagent-continuable-inheritance.cordis.yml
  15. 15 0
      examples/acp-agent/tests/acp.snapshot.ts
  16. 19 0
      examples/acp-agent/tests/fixtures/parent-sandbox-override.ts
  17. 19 0
      examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json
  18. 21 0
      examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl
  19. 29 0
      examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl
  20. 4 0
      examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl
  21. 435 0
      examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json
  22. 1 0
      knip.json
  23. 2 2
      packages/subagent/subagent-inprocess/README.i18n.yaml
  24. 1 1
      packages/subagent/subagent-inprocess/README.md
  25. 1 1
      packages/subagent/subagent-inprocess/README.zh.md
  26. 4 16
      packages/subagent/subagent-inprocess/src/index.ts
  27. 2 2
      packages/subagent/subagent/README.i18n.yaml
  28. 4 0
      packages/subagent/subagent/README.md
  29. 4 0
      packages/subagent/subagent/README.zh.md
  30. 15 0
      packages/subagent/subagent/package.json
  31. 56 5
      packages/subagent/subagent/src/child-agent.ts
  32. 25 5
      packages/subagent/subagent/src/continuation.ts
  33. 3 1
      packages/subagent/subagent/src/index.ts
  34. 171 0
      packages/subagent/subagent/tests/continuation-inheritance.spec.ts
  35. 9 0
      packages/subagent/subagent/tsconfig.json
  36. 9 0
      pnpm-lock.yaml

+ 2 - 2
.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.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-25-subagent-policy-inheritance.md
-2026-07-25-subagent-policy-inheritance.md: aeff83795eedead9c75de6bbb74c1da1945092ca
-2026-07-25-subagent-policy-inheritance.zh.md: c26e6bf8b79c86855022c384673957fe04ff761d
+2026-07-25-subagent-policy-inheritance.md: a2f4d578de857ee63df5e7741c433210d5f1ef86
+2026-07-25-subagent-policy-inheritance.zh.md: f069bf290586447afc0b7d46a41ad4de9bfcbe8f

+ 2 - 2
.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md

@@ -10,7 +10,7 @@ Sandbox and approval overrides are per-session log folds. An in-process subagent
 
 ## Decision
 
-The shared in-process driver snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants.
+The delegation boundary snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await, through the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`), which the one-shot driver and the [continuable start](2026-08-10-continuable-subagent-policy-inheritance.md) both call. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants.
 
 Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` at the fork-prefix length, so the inherited facts follow fork history, reach telemetry when the child is announced, and leave `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism.
 
@@ -33,4 +33,4 @@ A confined child gets the ordinary denial marker. No answerer currently owns an
 
 - Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal.
 - The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed.
-- Each delegation adds at most two log-only events. `dsh-subagent-inprocess` has optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches.
+- Each delegation adds at most two log-only events. `dsh-subagent` and `dsh-subagent-inprocess` have optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches.

+ 2 - 2
.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-共享的进程内驱动器在第一次 await 之前对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。
+委派边界在第一次 await 之前,经由共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`)对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照;一次性驱动器与[可继续启动](2026-08-10-continuable-subagent-policy-inheritance.md)都会调用这些辅助函数。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。
 
 每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已将 `Session.firstLiveSeq` 固定为 fork 前缀的长度,因此继承事实会排在 fork 历史之后,在子 agent 公布时进入遥测,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。
 
@@ -33,4 +33,4 @@ Status: implemented
 
 - spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。
 - 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。
-- 每次委派最多增加两条仅日志事件。`dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。
+- 每次委派最多增加两条仅日志事件。`dsh-subagent` 和 `dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md
+2026-08-10-continuable-subagent-policy-inheritance.md: 39df910a920e6995ba6048fdd2613d2c216f5ec2
+2026-08-10-continuable-subagent-policy-inheritance.zh.md: 2a977eaa9aade189213fdd20a7d888e8e62efb48

+ 29 - 0
.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md

@@ -0,0 +1,29 @@
+# Agent Note: Continuable subagent policy inheritance — the durable child log owns the delegation-time snapshot
+
+Status: implemented
+
+English | [中文](2026-08-10-continuable-subagent-policy-inheritance.zh.md)
+
+## Problem
+
+The one-shot in-process driver has seeded parent sandbox/approval overrides into its children since the [in-process policy-inheritance decision](2026-07-25-subagent-policy-inheritance.md), but the continuable path never did: `SubagentContinuationManager` materialization applied only child composition and the activation setup registry. The default bundle wires both delegation tools as `backgroundMode: continuable`, so in a default deployment every background child silently fell back to deployment defaults — a parent switched to `danger-full-access` produced children stuck at `workspace-write` whose every out-of-workspace operation raised an approval prompt, and a parent's unattended `'never'` approval stance reverted to prompting ([dsh-external/issues#334](https://github.com/dsh-external/issues/issues/334)).
+
+## Decision
+
+The capture/append pair moved from the one-shot driver into the seam's shared child-agent module (`dsh-subagent/src/child-agent.ts`), the declared one home for shared child composition: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` through optional `ctx.get`, and `appendDelegatedPolicyOverrides(childSession, overrides)` appends the `source: 'delegation'` events. The one-shot driver and the continuation manager both call them, so the two paths cannot drift.
+
+`startContinuable` captures before its first await (`prepareContinuable`), the same "a later parent switch belongs to the parent's future" boundary as one-shot. The snapshot travels in `MaterializeInputs.create`, so only fresh materialization appends the events during unpublished setup, after any fork seed. A cold resume passes no `create` inputs and appends nothing: the persisted child log already carries the delegation events, and replaying the log IS the state. The durable child log — not the current Activation, not the resuming parent — owns the child's effective policy, so a parent switch between residency epochs never retroactively changes a durable child.
+
+## Alternatives considered
+
+- **An activation-setup-registry contribution** (`registerContinuableSetup`) — rejected: a contribution receives only the child context, so it cannot capture the parent's overrides at the delegation boundary; the registry applies on cold resume as well as fresh creation, which would re-append or re-capture; and nothing ties a contribution's capture to the start call's synchronous prefix, so the pre-await capture guarantee would be lost.
+- **Re-capturing the parent's overrides at cold resume** — rejected: a resumed child would silently change policy with the parent's later switches, breaking the snapshot-at-delegation semantic and making effective policy depend on resume timing instead of the child's own log. A parent that wants a resumed child under new policy re-delegates.
+- **Importing the one-shot driver's inline logic from the continuation manager** — rejected: the Service Definition package cannot depend on its own provider package, and duplicating the capture/append pair in `continuation.ts` invites drift; `child-agent.ts` already holds every other shared composition step.
+- **Seeding the events into the descriptor seed turn** — rejected: the capture value is not known when the seed is assembled for every caller, and the one-shot precedent already establishes unpublished-setup appends as the ordering that places inherited facts after fork history with `firstLiveSeq` intact.
+
+## Consequences
+
+- Default-bundle background delegation (`backgroundMode: continuable`) now inherits a parent's explicit sandbox and approval overrides; compositions without either policy service behave unchanged.
+- `dsh-subagent` gains optional peer types on `dsh-sandbox-policy` and `dsh-user-approval` (the `ctx.get` pattern the one-shot driver used); `dsh-subagent-inprocess` keeps its optional peers but delegates to the shared helpers.
+- The continuable suite (`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`) pins fresh-start seeding, pre-await capture, default omission, cold-resume snapshot stability, and fork-seed precedence; the ACP snapshot scenario `subagent-continuable-inheritance` pins the child's delegation event and read-only runtime context through the assembled app and fails when the capture is removed.
+- Out-of-process providers (`acp`, `dsh-sdk`, `claude-code`, `codex`) support no continuable children (`prepareContinuable` absent), and their one-shot children keep their own deployment policy (`inheritsParentContext = false`); cross-process policy propagation remains out of scope.

+ 29 - 0
.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 可继续 subagent 策略继承——持久化子日志拥有委派时快照
+
+Status: implemented
+
+[English](2026-08-10-continuable-subagent-policy-inheritance.md) | 中文
+
+## 问题
+
+自[进程内策略继承决策](2026-07-25-subagent-policy-inheritance.md)以来,一次性进程内驱动器一直会把父级的沙箱/审批覆盖项注入其子级,但可继续路径从未这样做:`SubagentContinuationManager` 的物化只应用子级组合与 Activation(激活)设置注册表。默认组合包把两个委派工具都配置为 `backgroundMode: continuable`,因此在默认部署中,每个后台子 agent(智能体)都静默回退到部署默认值:切换到 `danger-full-access` 的父级产出的子 agent 卡在 `workspace-write`,每次工作区外操作都会触发审批提示;父级无人值守的 `'never'` 审批立场也退回为发起提示的行为([dsh-external/issues#334](https://github.com/dsh-external/issues/issues/334))。
+
+## 决策
+
+捕获/追加这对函数从一次性驱动器移入该 seam 的共享子 agent 模块(`dsh-subagent/src/child-agent.ts`),即声明的共享子级组合唯一归属之处:`captureDelegatedPolicyOverrides(parent)` 通过可选的 `ctx.get` 对 `sandboxPolicy.overrideOf(parent.session)` 与 `approval.overrideOf(parent.session)` 建立快照,`appendDelegatedPolicyOverrides(childSession, overrides)` 则追加 `source: 'delegation'` 事件。一次性驱动器与继续执行管理器都调用它们,因此两条路径不会出现偏差。
+
+`startContinuable` 在其第一次 await(`prepareContinuable`)之前完成捕获,沿用与一次性路径相同的「父级后续切换属于父级的未来」边界。快照放在 `MaterializeInputs.create` 中传递,因此只有全新物化会在未发布的设置阶段、排在任何 fork 种子之后追加这些事件。冷恢复(cold resume)不传入 `create` 输入,也不追加任何内容:持久化的子日志已经携带委派事件,而回放该日志本身就是状态。子 agent 的生效策略由持久化子日志拥有,而不是当前 Activation,也不是发起恢复的父级,因此父级在驻留纪元(residency epoch)之间的切换绝不会追溯性地改变一个持久化子 agent。
+
+## 考虑过的替代方案
+
+- **一项 Activation 设置注册表贡献**(`registerContinuableSetup`):不予采纳。贡献只接收子级上下文,因此无法在委派边界捕获父级的覆盖项;该注册表在冷恢复与全新创建时都会应用,会导致重复追加或重复捕获;而且没有任何机制把贡献的捕获绑定到 start 调用的同步前缀,await 前捕获的保证会因此丢失。
+- **在冷恢复时重新捕获父级覆盖项**:不予采纳。恢复的子 agent 会随父级后续切换静默改变策略,这会破坏委派时快照的语义,并让生效策略取决于恢复时机而非子级自身的日志。希望恢复的子 agent 采用新策略的父级应重新委派。
+- **让继续执行管理器导入一次性驱动器的内联逻辑**:不予采纳。Service Definition 包不能依赖自己的提供方包,而在 `continuation.ts` 中复制捕获/追加这对函数会招致偏差;`child-agent.ts` 已经承载其余每个共享组合步骤。
+- **把这些事件写入描述符种子轮次**:不予采纳。种子为每个调用方组装时,捕获值尚不可知;而且一次性路径的先例已经确立:在未发布的设置阶段追加,才是把继承事实排在 fork 历史之后、同时保持 `firstLiveSeq` 不变的顺序。
+
+## 后果
+
+- 默认组合包的后台委派(`backgroundMode: continuable`)现在会继承父级显式的沙箱与审批覆盖项;未组合任一策略服务的组合保持原有行为。
+- `dsh-subagent` 新增针对 `dsh-sandbox-policy` 与 `dsh-user-approval` 的可选 peer 类型(即一次性驱动器所用的 `ctx.get` 模式);`dsh-subagent-inprocess` 保留自己的可选 peer,但委托给共享辅助函数。
+- 可继续测试套件(`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`)锁定全新启动的种子写入、await 前捕获、默认值省略、冷恢复快照稳定性与 fork 种子优先级;ACP 快照场景 `subagent-continuable-inheritance` 经组装后的应用锁定子级的委派事件与只读运行时上下文,移除捕获时即失败。
+- 进程外提供方(`acp`、`dsh-sdk`、`claude-code`、`codex`)不支持可继续子 agent(没有 `prepareContinuable`),其一次性子 agent 保留自身的部署策略(`inheritsParentContext = false`);跨进程策略传播仍不在范围内。

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 11eecf81a4eccadf2b97026154a78e4ed8a72164
-event-producer-consumer.zh.md: 2db5e596465b4adaf98c1b05692b61de3ced47b9
+event-producer-consumer.md: e238734189d6553f9008d958bafbf9556ee23bff
+event-producer-consumer.zh.md: b74a1ed334919fd6db1187d7f0e07e2b6ddc5221

+ 4 - 4
docs/event-producer-consumer.md

@@ -37,10 +37,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |

+ 4 - 4
docs/event-producer-consumer.zh.md

@@ -39,10 +39,10 @@
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:155`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |

+ 2 - 2
docs/subsystems/subagent.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
-subagent.md: 99c54e0696c9c3585c50285ecb69b14c90d83c11
-subagent.zh.md: c5a79247a81f5174662f2b5d1ed2d3c20cf6c672
+subagent.md: b554bb20180b8784e38ac14fb83769eb354b53ae
+subagent.zh.md: 14e2b9b3e571c97384ccf560ce23edbeda62a305

+ 5 - 5
docs/subsystems/subagent.md

@@ -613,7 +613,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
 
 Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md)
 
-Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:169`](../../packages/subagent/subagent/src/index.ts)
 
 <a id="subagent-events"></a>
 
@@ -639,7 +639,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare
 
 Types: [Scoped](scope.md)
 
-Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:164`](../../packages/subagent/subagent/src/index.ts)
 
 <a id="subagentprovider-added--emit"></a>
 
@@ -656,7 +656,7 @@ A provider became resolvable in the registry.
 'subagent/provider-added'(provider: SubagentProvider): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts)
 
 <a id="subagentprovider-removed--emit"></a>
 
@@ -673,7 +673,7 @@ A provider left the registry. Accepted runs remain holder-owned.
 'subagent/provider-removed'(name: string): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts)
 
 <a id="subagentstart--emit"></a>
 
@@ -697,5 +697,5 @@ A provider established a published child. For in-process providers, `ctx.agents.
 
 Types: [Scoped](scope.md)
 
-Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 5 - 5
docs/subsystems/subagent.zh.md

@@ -615,7 +615,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
 
 Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md)
 
-Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:169`](../../packages/subagent/subagent/src/index.ts)
 
 <a id="subagent-events"></a>
 
@@ -641,7 +641,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare
 
 Types: [Scoped](scope.md)
 
-Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:164`](../../packages/subagent/subagent/src/index.ts)
 
 <a id="subagentprovider-added--emit"></a>
 
@@ -658,7 +658,7 @@ A provider became resolvable in the registry.
 'subagent/provider-added'(provider: SubagentProvider): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts)
 
 <a id="subagentprovider-removed--emit"></a>
 
@@ -675,7 +675,7 @@ A provider left the registry. Accepted runs remain holder-owned.
 'subagent/provider-removed'(name: string): void
 ```
 
-Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:144`](../../packages/subagent/subagent/src/index.ts)
 
 <a id="subagentstart--emit"></a>
 
@@ -699,5 +699,5 @@ A provider established a published child. For in-process providers, `ctx.agents.
 
 Types: [Scoped](scope.md)
 
-Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts)
+Source: [`packages/subagent/subagent/src/index.ts:155`](../../packages/subagent/subagent/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 46 - 0
examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml

@@ -0,0 +1,46 @@
+# Keyless counterpart to subagent-continuable-inheritance.cordis.yml: replace
+# the live adapter with replay and switch the root session to read-only at
+# creation.
+- id: base
+  name: '@cordisjs/plugin-include'
+  config:
+    path: ./cordis.yml
+    patches:
+      - id: llm-deepseek
+        name: '@deepseek-ai/dsh-llm-deepseek'
+        disabled: true
+      - id: acp-agent
+        name: '@deepseek-ai/dsh-acp-demo'
+        config:
+          provider: deepseek-official
+          model: deepseek-v4-flash
+          persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
+          persistenceCompression: none
+          workspaceContext:
+            maxBytes: 65536
+          persona: |
+            You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
+
+            Verify your work by running the code or tests. Keep answers brief and factual.
+      - id: sandbox
+        name: '@deepseek-ai/dsh-sandbox-local'
+        config:
+          runnerCommand:
+            - bash
+            - -c
+            - while [ "$1" != "--" ]; do shift; done; shift; exec "$@"
+            - passthrough-runner
+          runnerFailureSignatures:
+            - 'passthrough-runner: profile rejected'
+      - insert:
+          - id: llm-replay
+            name: '@deepseek-ai/dsh-llm-replay'
+            config:
+              providers:
+                - id: deepseek-official
+                  name: DeepSeek
+                  models:
+                    - id: deepseek-v4-flash
+                    - id: deepseek-v4-pro
+          - id: parent-sandbox-override
+            name: './tests/fixtures/parent-sandbox-override.ts'

+ 11 - 0
examples/acp-agent/subagent-continuable-inheritance.cordis.yml

@@ -0,0 +1,11 @@
+# Policy-inheritance overlay: the root session is switched to read-only at
+# creation (the UI Access switch equivalent), so a continuable background
+# child must inherit that override instead of the deployment default.
+- id: base
+  name: '@cordisjs/plugin-include'
+  config:
+    path: ./cordis.yml
+    patches:
+      - insert:
+          - id: parent-sandbox-override
+            name: './tests/fixtures/parent-sandbox-override.ts'

+ 15 - 0
examples/acp-agent/tests/acp.snapshot.ts

@@ -47,6 +47,9 @@ const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml'
 const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath(
   new URL('../subagent-durability-failure.cordis.yml', import.meta.url),
 )
+const SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG = fileURLToPath(
+  new URL('../subagent-continuable-inheritance.cordis.yml', import.meta.url),
+)
 const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url))
 const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url))
 const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url))
@@ -315,6 +318,18 @@ const SCENARIOS: Scenario[] = [
     pinsChildToolSchemas: [1],
     configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG,
   },
+  // Authored policy-inheritance transcript: the root session is switched to
+  // read-only at creation (the UI Access switch equivalent), and the
+  // continuable background child's log carries that override as a
+  // `sandbox/mode` `source: 'delegation'` event, so the child's runtime
+  // context states the inherited policy instead of the deployment default.
+  {
+    name: 'subagent-continuable-inheritance',
+    hasModelTurn: true,
+    recorded: false,
+    pinsChildToolSchemas: [1],
+    configPath: SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG,
+  },
   // The in-process child is published before its first follow-up fails. The
   // foreground tool retains both that run-result failure and an independent
   // published-handle disposal failure.

+ 19 - 0
examples/acp-agent/tests/fixtures/parent-sandbox-override.ts

@@ -0,0 +1,19 @@
+import type { Context } from 'cordis'
+import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import type {} from '@deepseek-ai/dsh-agent'
+
+export const name = 'parent-sandbox-override'
+
+/**
+ * Snapshot-only overlay: switch each ROOT session to `read-only` at creation —
+ * the UI "Access" switch equivalent (one runtime `sandbox/mode` event on the
+ * session log) — so the scenario proves a continuable background child
+ * inherits the parent's explicit override as a `source: 'delegation'` event
+ * instead of falling back to the deployment default.
+ */
+export function apply(ctx: Context): void {
+  ctx.on('agent/created', ({ agent }) => {
+    if (agent.session.header.parentSession !== undefined) return
+    setSandboxMode(agent.session, 'read-only')
+  })
+}

+ 19 - 0
examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json

@@ -0,0 +1,19 @@
+{
+ "steps": [
+  {
+   "op": "initialize"
+  },
+  {
+   "op": "newSession"
+  },
+  {
+   "op": "prompt",
+   "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."
+  },
+  {
+   "op": "waitForSubagentTurnEnd",
+   "child": 1,
+   "minimumTurn": 1
+  }
+ ]
+}

+ 21 - 0
examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl

@@ -0,0 +1,21 @@
+{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1}
+{"type":"subagent/descriptor","seq":0,"time":1786333735890,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}}
+{"type":"session/end-seed","seq":1,"time":1786333735890,"data":{}}
+{"type":"sandbox/mode","seq":2,"time":1786333735890,"data":{"mode":"read-only","source":"delegation"}}
+{"type":"agent/inbox/spliced","seq":3,"time":1786333735891,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"}]}}
+{"type":"turn/start","seq":4,"time":1786333735891,"data":{"turn":1}}
+{"type":"agent/inbox/spliced","seq":5,"time":1786333735891,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","seq":6,"time":1786333735916,"data":{"turn":1,"step":1}}
+{"type":"user/message","seq":7,"time":1786333735916,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"},"surfaceOp":"append"}
+{"type":"user/message","seq":8,"time":1786333735916,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"5f181e93-3fd7-40f3-b5f7-21674b53d7c7"},"surfaceOp":"append"}
+{"type":"session/title","seq":9,"time":1786333735916,"data":{"title":"Reply with exactly the word","messageSeqs":[7],"source":{"kind":"fallback"}}}
+{"type":"request/header","seq":10,"time":1786333735916,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":11,"time":1786333735916,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"assistant/chunk","seq":12,"time":1786333735920,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":13,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}}
+{"type":"assistant/chunk","seq":14,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}}
+{"type":"assistant/chunk","seq":15,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":16,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":17,"time":1786333735921,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e2b94008-b067-4ca7-a576-6b4a9060cd83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"}
+{"type":"step/end","seq":18,"time":1786333735921,"data":{"turn":1,"step":1}}
+{"type":"turn/end","seq":19,"time":1786333735921,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 29 - 0
examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl

@@ -0,0 +1,29 @@
+{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"sandbox/mode","seq":0,"time":1786333735842,"data":{"mode":"read-only"}}
+{"type":"agent/inbox/spliced","seq":1,"time":1786333735845,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"d554122c-d857-4de0-aea0-6452f260d032"}]}}
+{"type":"turn/start","seq":2,"time":1786333735845,"data":{"turn":1}}
+{"type":"agent/inbox/spliced","seq":3,"time":1786333735845,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","seq":4,"time":1786333735878,"data":{"turn":1,"step":1}}
+{"type":"user/message","seq":5,"time":1786333735878,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"d554122c-d857-4de0-aea0-6452f260d032"},"surfaceOp":"append"}
+{"type":"user/message","seq":6,"time":1786333735878,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9a793bfc-c155-44f8-ba56-c6843338d6be"},"surfaceOp":"append"}
+{"type":"session/title","seq":7,"time":1786333735878,"data":{"title":"Follow these steps exactly, then","messageSeqs":[5],"source":{"kind":"fallback"}}}
+{"type":"request/header","seq":8,"time":1786333735879,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":9,"time":1786333735879,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
+{"type":"assistant/chunk","seq":10,"time":1789000000000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"assistant/chunk","seq":11,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}
+{"type":"assistant/chunk","seq":12,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}}
+{"type":"assistant/chunk","seq":13,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":14,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":15,"time":1786333735884,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8ab58a42-e74c-4121-a6ca-63696e592287"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}
+{"type":"tool/call","seq":16,"time":1786333735885,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}
+{"type":"tool/result","seq":17,"time":1786333735892,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"3478555e-f0d0-4ec1-a7e4-a15ab24b9ecf"}},"sourceEventSeqs":[16],"surfaceOp":"append"}
+{"type":"step/end","seq":18,"time":1786333735892,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":19,"time":1786333735897,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":20,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":21,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}
+{"type":"assistant/chunk","seq":22,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
+{"type":"assistant/chunk","seq":23,"time":1786333735904,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
+{"type":"assistant/chunk","seq":24,"time":1786333735904,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":25,"time":1786333735904,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4057a08e-b50e-45e7-beb0-c74485f2b7d6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"}
+{"type":"step/end","seq":26,"time":1786333735904,"data":{"turn":1,"step":2}}
+{"type":"turn/end","seq":27,"time":1786333735904,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 4 - 0
examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl

@@ -0,0 +1,4 @@
+{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
+{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
+{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

File diff suppressed because it is too large
+ 435 - 0
examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json


+ 1 - 0
knip.json

@@ -48,6 +48,7 @@
         "headless-agent/tests/fixtures/e2b/e2b/bin.ts",
         "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts",
         "acp-agent/tests/fixtures/child-question-tripwire.ts",
+        "acp-agent/tests/fixtures/parent-sandbox-override.ts",
         "acp-agent/tests/fixtures/partial-landlock-sandbox.ts",
         "acp-agent/tests/fixtures/subagent-durability-failure.ts",
         "acp-agent/tests/fixtures/subagent-settlement-marker.ts",

+ 2 - 2
packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md
-README.md: 67f0cf5dd1ecb18542af56953a0eaa40988aca0d
-README.zh.md: 648a160be5f1c3dcbe66a867a273a3df610dbc0a
+README.md: 4189979806ccd4e7dcfee231ab3e9e2331550b0d
+README.zh.md: c6a9005cbfdb9d40a54383f921671fa22a31dc32

+ 1 - 1
packages/subagent/subagent-inprocess/README.md

@@ -20,7 +20,7 @@ The child gets the parent's working-directory/session lineage and inherits the p
 
 This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output.
 
-When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
+The driver applies the seam's [delegated policy inheritance](../subagent/README.md#delegated-policy-inheritance) through the shared child-agent helpers: it captures the parent's explicit sandbox/approval overrides before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md).
 
 ## Cancellation and ownership
 

+ 1 - 1
packages/subagent/subagent-inprocess/README.zh.md

@@ -20,7 +20,7 @@
 
 该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。
 
-当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
+驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略继承](../subagent/README.md#delegated-policy-inheritance):它会在创建子 agent 前捕获父级的显式沙箱/审批覆盖项,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。
 
 ## 取消与所有权
 

+ 4 - 16
packages/subagent/subagent-inprocess/src/index.ts

@@ -17,8 +17,10 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
 import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
 import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm'
 import {
+  appendDelegatedPolicyOverrides,
   applyChildComposition,
   assertSubagentMaxDepth,
+  captureDelegatedPolicyOverrides,
   childSessionMeta,
   resolveChildAgentOptions,
   resolveChildDepth,
@@ -30,11 +32,6 @@ import type {
   SubagentRun,
   SubagentStopReason,
 } from '@deepseek-ai/dsh-subagent'
-// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
-// to the policy services when composed — the driver consumes both
-// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
-import type {} from '@deepseek-ai/dsh-sandbox-policy'
-import type {} from '@deepseek-ai/dsh-user-approval'
 import {
   attachStructuredRuntime,
   type StructuredAttachment,
@@ -111,20 +108,11 @@ export async function startInProcessRun(
 
   // Capture before the first await: a later parent switch belongs to the
   // parent's future.
-  const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session)
-  const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session)
+  const inherited = captureDelegatedPolicyOverrides(parent)
 
   let structured: StructuredAttachment | undefined
   const setup = (childCtx: Context): void => {
-    // Inherited overrides land on the child's own log, so its effective policy
-    // is reconstructable from that log alone.
-    const childSession = (childCtx.agent as Agent).session
-    if (inheritedMode !== undefined) {
-      childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' })
-    }
-    if (inheritedPolicy !== undefined) {
-      childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' })
-    }
+    appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited)
     applyChildComposition(childCtx, {
       persona: request.persona,
       toolFilter: request.toolFilter,

+ 2 - 2
packages/subagent/subagent/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/subagent/subagent/README.md
-README.md: 762030629c09305c48adebc71244655a5faa6585
-README.zh.md: 535cc25895e04e82b6667e6d2769f2dcbfa49cff
+README.md: 6cea175de3d07290b293ad55ccbe60d7d918d37c
+README.zh.md: c5fecd554357146d317b5f75824f40a1cb976f2c

+ 4 - 0
packages/subagent/subagent/README.md

@@ -52,6 +52,10 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th
 
 `inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority.
 
+## Delegated policy inheritance
+
+Both in-process delegation paths seed the parent's explicit policy overrides into the child through the shared child-agent helpers: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf()` and `approval.overrideOf()` synchronously at the delegation boundary (both services are optional `ctx.get` consumers), and `appendDelegatedPolicyOverrides()` writes each captured value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state, a later child switch wins the snapshot, and the child's effective policy stays reconstructable from its log alone. Deployment defaults are never copied: an unswitched parent stamps nothing and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) policy-inheritance Agent Notes.
+
 ## One-shot ownership and lifecycle
 
 `provider.start(request): Promise<SubagentRun>` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`.

+ 4 - 0
packages/subagent/subagent/README.zh.md

@@ -52,6 +52,10 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
 
 `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。
 
+## 委派策略继承
+
+两条进程内委派路径都会通过共享的子 agent 辅助函数,把父级的显式策略覆盖项作为种子注入子 agent:`captureDelegatedPolicyOverrides(parent)` 在委派边界同步对 `sandboxPolicy.overrideOf()` 与 `approval.overrideOf()` 获取快照(这两个服务都是可选的 `ctx.get` 消费方),`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个捕获值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,子 agent 后续的切换压过该快照,而子 agent 的生效策略始终可以仅凭其日志重建。部署默认值绝不复制:未切换的父级不会记录任何值,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇策略继承 Agent Note。
+
 ## 一次性所有权与生命周期
 
 `provider.start(request): Promise<SubagentRun>` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。

+ 15 - 0
packages/subagent/subagent/package.json

@@ -37,6 +37,8 @@
     "@deepseek-ai/dsh-brand": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-sandbox": "^0.0.1",
+    "@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
     "@deepseek-ai/dsh-scope": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
     "@deepseek-ai/dsh-session-persistence": "^0.0.1",
@@ -44,9 +46,16 @@
     "@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
     "@deepseek-ai/dsh-tasks": "^0.0.1",
     "@deepseek-ai/dsh-tools": "^0.0.1",
+    "@deepseek-ai/dsh-user-approval": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },
   "peerDependenciesMeta": {
+    "@deepseek-ai/dsh-sandbox": {
+      "optional": true
+    },
+    "@deepseek-ai/dsh-sandbox-policy": {
+      "optional": true
+    },
     "@deepseek-ai/dsh-session-persistence": {
       "optional": true
     },
@@ -58,6 +67,9 @@
     },
     "@deepseek-ai/dsh-tasks": {
       "optional": true
+    },
+    "@deepseek-ai/dsh-user-approval": {
+      "optional": true
     }
   },
   "devDependencies": {
@@ -65,6 +77,8 @@
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-sandbox": "workspace:^",
+    "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
@@ -74,6 +88,7 @@
     "@deepseek-ai/dsh-storage-domain": "workspace:^",
     "@deepseek-ai/dsh-tasks": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-user-approval": "workspace:^",
     "cordis": "^4.0.0-rc.7"
   }
 }

+ 56 - 5
packages/subagent/subagent/src/child-agent.ts

@@ -1,17 +1,23 @@
 /**
  * Shared in-process child composition: the delegation-depth budget, the
- * durable session metadata, the resolved child `AgentOptions`, and the scoped
- * setup a child agent needs. Both the one-shot provider driver and the
- * continuation manager compose children this way, so depth accounting and
- * lineage stamping have one home.
+ * durable session metadata, the resolved child `AgentOptions`, the delegated
+ * policy snapshot, and the scoped setup a child agent needs. Both the one-shot
+ * provider driver and the continuation manager compose children this way, so
+ * depth accounting, lineage stamping, and policy inheritance have one home.
  *
  * @module @deepseek-ai/dsh-subagent/child-agent
  */
 
 import type { Context } from 'cordis'
 import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
-import type { SessionId } from '@deepseek-ai/dsh-session'
+import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
+import type { Session, SessionId } from '@deepseek-ai/dsh-session'
 import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
+import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
+// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve
+// to the policy services when composed — delegation consumes both
+// opportunistically (the documented `ctx.get` pattern), never as a hard dep.
+import type {} from '@deepseek-ai/dsh-sandbox-policy'
 import { delegationDepthOf } from './depth.ts'
 
 /** Thrown when starting a child would exceed the requested depth cap. */
@@ -119,6 +125,51 @@ export function applyChildComposition(childCtx: Context, composition: ChildCompo
   if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter)
 }
 
+/** Parent-session policy overrides captured at the delegation boundary. */
+export interface DelegatedPolicyOverrides {
+  /** The parent session's explicit sandbox-mode override, or `undefined` without one. */
+  readonly sandboxMode: SandboxMode | undefined
+  /** The parent session's explicit approval-policy override, or `undefined` without one. */
+  readonly approvalPolicy: ApprovalPolicy | undefined
+}
+
+/**
+ * Capture the parent session's explicit policy overrides for one delegation.
+ * Call synchronously before the child start's first await: a later parent
+ * switch belongs to the parent's future, not to this child. Deployment
+ * defaults and one-shot grants are never captured, so an unswitched parent
+ * leaves the child following the deployment default dynamically.
+ * @param parent - the delegating parent agent.
+ * @returns the overrides to seed into the child, each `undefined` without one.
+ */
+export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides {
+  return {
+    sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session),
+    approvalPolicy: parent.ctx.get('approval')?.overrideOf(parent.session),
+  }
+}
+
+/**
+ * Append captured parent overrides onto the child's own log as
+ * `source: 'delegation'` events inside the unpublished creation window, so the
+ * child's effective policy is reconstructable from its log alone. Appends land
+ * after any fork seed, so fresh policy wins stale seed state; later child
+ * switches still win over these events.
+ * @param childSession - the unpublished child's session.
+ * @param overrides - the overrides captured at delegation.
+ */
+export function appendDelegatedPolicyOverrides(
+  childSession: Session,
+  overrides: DelegatedPolicyOverrides,
+): void {
+  if (overrides.sandboxMode !== undefined) {
+    childSession.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' })
+  }
+  if (overrides.approvalPolicy !== undefined) {
+    childSession.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' })
+  }
+}
+
 /** Identity and lineage inputs shared by every in-process child creation. */
 export interface ChildCreateInputs {
   /** The child's reserved session id. */

+ 25 - 5
packages/subagent/subagent/src/continuation.ts

@@ -32,11 +32,14 @@ import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
 import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts'
 import type { SubagentDescriptorData } from './descriptor.ts'
 import {
+  appendDelegatedPolicyOverrides,
   applyChildComposition,
+  captureDelegatedPolicyOverrides,
   childSessionMeta,
   resolveChildAgentOptions,
   resolveChildDepth,
 } from './child-agent.ts'
+import type { DelegatedPolicyOverrides } from './child-agent.ts'
 import { assertSubagentMaxDepth } from './depth.ts'
 import { seedDescriptorTurn } from './descriptor-seed.ts'
 import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
@@ -203,8 +206,17 @@ interface MaterializeInputs {
   childId: SessionId
   provider: string
   parent: Agent
-  /** Creation inputs; absent for a cold resume, which loads the persisted session. */
-  create?: { seed: readonly SessionEvent[]; meta: NonNullable<CreateAgentOptions['meta']> }
+  /**
+   * Creation inputs; absent for a cold resume, which loads the persisted
+   * session — including the delegation policy events a fresh creation seeded,
+   * so a resume never re-captures the parent's policy.
+   */
+  create?: {
+    seed: readonly SessionEvent[]
+    meta: NonNullable<CreateAgentOptions['meta']>
+    /** Parent policy overrides captured at the delegation boundary. */
+    inheritedPolicies: DelegatedPolicyOverrides
+  }
   agentOptions: AgentOptions
   composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined }
   signal: AbortSignal
@@ -341,6 +353,9 @@ export class SubagentContinuationManager {
       ...request.persona !== undefined ? { persona: request.persona } : {},
       ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
     })
+    // Capture before the first await: a later parent switch belongs to the
+    // parent's future, not to this child.
+    const inheritedPolicies = captureDelegatedPolicyOverrides(parent)
 
     const prepared = await this.host.prepareContinuable(spec.provider, {
       sessionId: childId,
@@ -357,7 +372,7 @@ export class SubagentContinuationManager {
         childId,
         provider: spec.provider,
         parent,
-        create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength) },
+        create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), inheritedPolicies },
         agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
         composition: { persona: request.persona, toolFilter: request.toolFilter },
         signal: spec.signal,
@@ -878,18 +893,23 @@ export class SubagentContinuationManager {
     inputs: MaterializeInputs,
     parentLineage: readonly Agent[],
   ): Promise<Activation> {
-    const { childId, provider, parent } = inputs
+    const { childId, provider, parent, create } = inputs
     // No id pre-check here: the child lock serializes each durable child, both
     // callers reach this only after confirming no Activation exists, and
     // `AgentRegistry.enter()` is the authoritative collision boundary for an id
     // some other owner holds — a duplicate would reject there with rollback.
     inputs.signal.throwIfAborted()
     const setup = (childCtx: Context): AgentSetupCommit => {
+      // Only fresh creation seeds captured parent policy onto the child's own
+      // log (after any fork seed, so fresh policy wins stale seed state); a
+      // cold resume replays those persisted events instead.
+      if (create !== undefined) {
+        appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.inheritedPolicies)
+      }
       applyChildComposition(childCtx, inputs.composition)
       return this.setupRegistry.apply(childCtx)
     }
     const observer = this.host.observeActivation(provider, childId, parent)
-    const { create } = inputs
     // Agent creation owns rollback before handle transfer. A rejection leaves
     // no resident Activation and therefore publishes no lifecycle edge.
     const handle: AgentHandle = create === undefined

+ 3 - 1
packages/subagent/subagent/src/index.ts

@@ -100,13 +100,15 @@ export { SubagentError } from './error.ts'
 export { settleRun } from './run-settlement.ts'
 export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts'
 export {
+  appendDelegatedPolicyOverrides,
   applyChildComposition,
+  captureDelegatedPolicyOverrides,
   childSessionMeta,
   resolveChildAgentOptions,
   resolveChildDepth,
   SubagentDepthError,
 } from './child-agent.ts'
-export type { ChildComposition } from './child-agent.ts'
+export type { ChildComposition, DelegatedPolicyOverrides } from './child-agent.ts'
 export type {
   ContinuableStart,
   ContinuableStartSpec,

+ 171 - 0
packages/subagent/subagent/tests/continuation-inheritance.spec.ts

@@ -0,0 +1,171 @@
+/**
+ * Continuable-child policy inheritance: a fresh continuable start seeds the
+ * parent's explicit sandbox/approval overrides onto the child's own log as
+ * `source: 'delegation'` events, and a cold resume replays that persisted
+ * snapshot instead of re-capturing the parent (the one-shot
+ * `subagent-inprocess/tests/inheritance.spec.ts` counterpart).
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from 'cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
+import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
+import ApprovalService, { effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
+import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import SubagentService from '../src/index.ts'
+
+type Script = ConstructorParameters<typeof MockAdapter>[0]
+
+const roots: string[] = []
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+/** Boot the continuable stack plus both policy services the manager consumes opportunistically. */
+async function setup(script: Script) {
+  const ctx = new Context()
+  await mountAgentLoopTestDependencies(ctx)
+  const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-'))
+  roots.push(root)
+  await ctx.plugin(JsonlSessionPersistence, { root })
+  await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: root })
+  await ctx.plugin(ApprovalService)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(SubagentService)
+  await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
+  await ctx.plugin(SubagentFork, { providerName: 'fork' })
+  ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
+  const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
+  return { ctx, parent }
+}
+
+function startSpec(parent: Agent, provider = 'spawn') {
+  return {
+    provider,
+    label: 'child task',
+    request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent },
+    signal: new AbortController().signal,
+  }
+}
+
+/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */
+async function waitNoActivation(ctx: Context, childId: SessionId): Promise<void> {
+  await vi.waitFor(() => {
+    expect(ctx.agents.get(childId)).toBeUndefined()
+  }, { timeout: 5_000 })
+}
+
+function policyEvents(events: readonly SessionEvent[]) {
+  return events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy')
+}
+
+describe('continuable policy inheritance', () => {
+  it('seeds parent overrides into a fresh continuable child', async () => {
+    const { ctx, parent } = await setup([textResponse('child done')])
+    setSandboxMode(parent.session, 'danger-full-access')
+    setApprovalPolicy(parent.session, 'never')
+    let child: Agent | undefined
+    ctx.on('agent/created', ({ agent }) => {
+      if (agent !== parent) child = agent
+    })
+
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    // The delegation events are appended in the creation window, so they are
+    // already the child's effective policy at inbox acceptance.
+    if (child === undefined) throw new Error('expected the continuable child to be created')
+    expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access')
+    expect(ctx.approval.overrideOf(child.session)).toBe('never')
+
+    await waitNoActivation(ctx, started.childId)
+    const loaded = await ctx.sessionPersistence.load(started.childId)
+    expect(policyEvents(loaded.events)).toMatchObject([
+      { type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } },
+      { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
+    ])
+    // Durable: a reload folds the same effective policy.
+    expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access')
+    expect(effectiveApprovalPolicy(loaded.events)).toBe('never')
+  })
+
+  it('captures policy at delegation before asynchronous child creation', async () => {
+    const { ctx, parent } = await setup([textResponse('child done')])
+    setSandboxMode(parent.session, 'read-only')
+
+    const starting = ctx.subagents.startContinuable(startSpec(parent))
+    // A parent switch after the synchronous capture belongs to the parent's
+    // future, not to this child.
+    setSandboxMode(parent.session, 'danger-full-access')
+    const started = await starting
+
+    await waitNoActivation(ctx, started.childId)
+    const loaded = await ctx.sessionPersistence.load(started.childId)
+    expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
+    expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
+  })
+
+  it('does not freeze deployment defaults into an unswitched child', async () => {
+    const { ctx, parent } = await setup([textResponse('child done')])
+
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await waitNoActivation(ctx, started.childId)
+
+    const loaded = await ctx.sessionPersistence.load(started.childId)
+    expect(policyEvents(loaded.events)).toEqual([])
+  })
+
+  it('cold-resumes on the persisted snapshot without re-capturing the parent', async () => {
+    const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')])
+    setSandboxMode(parent.session, 'read-only')
+    const started = await ctx.subagents.startContinuable(startSpec(parent))
+    await waitNoActivation(ctx, started.childId)
+
+    // The parent widens AFTER the child was created; the resumed child keeps
+    // the delegation-time snapshot from its own log.
+    setSandboxMode(parent.session, 'danger-full-access')
+    await ctx.subagents.followup(parent, started.childId, [{ type: 'text', text: 'continue please' }], {
+      source: { kind: 'user' },
+      signal: new AbortController().signal,
+    })
+    await waitNoActivation(ctx, started.childId)
+
+    const loaded = await ctx.sessionPersistence.load(started.childId)
+    expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
+      { data: { mode: 'read-only', source: 'delegation' } },
+    ])
+    expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
+  })
+
+  it('places inherited events after a fork prefix so fresh policy wins stale seed state', async () => {
+    const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')])
+    // The stale mode lands inside the completed turn the fork seed replays.
+    setSandboxMode(parent.session, 'workspace-write')
+    parent.followup(createUserMessage({
+      content: [{ type: 'text', text: 'parent work' }],
+      source: { kind: 'user' },
+    }))
+    await parent.whenIdle()
+    setSandboxMode(parent.session, 'read-only')
+
+    const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork'))
+    await waitNoActivation(ctx, started.childId)
+
+    const loaded = await ctx.sessionPersistence.load(started.childId)
+    expect(loaded.meta.seedLength).toBeGreaterThan(0)
+    expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
+      { data: { mode: 'workspace-write' } },
+      { data: { mode: 'read-only', source: 'delegation' } },
+    ])
+    expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
+  })
+})

+ 9 - 0
packages/subagent/subagent/tsconfig.json

@@ -26,6 +26,15 @@
     {
       "path": "../../core/scope"
     },
+    {
+      "path": "../../interaction/user-approval"
+    },
+    {
+      "path": "../../sandbox/sandbox"
+    },
+    {
+      "path": "../../sandbox/sandbox-policy"
+    },
     {
       "path": "../../session/session-persistence"
     },

+ 9 - 0
pnpm-lock.yaml

@@ -5887,6 +5887,12 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-sandbox':
+        specifier: workspace:^
+        version: link:../../sandbox/sandbox
+      '@deepseek-ai/dsh-sandbox-policy':
+        specifier: workspace:^
+        version: link:../../sandbox/sandbox-policy
       '@deepseek-ai/dsh-scope':
         specifier: workspace:^
         version: link:../../core/scope
@@ -5914,6 +5920,9 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
+      '@deepseek-ai/dsh-user-approval':
+        specifier: workspace:^
+        version: link:../../interaction/user-approval
       cordis:
         specifier: ^4.0.0-rc.7
         version: link:../../../vendor/cordis

Some files were not shown because too many files changed in this diff