Browse Source

Merge awaited Agent creation from master

Tianyi Cui 1 week ago
parent
commit
e543cc9a6e
100 changed files with 907 additions and 592 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml
  2. 9 11
      .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
  3. 9 11
      .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.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. 6 0
      .agents/notes/implemented/architecture/2026-09-09-awaited-agent-creation.i18n.yaml
  8. 31 0
      .agents/notes/implemented/architecture/2026-09-09-awaited-agent-creation.md
  9. 31 0
      .agents/notes/implemented/architecture/2026-09-09-awaited-agent-creation.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-06-30-interception-extension-points.i18n.yaml
  11. 1 1
      .agents/notes/implemented/feature/2026-06-30-interception-extension-points.md
  12. 1 1
      .agents/notes/implemented/feature/2026-06-30-interception-extension-points.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.i18n.yaml
  14. 1 1
      .agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md
  15. 1 1
      .agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md
  16. 2 2
      docs/architecture.i18n.yaml
  17. 2 0
      docs/architecture.md
  18. 2 0
      docs/architecture.zh.md
  19. 2 2
      docs/config-catalog.i18n.yaml
  20. 1 1
      docs/config-catalog.md
  21. 1 1
      docs/config-catalog.zh.md
  22. 2 2
      docs/cookbook/extension-cookbook.i18n.yaml
  23. 1 1
      docs/cookbook/extension-cookbook.md
  24. 1 1
      docs/cookbook/extension-cookbook.zh.md
  25. 2 2
      docs/event-producer-consumer.i18n.yaml
  26. 13 14
      docs/event-producer-consumer.md
  27. 14 15
      docs/event-producer-consumer.zh.md
  28. 2 2
      docs/glossary.i18n.yaml
  29. 1 1
      docs/glossary.md
  30. 1 1
      docs/glossary.zh.md
  31. 2 2
      docs/subsystems/core.i18n.yaml
  32. 25 43
      docs/subsystems/core.md
  33. 25 43
      docs/subsystems/core.zh.md
  34. 3 3
      packages/api/session-controller/tests/agent.host.spec.ts
  35. 1 1
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  36. 7 7
      packages/api/session-controller/tests/commands-upload-file.host.spec.ts
  37. 1 1
      packages/api/session-controller/tests/control-jobs.host.spec.ts
  38. 1 1
      packages/api/session-controller/tests/controller.host.spec.ts
  39. 10 10
      packages/api/session-controller/tests/session-cold.host.spec.ts
  40. 12 12
      packages/api/session-controller/tests/session-fork.host.spec.ts
  41. 5 5
      packages/api/session-controller/tests/session-list-blank.host.spec.ts
  42. 1 1
      packages/api/session-controller/tests/session-models.host.spec.ts
  43. 2 2
      packages/api/session-controller/tests/session-presets.host.spec.ts
  44. 9 9
      packages/api/session-controller/tests/session-rename.host.spec.ts
  45. 1 1
      packages/api/session-controller/tests/session-skills.host.spec.ts
  46. 1 1
      packages/bundle/headless/tests/headless.spec.ts
  47. 4 4
      packages/context/agent-instructions/tests/agent-instructions.spec.ts
  48. 2 2
      packages/context/file-reference-local/README.i18n.yaml
  49. 1 1
      packages/context/file-reference-local/README.md
  50. 1 1
      packages/context/file-reference-local/README.zh.md
  51. 5 3
      packages/context/file-reference-local/src/index.ts
  52. 25 6
      packages/context/file-reference-local/tests/service.spec.ts
  53. 2 2
      packages/core/agent-loop/README.i18n.yaml
  54. 1 1
      packages/core/agent-loop/README.md
  55. 1 1
      packages/core/agent-loop/README.zh.md
  56. 2 1
      packages/core/agent-loop/src/agent.ts
  57. 41 28
      packages/core/agent-loop/src/index.ts
  58. 24 0
      packages/core/agent-loop/tests/fixtures/serial-created.mjs
  59. 13 18
      packages/core/agent-loop/tests/interception.spec.ts
  60. 8 14
      packages/core/agent-loop/tests/resume.spec.ts
  61. 105 44
      packages/core/agent-loop/tests/scope-lifecycle.spec.ts
  62. 123 0
      packages/core/agent-loop/tests/serial-listener-review.spec.ts
  63. 2 2
      packages/core/agent/README.i18n.yaml
  64. 2 2
      packages/core/agent/README.md
  65. 2 2
      packages/core/agent/README.zh.md
  66. 24 28
      packages/core/agent/src/index.ts
  67. 11 21
      packages/core/agent/src/runtime-types.ts
  68. 49 20
      packages/core/agent/tests/agent.spec.ts
  69. 0 1
      packages/core/scope/src/scoped-events.generated.ts
  70. 1 2
      packages/core/scope/tests/invariant.spec.ts
  71. 1 1
      packages/experimental/agent-team/src/index.ts
  72. 12 19
      packages/extensions/tool-cordis/src/api-catalog.ts
  73. 1 1
      packages/feedback/command-feedback/tests/command-feedback.spec.ts
  74. 3 3
      packages/feedback/command-feedback/tests/loader-composition.spec.ts
  75. 1 1
      packages/fs/tool-present/tests/built-errors.e2e.ts
  76. 1 1
      packages/fs/tool-present/tests/present.spec.ts
  77. 3 3
      packages/fs/tool-str-replace-editor/tests/tools.spec.ts
  78. 1 1
      packages/goal/command-goal/tests/command-goal.spec.ts
  79. 1 2
      packages/goal/goal-round-driver/src/index.ts
  80. 1 1
      packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts
  81. 2 2
      packages/goal/goal/README.i18n.yaml
  82. 1 1
      packages/goal/goal/README.md
  83. 1 1
      packages/goal/goal/README.zh.md
  84. 1 1
      packages/goal/goal/src/index.ts
  85. 9 9
      packages/goal/goal/tests/goal.spec.ts
  86. 5 6
      packages/goal/goal/tests/projection.spec.ts
  87. 5 5
      packages/goal/tool-goal/tests/tool-goal.spec.ts
  88. 2 2
      packages/hooks/hooks-claude-code/README.i18n.yaml
  89. 1 1
      packages/hooks/hooks-claude-code/README.md
  90. 1 1
      packages/hooks/hooks-claude-code/README.zh.md
  91. 6 6
      packages/hooks/hooks-claude-code/src/index.ts
  92. 25 15
      packages/hooks/hooks-claude-code/tests/coverage-cases.ts
  93. 2 2
      packages/hooks/hooks-codex/README.i18n.yaml
  94. 1 1
      packages/hooks/hooks-codex/README.md
  95. 1 1
      packages/hooks/hooks-codex/README.zh.md
  96. 6 6
      packages/hooks/hooks-codex/src/index.ts
  97. 37 11
      packages/hooks/hooks-codex/tests/bridge.spec.ts
  98. 18 5
      packages/hooks/hooks-codex/tests/coverage-cases.ts
  99. 29 29
      packages/jobs/jobs-local/tests/jobs.spec.ts
  100. 27 27
      packages/jobs/tool-jobs/tests/tool-jobs.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.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-12-agent-scope-runtime-design.md
-2026-07-12-agent-scope-runtime-design.md: cd916ab1bf9f4bb7a02cb5c6a8458630b757e09d
-2026-07-12-agent-scope-runtime-design.zh.md: a0db51d7129d744b96946782a920e99b0cd7b518
+2026-07-12-agent-scope-runtime-design.md: b9b2c837f560c2b8b98da37e3656caf298b705e2
+2026-07-12-agent-scope-runtime-design.zh.md: 4b94d1b8449bd619c47f6bee0712ec8ed0b39593

+ 9 - 11
.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md

@@ -119,30 +119,28 @@ Publication admits and announces resources in the order required by observers:
 1. Enter the session.
 2. Enter the agent.
 3. Announce `session/created`.
-4. Announce `agent/created`.
-5. Enable public driving.
-6. Emit `agent/session-start`.
-7. Start the driver.
+4. Await serial `agent/created` listeners.
+5. Release queued input to the driver.
 
-The agent never drives before both registries and creation notifications agree. A synchronous listener may veto or dispose an owner; the transaction records publication in progress and waits for that callback stack to unwind before teardown continues. Every creation announcement that begins has a matching disposal announcement during rollback.
+The agent never drives before both registries and creation listeners finish. A listener may reject or dispose an owner; the transaction retains the scope and session until dispatch settles before teardown continues. Every creation announcement that begins has a matching disposal announcement during rollback. The [awaited creation decision](2026-09-09-awaited-agent-creation.md) owns asynchronous initializer timing.
 
-The sequence diagram isolates the non-obvious race: a synchronous creation listener can request disposal while the publication call stack still owns both registry entries. Teardown must deactivate immediately but wait for that stack to unwind before stopping and detaching anything.
+A creation listener can request disposal while publication still owns both registry entries. Teardown deactivates immediately and waits for the awaited dispatch before stopping and detaching anything.
 
 ```mermaid
 sequenceDiagram
   participant Tx as AgentCreationTransaction
   participant Registries
-  participant Listener as Synchronous listener
+  participant Listener as Creation listener
   participant Driver
 
   Tx->>Tx: mark publication in progress
   Tx->>Registries: announce agent/created
-  Registries->>Listener: invoke inside the same call stack
+  Registries->>Listener: await listener
   Listener->>Tx: dispose reentrantly
   Tx->>Tx: deactivate, teardown waits for publication
   Tx-->>Listener: disposal request accepted
-  Listener-->>Registries: return
-  Registries-->>Tx: announcement unwound
+  Listener-->>Registries: settle
+  Registries-->>Tx: dispatch settled
   Tx->>Tx: resolve publication settlement
   Tx->>Driver: stop and drain
   Tx->>Registries: detach agent, then session
@@ -153,7 +151,7 @@ sequenceDiagram
 
 Every teardown request joins one memoized path. The order is:
 
-1. Deactivate creation or driving and let synchronous publication finish.
+1. Deactivate creation or driving and await creation dispatch.
 2. Stop and drain the driver, discarding any injection that remains pending.
 3. Detach the agent.
 4. Detach the session.

+ 9 - 11
.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md

@@ -119,30 +119,28 @@ Setup 接收完整的子上下文和确切的未发布 Agent,可以等待插
 1. 将会话写入注册表。
 2. 将 agent 写入注册表。
 3. 宣告 `session/created`。
-4. 宣告 `agent/created`。
-5. 启用公开驱动。
-6. 发射 `agent/session-start`。
-7. 启动 driver。
+4. 等待串行 `agent/created` 监听器。
+5. 向驱动器释放已排队输入。
 
-Agent 在两个注册表和创建通知都达成一致之前绝不驱动。同步监听器可以否决或 dispose 一个所有者;事务记录发布进行中,并等待该回调栈展开后再继续拆除。每个已开始的创建宣告在回滚期间都有匹配的销毁宣告。
+Agent 在两个注册表与创建监听器都完成前绝不驱动。监听器可以拒绝或 dispose 一个所有者;事务保留作用域与会话,等待分发结算后再继续拆除。每个已开始的创建宣告在回滚期间都有匹配的销毁宣告。[可等待创建决策](2026-09-09-awaited-agent-creation.zh.md)拥有异步初始化器时序。
 
-以下序列图隔离了非显而易见的竞态:同步创建监听器可以在发布调用栈仍拥有两个注册表条目时请求 dispose。拆除必须立即停用,但要等待该栈展开后才停止和分离任何东西
+创建监听器可以在发布仍拥有两个注册表条目时请求 dispose。Teardown 会立即停用,并等待所调用的异步分发完成后才停止和分离资源
 
 ```mermaid
 sequenceDiagram
   participant Tx as AgentCreationTransaction
   participant Registries
-  participant Listener as Synchronous listener
+  participant Listener as Creation listener
   participant Driver
 
   Tx->>Tx: mark publication in progress
   Tx->>Registries: announce agent/created
-  Registries->>Listener: invoke inside the same call stack
+  Registries->>Listener: await listener
   Listener->>Tx: dispose reentrantly
   Tx->>Tx: deactivate, teardown waits for publication
   Tx-->>Listener: disposal request accepted
-  Listener-->>Registries: return
-  Registries-->>Tx: announcement unwound
+  Listener-->>Registries: settle
+  Registries-->>Tx: dispatch settled
   Tx->>Tx: resolve publication settlement
   Tx->>Driver: stop and drain
   Tx->>Registries: detach agent, then session
@@ -153,7 +151,7 @@ sequenceDiagram
 
 每个拆除请求加入一条记忆化路径。顺序为:
 
-1. 停用创建或驱动,让同步发布完成
+1. 停用创建或驱动,并等待创建分发
 2. 停止并排空 driver,丢弃仍处于待处理状态的注入。
 3. 分离 agent。
 4. 分离会话。

+ 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: 76f8904f75f6c4b5a1a6acab8d69ef2290be718e
-2026-07-30-session-end-seed-log-boundary.zh.md: 4310654c841305a7f0de2f46434d0f839085c482
+2026-07-30-session-end-seed-log-boundary.md: 9cf7c7f20701688208e8b1c343904f2a9f2cab9e
+2026-07-30-session-end-seed-log-boundary.zh.md: fdf4ec9de610f17e792de98334dd5d37f5e4c355

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

@@ -8,7 +8,7 @@ English | [中文](2026-07-30-session-end-seed-log-boundary.zh.md)
 
 A plugin that owns a standalone open/close bracket in the session log cannot tell a dead marker from a live one. `compaction/start` … `compaction/end` is the shipped case: on picking up a log whose last compaction event is an unmatched `compaction/start`, "the previous writer died mid-compaction" and "a compaction is running right now" are byte-identical stored history. The owner must either refuse to compact a log that is actually free (wedging the session) or proceed over one that is genuinely busy.
 
-Nothing in the log marked where inherited history ended. `session/created`, `session/disposed`, and `session/flush` are cordis runtime signals, not log events; `agent/session-start` is emit-only. `Session.firstLiveSeq` already held the answer exactly — the seq of this lifecycle's first own write — but only in memory, so a consumer reading stored bytes could not see it.
+Nothing in the log marked where inherited history ended. `session/created`, `session/disposed`, and `session/flush` are cordis runtime signals, not log events; `agent/created` is also a non-durable runtime event. `Session.firstLiveSeq` already held the answer exactly — the seq of this lifecycle's first own write — but only in memory, so a consumer reading stored bytes could not see it.
 
 Crash repair does not close the gap and must not: `interruptedTurnClosers` synthesizes turn, step, and tool boundaries because core owns that vocabulary, and `compaction/*` belongs to the compaction seam. A core repair pass that closed plugin brackets would put every plugin's bracket semantics in core.
 

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

@@ -8,7 +8,7 @@ Status: implemented
 
 在会话日志中拥有独立开/闭括号的插件无法区分一个已死的标记和一个存活的标记。`compaction/start` … `compaction/end` 就是已发布的实例:当接手一份日志、而它最后的压缩(compaction)事件是一个未配对的 `compaction/start` 时,「上一个写入方在压缩中途死掉了」与「此刻正有一次压缩在运行」在存储历史中是逐字节相同的。该括号所有方只能二选一:拒绝压缩一份其实空闲的日志(把会话卡死),或者在一份确实繁忙的日志上继续压缩。
 
-日志中没有任何东西标出继承历史在哪里结束。`session/created`、`session/disposed` 与 `session/flush` 是 Cordis 运行时信号,不是日志事件;`agent/session-start` 只发射不落盘。`Session.firstLiveSeq` 本来就精确地持有这个答案——本生命周期第一次自有写入的 seq——但只存在于内存中,因此读取存储字节的消费方看不到它。
+日志中没有任何东西标出继承历史在哪里结束。`session/created`、`session/disposed` 与 `session/flush` 是 Cordis 运行时信号,不是日志事件;`agent/created` 同样是不落盘的运行时事件。`Session.firstLiveSeq` 本来就精确地持有这个答案——本生命周期第一次自有写入的 seq——但只存在于内存中,因此读取存储字节的消费方看不到它。
 
 崩溃修复既没有填上这个缺口,也不应该去填:`interruptedTurnClosers` 合成轮次、步骤与工具边界,是因为核心拥有那套词汇表,而 `compaction/*` 属于压缩 seam。一个会关闭插件括号的核心修复流程,等于把每个插件的括号语义都搬进核心。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-awaited-agent-creation.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/architecture/2026-09-09-awaited-agent-creation.md
+2026-09-09-awaited-agent-creation.md: 27353225a8d99d038d5c61b1eea0fa67d9120a55
+2026-09-09-awaited-agent-creation.zh.md: f664e89da0794acd1d2a6c0c8a4820a7056dcac6

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-09-awaited-agent-creation.md

@@ -0,0 +1,31 @@
+# Agent Note: Awaited per-agent initialization
+
+Status: implemented
+
+English | [中文](2026-09-09-awaited-agent-creation.zh.md)
+
+## Problem
+
+Shared presets install tools and prompt sections separately for each Agent. That installation can await plugin activation, and external SessionStart hooks can produce context asynchronously. A creator must know that these contributions have finished before the Agent's first model request.
+
+## Decision
+
+`agent/created` is the serial initialization event after factory setup and registry entry. Each listener finishes before the next starts; a throw or rejection fails creation and skips later listeners. The payload retains `SessionStartSource` and accepts the factory's cancellation signal. `register()` and `announce()` are awaited by their callers. Lifecycle source selection belongs to factory publication through `announce()`; `register()` announces fresh startup.
+
+AgentLoop holds its existing maintenance activity through setup and creation dispatch. Input may enter the inbox during initialization, but the driver starts only after successful completion. Failure cancels that activity without waking queued input; ordered teardown owns inbox cleanup. Keeping these operations separate preserves the initialization error when another teardown has already removed the inbox projection.
+
+Creation dispatch retains the scope and Session while listeners await. Disposal cancels initialization and joins the dispatch before releasing those resources. A listener must not await its own Agent's idle state or its owner's disposal, because each waits for that listener to finish. Background work on another Agent follows that Agent's own initialization lifecycle.
+
+This decision owns asynchronous creation timing. The [scope runtime decision](2026-07-12-agent-scope-runtime-design.md) retains registry identity and teardown ownership, while the [interception decision](../feature/2026-06-30-interception-extension-points.md) retains policy and tool-event semantics.
+
+## Alternatives considered
+
+**A separate setup event.** Existing creation listeners already install per-agent contributions. A second initialization event splits that responsibility without a distinct consumer need.
+
+**Detached initialization.** Returning before plugin activation or hook context settles lets the first request omit required tools or context and disconnects initialization failure from the creator.
+
+## Consequences
+
+Creation latency includes asynchronous plugin and SessionStart work. Initializer failures become caller-visible creation failures, and cancellation relies on listeners settling cooperatively. Notifications already delivered cannot be undone; rollback pairs them with disposal notifications.
+
+Scoped installer tests verify rollback, lifecycle tests verify ordered completion and cancellation, and the SDK serial-created scenario verifies that asynchronous prompt context reaches the first request. Hook tests cover awaited context injection and subprocess disposal.

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-09-awaited-agent-creation.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 可等待的单个 agent 初始化
+
+Status: implemented
+
+[English](2026-09-09-awaited-agent-creation.md) | 中文
+
+## 问题
+
+共享 preset 为每个 Agent(智能体)分别安装工具与提示词段。安装可能要等待插件激活,外部 SessionStart 钩子也可能异步产生上下文。创建方必须知道这些贡献已完成,才能允许 Agent 发起首次模型请求。
+
+## 决策
+
+`agent/created` 是工厂 setup 完成且进入注册表后的串行初始化事件。每个监听器完成后才启动下一个;抛出或拒绝会使创建失败,并跳过后续监听器。载荷保留 `SessionStartSource`,并接受工厂的取消信号。调用方等待 `register()` 与 `announce()` 完成。生命周期来源由工厂发布时通过 `announce()` 指定;`register()` 宣告全新启动。
+
+AgentLoop 在 setup 与创建分发期间保留现有的维护活动。初始化期间输入可以进入收件箱,但只有成功完成后驱动器才会启动。失败会取消该活动而不唤醒已排队输入;有序 teardown 拥有收件箱清理。区分这两项操作,能够在另一条 teardown 已移除收件箱投影时保留初始化错误。
+
+创建分发在监听器等待期间保留作用域与 Session。Dispose(资源释放)会取消初始化,并在释放这些资源前等待分发结束。监听器不得等待自身 Agent 的空闲状态或自身所有者的 dispose,因为两者都要等待该监听器完成。另一个 Agent 上的后台工作遵循该 Agent 自己的初始化生命周期。
+
+本决策拥有异步创建时序。[作用域运行时决策](2026-07-12-agent-scope-runtime-design.zh.md)继续拥有注册表身份与 teardown 归属,[拦截决策](../feature/2026-06-30-interception-extension-points.zh.md)继续拥有策略与工具事件语义。
+
+## 考虑过的替代方案
+
+**单独的 setup 事件。** 现有创建监听器已经安装针对单个 agent 的贡献。第二个初始化事件会拆分这一职责,却没有独立的消费方需求。
+
+**分离运行的初始化。** 在插件激活或钩子上下文结算前返回,会让首次请求缺少所需工具或上下文,也会使初始化失败脱离创建方。
+
+## 后果
+
+创建延迟包括异步插件与 SessionStart 工作。初始化器失败成为调用方可见的创建失败,取消依赖监听器协作结算。已经送达的通知无法撤回;回滚会为其配对销毁通知。
+
+作用域安装器测试验证回滚,生命周期测试验证有序完成与取消,SDK serial-created 场景验证异步提示词上下文进入首次请求。钩子测试覆盖需等待的上下文注入与子进程 dispose。

+ 2 - 2
.agents/notes/implemented/feature/2026-06-30-interception-extension-points.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-06-30-interception-extension-points.md
-2026-06-30-interception-extension-points.md: d628cdce0bc6c13a90a25eeebcfd977602dd76e3
-2026-06-30-interception-extension-points.zh.md: 689d7f6ee6d5a552c2eaf950ce5930be559eb5bb
+2026-06-30-interception-extension-points.md: 5adffd1679c97cc69cf18991913113b84d88ba3c
+2026-06-30-interception-extension-points.zh.md: c3aa374a22d58c5185a3bd8e79b4040a04bd43e3

+ 1 - 1
.agents/notes/implemented/feature/2026-06-30-interception-extension-points.md

@@ -15,7 +15,7 @@ The surface needs distinct contracts for per-prompt policy (CC's `UserPromptSubm
 The canonical surface separates transformable policy, around-dispatch control, and observe-only notification. Policy waterfalls return small extension-point-specific **typed Decision unions**; wrappers return normalized results; notifications receive immutable snapshots and cannot affect the outcome. The set covers the hook points in scope (`session-start`, `prompt-submit`, `pre-tool`, `post-tool`, `stop`-via-continuation) while leaving non-hook execution policy independently composable.
 
 **Agent events** (`dsh-agent`):
-- `agent/session-start({ agent, source })` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`.
+- `agent/created({ agent, source, signal? })` — serial per-agent initialization before the first turn, carrying `SessionStartSource`. Listeners may install tools and seed context through `agent.inject()`; creation awaits them and rejects on failure. The [awaited creation decision](../architecture/2026-09-09-awaited-agent-creation.md) owns this timing and supersedes the observe-only startup policy.
 - `agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision` — waterfall, fired before every proposed step after the loop has atomically removed its exclusive inbox batch. The payload carries the request's `turn`, `step`, and cancellation `signal` (the retired `PreStepContext` fields live in the payload; see the [payload-object events decision](../../archived/architecture/2026-08-06-agent-event-payload-objects.md)); `messages` is empty for a tool continuation with no intervening input. `enter` returns the complete message batch, including any current-request context a listener contributes; `reject` opens no step and leaves the claimed messages removed.
 
 **`agent/turn-stopping`** is an awaited notification at the natural stop boundary. A listener that needs another step calls `agent.steer()` with explicitly sourced model-facing content; the loop then re-reads the outbox and either continues or closes the turn.

+ 1 - 1
.agents/notes/implemented/feature/2026-06-30-interception-extension-points.zh.md

@@ -15,7 +15,7 @@ harness 需要一套钩子子系统:用户像 Claude Code(CC)和 Codex 那
 规范接口将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall(瀑布式事件)返回小型的、扩展点专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start`、`prompt-submit`、`pre-tool`、`post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留作独立可组合。
 
 **Agent 事件**(`dsh-agent`):
-- `agent/session-start({ agent, source })` ——emit,在第 1 轮次之前触发一次,携带 `SessionStartSource`(`startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文
+- `agent/created({ agent, source, signal? })` ——首个轮次前针对单个 agent 的串行初始化,携带 `SessionStartSource`。监听器可以安装工具,并通过 `agent.inject()` 注入上下文;创建等待监听器完成,失败时拒绝。[可等待创建决策](../architecture/2026-09-09-awaited-agent-creation.zh.md)拥有这一时序,并取代只观察启动的策略
 - `agent/pre-step({ agent, messages, turn, step, signal }, next) → PreStepDecision` ——waterfall,在每个拟议步骤之前、循环原子移除其独占 inbox 批次后触发。payload 携带该请求的 `turn`、`step` 与取消 `signal`(已退役的 `PreStepContext` 字段位于 payload 中;参见 [payload-object 事件决策](../../archived/architecture/2026-08-06-agent-event-payload-objects.md));没有中途输入的工具续步会收到空批次。`enter` 返回完整消息批次,其中包括监听器为当前请求贡献的上下文;`reject` 不打开步骤,并让已领取消息保持已删除。
 
 **`agent/turn-stopping`** 是自然停止边界上的一次 awaited 通知。需要再执行一步的监听器调用 `agent.steer()`,传入来源显式的 steering(中途引导)内容供模型使用;循环随后重新读取 outbox,继续执行或关闭轮次。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.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-19-persisted-same-session-goal-domain.md
-2026-07-19-persisted-same-session-goal-domain.md: bc6c9ec14c16b20bc8434bac0f1eb6070ea8920d
-2026-07-19-persisted-same-session-goal-domain.zh.md: 30b9d22633f37dda971104f8d2d5be0d3c8c3163
+2026-07-19-persisted-same-session-goal-domain.md: 4ddc02c3bd3b7bbf04b3c6867a76673431309460
+2026-07-19-persisted-same-session-goal-domain.zh.md: 2bb937b0f637e54ab6d9a6e10ebe237849e08171

+ 1 - 1
.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.md

@@ -28,7 +28,7 @@ Incremental replay advances its cursor after each valid event and remains positi
 
 At most one goal is current. Create requires no current non-complete goal and always generates a revision-one id not used earlier in the session; a completed goal may be replaced. Every other mutation carries the expected `GoalRef`, and stale ids or revisions reject. Resume accepts a paused or blocked phase, or a disarmed active goal, only when the round cap has remaining capacity. The domain validates blocker reason shape but deliberately leaves reason codes and the decision to block to policy consumers.
 
-A cache built from any seed starts disarmed, and every `agent/session-start` edge disarms it again. `GoalService.disarm(agent)` also lets a lifecycle owner remove process-local authority without a session event, revision change, or `goal/changed` notification. Resume, fork, and continuation-driver replacement therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy API may explicitly call resume and arm the goal.
+A cache built from any seed starts disarmed, and every `agent/created` edge disarms it again. `GoalService.disarm(agent)` also lets a lifecycle owner remove process-local authority without a session event, revision change, or `goal/changed` notification. Resume, fork, and continuation-driver replacement therefore preserve the durable objective and history but never initiate work on their own. A later human prompt can be interpreted by the model, whose policy API may explicitly call resume and arm the goal.
 
 ### Service boundary
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-19-persisted-same-session-goal-domain.zh.md

@@ -28,7 +28,7 @@ Status: implemented
 
 最多只有一个当前目标。创建要求不存在未完成的当前目标,并始终生成该会话此前未使用过、修订号为一的 id;已完成目标可以被替换。其他每次变更都携带预期的 `GoalRef`,陈旧的 id 或修订号会被拒绝。仅当 Goal Round 上限仍有余量时,暂停或阻塞阶段以及已解除激活的活跃目标才能恢复。领域层校验阻塞原因的形状,但会把原因代码和是否阻塞的决策留给策略消费方。
 
-从任何种子构建的缓存都以未激活状态开始,每次 `agent/session-start` 边沿也会再次解除激活。`GoalService.disarm(agent)` 还允许生命周期所有者移除进程内权限,而不写入会话事件、不改变修订号,也不发出 `goal/changed` 通知。因此,会话恢复、fork 和继续执行驱动器替换都会保留持久化目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略 API 可以显式调用恢复操作并激活目标。
+从任何种子构建的缓存都以未激活状态开始,每次 `agent/created` 边沿也会再次解除激活。`GoalService.disarm(agent)` 还允许生命周期所有者移除进程内权限,而不写入会话事件、不改变修订号,也不发出 `goal/changed` 通知。因此,会话恢复、fork 和继续执行驱动器替换都会保留持久化目标与历史,但绝不会自行启动工作。后续人类提示词可由模型解释,其策略 API 可以显式调用恢复操作并激活目标。
 
 ### 服务边界
 

+ 2 - 2
docs/architecture.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/architecture.md
-architecture.md: 3addf70ab997590d97a2581ea0a459e6d4e86911
-architecture.zh.md: 078d99dc06cf49233d0ca4c036bab89a3f664d57
+architecture.md: efd726f9fcccc53c5bf927f4eadf20b6f5b36be8
+architecture.zh.md: 043eeecd1299e06ad7d728a09036f07dddca6825

+ 2 - 0
docs/architecture.md

@@ -75,6 +75,8 @@ Events are the extension points, and picking the right domain is the first decis
 - **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight.
 - **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop.
 
+AgentLoop awaits serial `agent/created` initialization before starting queued work. Initialization failure rolls back creation; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines teardown ordering.
+
 The [event map](event-producer-consumer.md) lists every event's producers and consumers.
 
 ## Turn flow

+ 2 - 0
docs/architecture.zh.md

@@ -77,6 +77,8 @@ Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该
 - **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。
 - **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。
 
+AgentLoop 在启动已排队工作前等待串行 `agent/created` 初始化。初始化失败会回滚创建;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义 teardown 顺序。
+
 [事件映射](event-producer-consumer.zh.md)列出每个事件的生产方与消费方。
 
 <a id="turn-flow"></a>

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: ad1d7d977e0cea23c76256fca626988d51429fb6
-config-catalog.zh.md: 4a3d1692355c0a6ff7306a929fd87050f61ed06b
+config-catalog.md: d13611e7fd2131f8d6fda4fe79ed09397e1c8fe3
+config-catalog.zh.md: ccd14d9b486568188f3e5985c0f8d19a60a612b0

+ 1 - 1
docs/config-catalog.md

@@ -111,7 +111,7 @@ export interface Config {
 
 Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md)
 
-Source: [`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:317`](../packages/core/agent-loop/src/index.ts)
 
 <a id="deepseek-aidsh-agent-presets"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -113,7 +113,7 @@ export interface Config {
 
 依赖:[`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md)
 
-来源:[`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts)
+来源:[`packages/core/agent-loop/src/index.ts:317`](../packages/core/agent-loop/src/index.ts)
 
 <a id="deepseek-aidsh-agent-presets"></a>
 

+ 2 - 2
docs/cookbook/extension-cookbook.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/cookbook/extension-cookbook.md
-extension-cookbook.md: 40d91dbe698a2f2801503a5278c885d6b7a39ed5
-extension-cookbook.zh.md: 149817dec36c5a625e417e9cc1e2879a012682d6
+extension-cookbook.md: b54f0191b48c109b21e111cac27020efcb37174a
+extension-cookbook.zh.md: c03e92b21e156a82d51d2afd742f1dd880e32a47

+ 1 - 1
docs/cookbook/extension-cookbook.md

@@ -103,7 +103,7 @@ Every product feature maps to a listener on a documented extension point — the
 
 | Product feature | Plugin mechanism |
 |---|---|
-| Hook system (user + project level) | listeners on `agent/session-start`, `agent/pre-step`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfalls return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude-code` / `dsh-hooks-codex` bridges map hook config files onto these extension points |
+| Hook system (user + project level) | listeners on `agent/created`, `agent/pre-step`, `agent/request`, `tools/pre-execute`, `tools/post-execute`, and `agent/turn-stopping`; the waterfalls return typed decisions, while `agent/turn-stopping` may steer another step; the `dsh-hooks-claude-code` / `dsh-hooks-codex` bridges map hook config files onto these extension points |
 | `/goal` | `ctx.goals` owns durable state, `dsh-goal-round-driver` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control |
 | `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue |
 | Dynamic workflow | `ctx.workflowEngine` + the PTC workflow engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and the structured-output execution's monotonic `concludeTurn()` marker |

+ 1 - 1
docs/cookbook/extension-cookbook.zh.md

@@ -107,7 +107,7 @@ export function apply(ctx: Context) {
 
 | 产品功能 | 插件机制 |
 |---|---|
-| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/pre-step`、`agent/request`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall 返回类型化决策,`agent/turn-stopping` 则可通过 steering(中途引导)触发下一步;`dsh-hooks-claude-code` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些扩展点上 |
+| 钩子系统(用户级 + 项目级) | `agent/created`、`agent/pre-step`、`agent/request`、`tools/pre-execute`、`tools/post-execute` 和 `agent/turn-stopping` 上的监听器;waterfall 返回类型化决策,`agent/turn-stopping` 则可通过 steering(中途引导)触发下一步;`dsh-hooks-claude-code` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些扩展点上 |
 | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-round-driver` 通过公共 `Agent` 调度同会话 Round,独立的命令/工具生产方分别提供人类/模型控制 |
 | `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 |
 | 动态工作流 | `ctx.workflowEngine` + PTC 工作流引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的提示词/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和结构化输出执行的单调 `concludeTurn()` 标记来强制输出 |

+ 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: b727570dff92aba1bfa0cbb1fba86f9ffe9d9bff
-event-producer-consumer.zh.md: 8fc9c6263e133b4f1fd8703167d44ee530f8b079
+event-producer-consumer.md: f2f4795cf9187adc72ce98d35a452930cba57f35
+event-producer-consumer.zh.md: ed8766bfb45088a7160f51d07f0d6a4fbe2e85a9

+ 13 - 14
docs/event-producer-consumer.md

@@ -7,21 +7,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
-| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
+| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:245`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:403`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
+| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |

+ 14 - 15
docs/event-producer-consumer.zh.md

@@ -7,23 +7,22 @@
 
 本矩阵展示哪些包会派发各个 harness 自有事件,以及哪些包会监听这些事件。事件之间存在多对多关系,因此密集的关系数据以表格而非一张大型关系图呈现。接收方和事件名称类型还涵盖有意绕过 `ctx.emit` 的内含派发位置,例如 subagent 生命周期封装。
 
-| 事件 | 模式 | 声明位置 | 派发方 | 监听方 |
+| Event | Mode | Declared in | Dispatchers | Listeners |
 | --- | --- | --- | --- | --- |
-| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
+| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:245`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
 | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
-| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
-| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:403`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
-| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
-| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
-| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:347`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
-| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
-| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
-| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
+| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
+| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
+| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
+| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) |
+| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
+| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
+| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
+| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
+| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:601`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:608`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |

+ 2 - 2
docs/glossary.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/glossary.md
-glossary.md: 891234c487eea37e6f6beb4779c47054dd011168
-glossary.zh.md: d8fef23873d4c0098b774cf846c8fec156c2d269
+glossary.md: 1a9ac39a73526833be3cdc191d67f519bdf8fc90
+glossary.zh.md: cd41876d41148082ddc18a08c49cf2abba882c79

+ 1 - 1
docs/glossary.md

@@ -17,7 +17,7 @@ Domain vocabulary for DeepSeek Harness uses one canonical term per concept. Term
 - **scoped dispatch** — the rule: an event about one agent's activity dispatches with that agent's carrier. Events about a registry itself (a tool was added) are *registry-subject* and stay unfiltered.
 - **shadowing** — most-specific-wins name resolution: a scoped tool/section/variable replaces its same-named global twin for that scope alone. The per-agent persona and per-agent tool-variant mechanism.
 - **restriction / scope-local registration** — a restriction (`tools.restrict`) filters the GLOBAL tool set for one scope (compose by intersection); scope-local registrations are merged after that filter. A filtered-away global tool is absent from the prompt AND refuses execution, indistinguishably from a nonexistent one.
-- **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope and agent object exist but before the agent or session is published, `agent/session-start` fires, or the first prompt is assembled. Setup registers; it never drives the agent.
+- **setup window** — the creation slot where a creator composes an agent's scoped world (`CreateAgentOptions.setup`): after the scope and agent object exist but before the agent or session is published, `agent/created` fires, or the first prompt is assembled. Setup registers; it never drives the agent.
 - **lineage** — parent/child facts carried as data (`parentSession`, durable `delegationDepth`, runtime `subagentDepth`); never affects visibility. <a id="lineage"></a>
 
 ## goal

+ 1 - 1
docs/glossary.zh.md

@@ -17,7 +17,7 @@ DeepSeek Harness 的领域词汇为每个概念规定一个规范术语。各术
 - **scoped dispatch**:规则是:关于某个 agent 的活动的事件以该 agent 的 carrier 进行分发。关于注册表本身的事件(如「一个工具被添加了」)属于*注册表主体*事件,保持不过滤。
 - **shadowing**:最具体者胜出的名称解析:一个带作用域的工具/片段/变量仅在该 scope 内替换同名的全局对应项。这是按 agent 定制 persona 和按 agent 定制工具变体的机制。
 - **restriction / scope-local 注册**:restriction(`tools.restrict`)为单个 scope 过滤全局工具集合(多个 restriction 取交集组合);scope-local 注册在过滤之后合并。被过滤掉的全局工具既不出现在提示词中,也拒绝执行,与不存在的工具无法区分。
-- **setup window**:创建者组装 agent 作用域环境的创建时隙(`CreateAgentOptions.setup`):此时 scope 和 agent 对象已存在,但 agent 或会话尚未发布,`agent/session-start` 尚未触发,首次提示词尚未组装。setup 只做注册,从不驱动 agent。
+- **setup window**:创建者组装 agent 作用域环境的创建时隙(`CreateAgentOptions.setup`):此时 scope 和 agent 对象已存在,但 agent 或会话尚未发布,`agent/created` 尚未触发,首次提示词尚未组装。setup 只做注册,从不驱动 agent。
 - **lineage**:以数据形式携带的父子关系事实(`parentSession`、持久的 `delegationDepth`、运行时 `subagentDepth`);从不影响可见性。<a id="lineage"></a>
 
 ## 目标

+ 2 - 2
docs/subsystems/core.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/core.md
-core.md: deb4743df146983857555016b5bdbe8d2ce29b42
-core.zh.md: f18a00eb02aac23ec07e80b4a9e3ea2234d7e576
+core.md: e5a155854a0a5ecf7b663087bf45f78aeb79297d
+core.zh.md: 24aebd77f54e2b2c0c94e2a044d51b2b14e83f6f

+ 25 - 43
docs/subsystems/core.md

@@ -336,7 +336,7 @@ type RequestErrorAction = { kind: 'retry' } | undefined
 
 `agent/pre-step` is the only waterfall listener chain before request derivation. `agent/turn-stopping` runs when a turn has no tool or steering continuation, before one final steering drain.
 
-`agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it):
+`agent/created` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it):
 
 ```ts type-equiv
 /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
@@ -818,16 +818,16 @@ async create(options: CreateAgentOptions): Promise<AgentHandle>
 async resume(options: ResumeAgentOptions): Promise<AgentHandle>
 
 /**
- * Register a live agent. Throws if an agent with the same id is already
- * registered. Emits `agent/created` on registration and `agent/disposed`
+ * Register a live agent with source `startup`. Rejects if the id is already registered or a
+ * serial `agent/created` listener fails. Emits `agent/disposed`
  * when the calling fiber is disposed — both with the agent's scope carrier
  * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the
  * emits are scope-filtered regardless of which context invoked `register`
  * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always
  * requires passing the carrier). The entry is a runtime root; factory-backed
- * creation uses `options.parentAgent` for child ownership. Returns the disposer.
+ * creation uses `options.parentAgent` for child ownership. Await the registration before using the agent.
  * @param agent - the already-constructed agent to record in the store.
- * @returns the EXACT Cordis effect disposer (single-shot; a repeat call
+ * @returns the awaitable Cordis effect disposer (single-shot; a repeat call
  *   returns undefined without awaiting an in-flight teardown). Exact
  *   identity is load-bearing: a composite (generator) effect that owns a
  *   teardown ORDER — the agent factory's lifecycle chain — must yield THIS
@@ -836,7 +836,7 @@ async resume(options: ResumeAgentOptions): Promise<AgentHandle>
  *   owner unload, unregistering the agent (and emitting `agent/disposed`)
  *   while its final turn is still draining.
  */
-register(agent: Agent): () => void
+register(agent: Agent): ReturnType<Context['effect']>
 
 /**
  * Insert an already-constructed agent without announcing it. This is the
@@ -850,19 +850,22 @@ register(agent: Agent): () => void
  *   the resumed session's durable parent lineage.
  * @returns an idempotent closure that removes this exact entry and emits
  *   `agent/disposed` with listener failures contained. When called from a
- *   synchronous `agent/created` listener, removal and disposal wait until
- *   that creation dispatch unwinds.
+ *   `agent/created` listener, removal and disposal wait until the serial
+ *   creation dispatch settles.
  */
 enter(agent: Agent, owner: Agent | undefined): () => void
 
 /**
  * Announce an agent previously inserted with {@link enter}.
  * @param agent - the live inserted agent to announce.
+ * @param source - fresh creation, resume, clear, or compaction source.
+ * @param signal - optional factory initialization cancellation signal passed to listeners.
+ * @returns completion of the serial creation listeners; a listener failure rejects.
  * @throws if `agent` is not the exact live registry entry for its id, or its
  *   creation announcement already began (including a reentrant call from a
  *   creation listener).
  */
-announce(agent: Agent): void
+async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): Promise<void>
 
 /**
  * Look up a live agent.
@@ -925,24 +928,27 @@ Types: [Scoped](scope.md)
 
 Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
 
-<a id="agentcreated--emit"></a>
+<a id="agentcreated--serial"></a>
 
-#### `agent/created` — emit
+#### `agent/created` — serial
 
-A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving extension point. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry.
+An entered agent is ready for per-agent initialization after factory setup. Listeners run in order and are awaited before creation resolves. AgentLoop holds queued input until all listeners finish. A throw or rejection fails creation and skips later listeners. Disposal retains the scope and session until dispatch settles; listeners must not await agent.whenIdle() or their own owner's disposal.
 
 ```ts cordis-catalog
 /**
- * A fully configured agent and live session were published. Setup is
- * composition-only; `agent/session-start` is the first startup-driving extension point.
- * Synchronous listener failure vetoes publication, while returned-promise
- * rejection is reported. Detach requested during dispatch waits until every
- * creation listener has observed the stable entry.
+ * An entered agent is ready for per-agent initialization after factory setup.
+ * Listeners run in order and are awaited before creation resolves. AgentLoop
+ * holds queued input until all listeners finish. A throw or rejection fails
+ * creation and skips later listeners. Disposal retains the scope and session
+ * until dispatch settles; listeners must not await agent.whenIdle() or their
+ * own owner's disposal.
  * @param payload.agent - the newly registered agent with its live session and completed setup.
+ * @param payload.source - fresh creation, resume, clear, or compaction source.
+ * @param payload.signal - factory initialization cancellation signal, when provided.
  * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @mode emit
+ * @mode serial
  */
-'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
+'agent/created'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource; signal?: AbortSignal }): undefined | Promise<undefined>
 ```
 
 Types: [Scoped](scope.md)
@@ -1145,30 +1151,6 @@ Types: [LlmFailure](llm-streaming.md) · [ResolvedRetryPolicy](llm-streaming.md)
 
 Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
 
-<a id="agentsession-start--emit"></a>
-
-#### `agent/session-start` — emit
-
-The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts.
-
-```ts cordis-catalog
-/**
- * The session lifecycle began, once before the first turn. Use
- * `agent.inject()` to seed model-facing context. This is a notification, not
- * a veto; disposal requested by a lifecycle owner is rechecked before the
- * driver starts.
- * @param payload.agent - the agent whose session lifecycle began.
- * @param payload.source - why the session started (fresh startup, resume, …).
- * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @mode emit
- */
-'agent/session-start'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void
-```
-
-Types: [Scoped](scope.md)
-
-Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
-
 <a id="agentstatus--emit"></a>
 
 #### `agent/status` — emit

+ 25 - 43
docs/subsystems/core.zh.md

@@ -344,7 +344,7 @@ type RequestErrorAction = { kind: 'retry' } | undefined
 
 `agent/pre-step` 是请求推导前唯一的 waterfall(瀑布式)监听器链。`agent/turn-stopping` 在轮次没有工具或 steering(中途引导)后续时运行,先于最后一次 steering 排空。
 
-`agent/session-start` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart):
+`agent/created` 携带 `SessionStartSource`(会话生命周期为何开始;桥接层据此匹配其 SessionStart):
 
 ```ts type-equiv
 /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
@@ -828,16 +828,16 @@ async create(options: CreateAgentOptions): Promise<AgentHandle>
 async resume(options: ResumeAgentOptions): Promise<AgentHandle>
 
 /**
- * Register a live agent. Throws if an agent with the same id is already
- * registered. Emits `agent/created` on registration and `agent/disposed`
+ * Register a live agent with source `startup`. Rejects if the id is already registered or a
+ * serial `agent/created` listener fails. Emits `agent/disposed`
  * when the calling fiber is disposed — both with the agent's scope carrier
  * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the
  * emits are scope-filtered regardless of which context invoked `register`
  * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always
  * requires passing the carrier). The entry is a runtime root; factory-backed
- * creation uses `options.parentAgent` for child ownership. Returns the disposer.
+ * creation uses `options.parentAgent` for child ownership. Await the registration before using the agent.
  * @param agent - the already-constructed agent to record in the store.
- * @returns the EXACT Cordis effect disposer (single-shot; a repeat call
+ * @returns the awaitable Cordis effect disposer (single-shot; a repeat call
  *   returns undefined without awaiting an in-flight teardown). Exact
  *   identity is load-bearing: a composite (generator) effect that owns a
  *   teardown ORDER — the agent factory's lifecycle chain — must yield THIS
@@ -846,7 +846,7 @@ async resume(options: ResumeAgentOptions): Promise<AgentHandle>
  *   owner unload, unregistering the agent (and emitting `agent/disposed`)
  *   while its final turn is still draining.
  */
-register(agent: Agent): () => void
+register(agent: Agent): ReturnType<Context['effect']>
 
 /**
  * Insert an already-constructed agent without announcing it. This is the
@@ -860,19 +860,22 @@ register(agent: Agent): () => void
  *   the resumed session's durable parent lineage.
  * @returns an idempotent closure that removes this exact entry and emits
  *   `agent/disposed` with listener failures contained. When called from a
- *   synchronous `agent/created` listener, removal and disposal wait until
- *   that creation dispatch unwinds.
+ *   `agent/created` listener, removal and disposal wait until the serial
+ *   creation dispatch settles.
  */
 enter(agent: Agent, owner: Agent | undefined): () => void
 
 /**
  * Announce an agent previously inserted with {@link enter}.
  * @param agent - the live inserted agent to announce.
+ * @param source - fresh creation, resume, clear, or compaction source.
+ * @param signal - optional factory initialization cancellation signal passed to listeners.
+ * @returns completion of the serial creation listeners; a listener failure rejects.
  * @throws if `agent` is not the exact live registry entry for its id, or its
  *   creation announcement already began (including a reentrant call from a
  *   creation listener).
  */
-announce(agent: Agent): void
+async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): Promise<void>
 
 /**
  * Look up a live agent.
@@ -935,24 +938,27 @@ Types: [Scoped](scope.zh.md)
 
 Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
 
-<a id="agentcreated--emit"></a>
+<a id="agentcreated--serial"></a>
 
-#### `agent/created` — emit
+#### `agent/created` — serial
 
-A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving extension point. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry.
+An entered agent is ready for per-agent initialization after factory setup. Listeners run in order and are awaited before creation resolves. AgentLoop holds queued input until all listeners finish. A throw or rejection fails creation and skips later listeners. Disposal retains the scope and session until dispatch settles; listeners must not await agent.whenIdle() or their own owner's disposal.
 
 ```ts cordis-catalog
 /**
- * A fully configured agent and live session were published. Setup is
- * composition-only; `agent/session-start` is the first startup-driving extension point.
- * Synchronous listener failure vetoes publication, while returned-promise
- * rejection is reported. Detach requested during dispatch waits until every
- * creation listener has observed the stable entry.
+ * An entered agent is ready for per-agent initialization after factory setup.
+ * Listeners run in order and are awaited before creation resolves. AgentLoop
+ * holds queued input until all listeners finish. A throw or rejection fails
+ * creation and skips later listeners. Disposal retains the scope and session
+ * until dispatch settles; listeners must not await agent.whenIdle() or their
+ * own owner's disposal.
  * @param payload.agent - the newly registered agent with its live session and completed setup.
+ * @param payload.source - fresh creation, resume, clear, or compaction source.
+ * @param payload.signal - factory initialization cancellation signal, when provided.
  * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @mode emit
+ * @mode serial
  */
-'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
+'agent/created'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource; signal?: AbortSignal }): undefined | Promise<undefined>
 ```
 
 Types: [Scoped](scope.zh.md)
@@ -1155,30 +1161,6 @@ Types: [LlmFailure](llm-streaming.zh.md) · [ResolvedRetryPolicy](llm-streaming.
 
 Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
 
-<a id="agentsession-start--emit"></a>
-
-#### `agent/session-start` — emit
-
-The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts.
-
-```ts cordis-catalog
-/**
- * The session lifecycle began, once before the first turn. Use
- * `agent.inject()` to seed model-facing context. This is a notification, not
- * a veto; disposal requested by a lifecycle owner is rechecked before the
- * driver starts.
- * @param payload.agent - the agent whose session lifecycle began.
- * @param payload.source - why the session started (fresh startup, resume, …).
- * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
- * @mode emit
- */
-'agent/session-start'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void
-```
-
-Types: [Scoped](scope.zh.md)
-
-Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
-
 <a id="agentstatus--emit"></a>
 
 #### `agent/status` — emit

+ 3 - 3
packages/api/session-controller/tests/agent.host.spec.ts

@@ -172,7 +172,7 @@ describe('ApiSession Agent lookup and recovery', () => {
   it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => {
     const { ctx } = await harness()
     const live = agent(ctx, header('live'))
-    ctx.agents.register(live)
+    await ctx.agents.register(live)
     providePersistence(ctx, {
       list: () => Promise.resolve([]),
       inspect: vi.fn(),
@@ -193,7 +193,7 @@ describe('ApiSession Agent lookup and recovery', () => {
     })
     const winner = agent(ordinary.ctx, ordinaryMeta)
     vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => {
-      ordinary.ctx.agents.register(winner)
+      await ordinary.ctx.agents.register(winner)
       throw new Error('raced publication')
     })
     await expect(ordinary.agents.resolveAgent(ordinaryMeta.id)).resolves.toEqual({ agent: winner })
@@ -326,7 +326,7 @@ describe('ApiSession create or adoption', () => {
     const ordinaryMeta = header('create-race', cwd)
     const winner = agent(ordinary.ctx, ordinaryMeta)
     vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => {
-      ordinary.ctx.agents.register(winner)
+      await ordinary.ctx.agents.register(winner)
       throw new Error('raced creation')
     })
     await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false))

+ 1 - 1
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -82,7 +82,7 @@ async function commandHarness(
     followup: vi.fn(),
     cancel,
   } as unknown as Agent
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
   ctx.provide('agentDefaultModel', {
     currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),

+ 7 - 7
packages/api/session-controller/tests/commands-upload-file.host.spec.ts

@@ -29,7 +29,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
   saveFile: ReturnType<typeof vi.fn>
   saveFileStream: ReturnType<typeof vi.fn>
   saveImages: ReturnType<typeof vi.fn>
-  disposeAgent: () => void
+  disposeAgent: () => Promise<void>
   uploadRoute: (request: Request) => Promise<Response>
 }> {
   const ctx = new Context()
@@ -52,7 +52,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
     cancel: vi.fn(),
   } as unknown as Agent
   ;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx
-  const disposeAgent = ctx.agents.register(agent)
+  const disposeAgent = await ctx.agents.register(agent)
   const saveFile = vi.fn((input: SaveFileAttachment): Promise<FileAttachmentRef> => Promise.resolve({
     attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`),
     name: input.name ?? 'file',
@@ -226,7 +226,7 @@ describe('Session file uploads', () => {
     saveFile.mockReturnValueOnce(saved.promise)
     const uploading = uploads.upload(agent, { data: 'AAAA', name: 'late.bin' }, new AbortController().signal)
     await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() })
-    disposeAgent()
+    await disposeAgent()
     saved.resolve({
       attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), name: 'late.bin', bytes: 3,
     })
@@ -235,9 +235,9 @@ describe('Session file uploads', () => {
 
   it('resolves a cold ordinary Agent and releases the resolver registration', async () => {
     const { ctx, uploads, agent, disposeAgent } = await uploadHarness()
-    disposeAgent()
+    await disposeAgent()
     const resolveAgent = vi.fn(async () => {
-      ctx.agents.register(agent)
+      await ctx.agents.register(agent)
       return agent
     })
     const disposeResolver = uploads.registerAgentResolver(resolveAgent)
@@ -258,7 +258,7 @@ describe('Session file uploads', () => {
 
   it('rejects a cold upload when no Agent resolver is registered', async () => {
     const { uploads, disposeAgent } = await uploadHarness()
-    disposeAgent()
+    await disposeAgent()
     await expect(uploads.uploadStream({
       sessionId: SESSION,
       data: (async function* (): AsyncIterable<Uint8Array> {})(),
@@ -366,7 +366,7 @@ describe('Session file uploads', () => {
       { type: 'image', mediaType: 'image/png', data: 'AAAA' },
     ]))
     await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
-    disposeAgent()
+    await disposeAgent()
     admitted.resolve([{
       attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
     }])

+ 1 - 1
packages/api/session-controller/tests/control-jobs.host.spec.ts

@@ -59,7 +59,7 @@ async function harness(withJobs: boolean): Promise<{
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   const control = new SessionControlController(ctx)
   await new Promise(resolve => setTimeout(resolve, 0))
   return { ctx, session, agent, control }

+ 1 - 1
packages/api/session-controller/tests/controller.host.spec.ts

@@ -74,7 +74,7 @@ describe('SessionController facade', () => {
       status: 'idle',
       ctx,
     } as Agent
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     const resolveUploadAgent = (id: SessionId): Promise<Agent> => {
       if (uploadResolver === undefined) throw new Error('file upload resolver was not registered')
       return uploadResolver(id)

+ 10 - 10
packages/api/session-controller/tests/session-cold.host.spec.ts

@@ -202,7 +202,7 @@ describe('attached updatedAt tracks human prompts', () => {
       ],
       meta: { cwd: '/proj', createdAt: 500 },
     })
-    ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
+    await ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
     const boundary = resumed.snapshotEvents().at(-1)
     expect(boundary?.type).toBe('session/end-seed')
     expect(boundary?.time).toBeGreaterThan(worked)
@@ -330,7 +330,7 @@ describe('Remote Agent and Session lookup policy', () => {
       meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' },
     })
     const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent
-    ctx.agents.register(liveAgent)
+    await ctx.agents.register(liveAgent)
     const resume = vi.spyOn(ctx.agents, 'resume')
     const defaultAgentLookup = ctx.typert.lookups.get('agent')
     const defaultSessionLookup = ctx.typert.lookups.get('session')
@@ -478,7 +478,7 @@ describe('subagent ownership fence', () => {
     await ctx.plugin(AgentRegistry)
     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)
+    await ctx.agents.register(parent)
 
     const originSession = ctx.sessions.create(sid('session-origin-child'), {
       meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
@@ -493,7 +493,7 @@ describe('subagent ownership fence', () => {
       cancel,
       updateInbox,
     } as unknown as Agent
-    ctx.agents.register(originChild)
+    await ctx.agents.register(originChild)
 
     const startingSession = ctx.sessions.create(sid('session-starting-child'), {
       meta: { cwd: '/proj', parentSession: parent.id },
@@ -549,7 +549,7 @@ describe('subagent ownership fence', () => {
     const agent = {
       id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup,
     } as unknown as Agent
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
 
     const response = await remote.prompt(promptRequest({
@@ -570,7 +570,7 @@ describe('subagent ownership fence', () => {
     const agent = {
       id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup,
     } as unknown as Agent
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     const remote = createSessionTestRemote(ctx, {
       defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
       cwd: '/tmp',
@@ -686,7 +686,7 @@ describe('sessions.prompt synchronous rejection', () => {
     const session = ctx.sessions.create(sid('session-empty-prompt'))
     const followup = vi.fn()
     const steer = vi.fn()
-    ctx.agents.register({
+    await ctx.agents.register({
       id: session.id,
       session,
       inbox: inboxFor(),
@@ -781,7 +781,7 @@ describe('sessions.prompt synchronous rejection', () => {
     const session = ctx.sessions.create(sid('session-throwing'))
     // A live structural stub whose delivery verbs throw synchronously, the
     // shape a disposed loop presents at this gateway boundary.
-    ctx.agents.register({
+    await ctx.agents.register({
       id: session.id,
       session,
       inbox: inboxFor(),
@@ -820,7 +820,7 @@ describe('sessions.prompt synchronous rejection', () => {
     // while the generic cold resume is in flight, so the resume collides.
     const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
     const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
-    ctx.agents.register(parent)
+    await ctx.agents.register(parent)
     const childSession = ctx.sessions.create(sessionId, {
       meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
     })
@@ -828,7 +828,7 @@ describe('sessions.prompt synchronous rejection', () => {
     vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
       // The parent's `enter()` wins the identity between the pre-resume
       // re-check and publication; the generic resume then collides.
-      ctx.agents.register(child)
+      await ctx.agents.register(child)
       throw new Error('session id already published')
     })
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })

+ 12 - 12
packages/api/session-controller/tests/session-fork.host.spec.ts

@@ -42,7 +42,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
       const agentCtx = ownerCtx
       Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx })
       await options.setup?.(agentCtx, agent)
-      ctx.agents.register(agent)
+      await ctx.agents.register(agent)
       return { agent, dispose: () => Promise.resolve() }
     },
     resume: () => Promise.reject(new Error('fork test sources are live')),
@@ -53,13 +53,13 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
 /** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
 type Tail = 'none' | 'open' | 'aborted'
 
-function liveAgent(
+async function liveAgent(
   ctx: Context,
   id: string,
   turns: number,
   tail: Tail = 'none',
   lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
-): Session {
+): Promise<Session> {
   const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } })
   for (let turn = 1; turn <= turns; turn++) {
     session.append('turn/start', { turn })
@@ -80,7 +80,7 @@ function liveAgent(
       reason: { kind: 'aborted', reason: { kind: 'user' } },
     })
   }
-  ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+  await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
   return session
 }
 
@@ -139,7 +139,7 @@ describe('sessions.fork', () => {
 
   it('cuts at the anchored completed turn and records lineage and cwd', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-source', 2)
+    const source = await liveAgent(ctx, 'session-source', 2)
     const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 1 }))
     expect(response.ok ? null : response.error).toBeNull()
     if (!response.ok) return
@@ -161,13 +161,13 @@ describe('sessions.fork', () => {
       attachSession,
     } as unknown as Workspace
     const ctx = await composed([workspace])
-    const owner = liveAgent(ctx, 'session-owner', 1)
+    const owner = await liveAgent(ctx, 'session-owner', 1)
     accounted.push(owner.id)
-    const child = liveAgent(ctx, 'session-child', 1, 'none', {
+    const child = await liveAgent(ctx, 'session-child', 1, 'none', {
       parentSession: owner.id,
       origin: 'subagent',
     })
-    const grandchild = liveAgent(ctx, 'session-grandchild', 1, 'none', {
+    const grandchild = await liveAgent(ctx, 'session-grandchild', 1, 'none', {
       parentSession: child.id,
       origin: 'subagent',
     })
@@ -253,7 +253,7 @@ describe('sessions.fork', () => {
 
   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')
+    const source = await liveAgent(ctx, 'session-tail', 2, 'open')
     const proxy = remote(ctx)
     const expectedTypes = [
       'turn/start', 'user/message', 'turn/end',
@@ -289,7 +289,7 @@ describe('sessions.fork', () => {
 
   it('cuts through an aborted turn: stopped is closed, not open', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-aborted', 1, 'aborted')
+    const source = await liveAgent(ctx, 'session-aborted', 1, 'aborted')
     // What a stopped message's fork button anchors on: the frozen node sits
     // one event before its turn/end, floored client-side to that event's seq.
     const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1
@@ -306,7 +306,7 @@ describe('sessions.fork', () => {
 
   it('rejects an in-log anchor whose turn is still open', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-open', 1, 'open')
+    const source = await liveAgent(ctx, 'session-open', 1, 'open')
     const anchor = source.snapshotEvents().at(-1)?.seq ?? 0
     const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
     expect(response).toMatchObject({
@@ -319,7 +319,7 @@ describe('sessions.fork', () => {
 
   it('inherits model selection through the completed turn and excludes later changes', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-routed', 0)
+    const source = await liveAgent(ctx, 'session-routed', 0)
     source.append('turn/start', { turn: 1 })
     source.append('request/header', {
       header: {

+ 5 - 5
packages/api/session-controller/tests/session-list-blank.host.spec.ts

@@ -19,15 +19,15 @@ import type {} from '@deepseek-ai/dsh-permission-presets'
 import type {} from '@deepseek-ai/dsh-sandbox-policy'
 import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts'
 
-async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => void }> {
+async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => Promise<void> }> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
   return {
     ctx,
     remote: createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
-    attach: (session) => {
-      ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+    attach: async (session) => {
+      await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
     },
   }
 }
@@ -57,7 +57,7 @@ describe('summary blank = conversation not started', () => {
   it('standalone events (command lifecycle, plan/mode, title) keep the session blank', async () => {
     const { ctx, remote, attach } = await harness()
     const session = ctx.sessions.create()
-    attach(session)
+    await attach(session)
     expect(await listBlank(remote, session.id)).toBe(true)
     appendStandalone(session)
     expect(await listBlank(remote, session.id)).toBe(true)
@@ -66,7 +66,7 @@ describe('summary blank = conversation not started', () => {
   it('the first turn clears blank', async () => {
     const { ctx, remote, attach } = await harness()
     const session = ctx.sessions.create()
-    attach(session)
+    await attach(session)
     appendStandalone(session)
     session.append('turn/start', { turn: 0 })
     expect(await listBlank(remote, session.id)).toBe(false)

+ 1 - 1
packages/api/session-controller/tests/session-models.host.spec.ts

@@ -132,7 +132,7 @@ async function harness(logged?: {
     ctx,
     inbox: { nextTurn: [], nextStep: [] },
   } as unknown as Agent
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return { ctx, agent, sessionId: session.id }
 }
 

+ 2 - 2
packages/api/session-controller/tests/session-presets.host.spec.ts

@@ -68,8 +68,8 @@ async function harness(presets?: readonly string[]) {
       const agent = stubAgent(session)
       ;(agent as { ctx?: Context }).ctx = ctx
       await options.setup?.(ctx, agent)
-      const unregister = ctx.agents.register(agent)
-      return { agent, dispose: () => { unregister(); return Promise.resolve() } }
+      const unregister = await ctx.agents.register(agent)
+      return { agent, dispose: async () => { await unregister() } }
     },
     async resume() {
       throw new Error('test harness has no persisted sessions')

+ 9 - 9
packages/api/session-controller/tests/session-rename.host.spec.ts

@@ -36,13 +36,13 @@ async function composed(withTitles = true): Promise<Context> {
   // forwarded seed/meta (the store validates the balanced prefix) and
   // registers an idle agent stub over it.
   ctx.agents.setFactory({
-    createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
+    createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
       const session = ctx.sessions.create(options.sessionId, {
         ...options.seed === undefined ? {} : { seed: [...options.seed] },
         ...options.meta === undefined ? {} : { meta: options.meta },
       })
       const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent
-      ctx.agents.register(agent)
+      await ctx.agents.register(agent)
       return Promise.resolve({ agent, dispose: () => Promise.resolve() })
     },
     resume: () => Promise.reject(new Error('resume must not run: every source is attached')),
@@ -51,7 +51,7 @@ async function composed(withTitles = true): Promise<Context> {
 }
 
 /** Register one live agent whose log holds `turns` completed turns. */
-function liveAgent(ctx: Context, id: string, turns: number): Session {
+async function liveAgent(ctx: Context, id: string, turns: number): Promise<Session> {
   const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
   for (let turn = 1; turn <= turns; turn++) {
     session.append('turn/start', { turn })
@@ -61,7 +61,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
     }), { surfaceOp: 'append' })
     session.append('turn/end', { turn, reason: { kind: 'completed' } })
   }
-  ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
+  await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
   return session
 }
 
@@ -70,7 +70,7 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSele
 describe('sessions.rename', () => {
   it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-rename', 1)
+    const source = await liveAgent(ctx, 'session-rename', 1)
 
     const renamed = await remote(ctx).rename(request({ sessionId: source.id, title: '  new   name  ' }))
     expect(renamed.ok).toBe(true)
@@ -83,7 +83,7 @@ describe('sessions.rename', () => {
 
   it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-rename-bad', 1)
+    const source = await liveAgent(ctx, 'session-rename-bad', 1)
 
     // U+200B passes a client-side trim gate but normalizes to empty host-side.
     const response = await remote(ctx).rename(request({ sessionId: source.id, title: ' ​ ' }))
@@ -104,8 +104,8 @@ describe('sessions.rename', () => {
     // title service's liveness check throws a plain Error, which must not
     // read as the user's fault.
     const foreign = await composed(false)
-    const stale = liveAgent(foreign, 'session-rename-stale', 1)
-    ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent)
+    const stale = await liveAgent(foreign, 'session-rename-stale', 1)
+    await ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent)
 
     const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' }))
     expect(response.ok).toBe(false)
@@ -114,7 +114,7 @@ describe('sessions.rename', () => {
 
   it('answers internal when the composition mounts no session-title service', async () => {
     const ctx = await composed(false)
-    const source = liveAgent(ctx, 'session-no-titles', 1)
+    const source = await liveAgent(ctx, 'session-no-titles', 1)
 
     const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' }))
     expect(response.ok).toBe(false)

+ 1 - 1
packages/api/session-controller/tests/session-skills.host.spec.ts

@@ -90,7 +90,7 @@ describe('SessionSkillCatalog', () => {
     const sessionId = SessionId('live-skills')
     const session = ctx.sessions.create(sessionId, { meta: { cwd: '/live/project' } })
     const agent = { id: sessionId, session, status: 'idle', ctx } as Agent
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     ctx.provide('sessionQuery', {
       observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/live/project' })),
     } as never)

+ 1 - 1
packages/bundle/headless/tests/headless.spec.ts

@@ -165,7 +165,7 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{
       },
     }
     await createOptions.setup?.(ownerCtx, agent)
-    ctx.agents.register(agent)
+    await ctx.agents.register(agent)
     return agent
   }
 

+ 4 - 4
packages/context/agent-instructions/tests/agent-instructions.spec.ts

@@ -1416,7 +1416,7 @@ describe('workspace context request injection', () => {
       await fiber.dispose()
       await mountAgentInstructionsPlugin(ctx, { dshHome: home, maxBytes: 65536 })
       const resumed = await stubAgent(root, original.session.snapshotEvents())
-      agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
+      await agentEvents(ctx, resumed).serial('agent/created', { source: 'resume' })
       const claimed = claimInbox(resumed, 'next-step')
       const decision = await agentEvents(ctx, resumed).waterfall(
         'agent/pre-step',
@@ -1462,7 +1462,7 @@ describe('workspace context request injection', () => {
       await fiber.dispose()
       await mountAgentInstructionsPlugin(ctx, { dshHome: home, maxBytes: 65536 })
       const resumed = await stubAgent(root, original.session.snapshotEvents())
-      agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
+      await agentEvents(ctx, resumed).serial('agent/created', { source: 'resume' })
       const staleClaim = claimInbox(resumed, 'next-step')
       const staleDecision = await agentEvents(ctx, resumed).waterfall(
         'agent/pre-step',
@@ -1515,7 +1515,7 @@ describe('workspace context request injection', () => {
       if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' })
       await mountAgentInstructionsPlugin(resumedCtx, { dshHome: home, maxBytes })
       const resumed = await stubAgent(root, original.session.snapshotEvents())
-      agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' })
+      await agentEvents(resumedCtx, resumed).serial('agent/created', { source: 'resume' })
       const claimed = claimInbox(resumed, 'next-step')
       const decision = await agentEvents(resumedCtx, resumed).waterfall(
         'agent/pre-step',
@@ -1824,7 +1824,7 @@ describe('workspace context request injection', () => {
       const resumed = await stubAgent(root, original.session.snapshotEvents())
 
       // Resume announces its lifecycle start before the first step.
-      agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
+      await agentEvents(ctx, resumed).serial('agent/created', { source: 'resume' })
       await composeBaselinePrefix(ctx, resumed)
 
       const baselines = baselineEvents(resumed)

+ 2 - 2
packages/context/file-reference-local/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/context/file-reference-local/README.md
-README.md: ec1a681f6fa1c5fb33a7705d2c1faf5232690b23
-README.zh.md: c80a5546d8bb9dce1b0d3862f3169deffa7d4fdf
+README.md: efeebdbd3e113597cc93dfe4d6c379e16b324d93
+README.zh.md: f286f92d6e6ec76afc9919db9f81c73e4ace8d04

+ 1 - 1
packages/context/file-reference-local/README.md

@@ -63,7 +63,7 @@ This section explains the design of the provider; the observable behavior is cov
 
 ### Design concept
 
-The provider maintains one reusable `WorkspaceFileSearch` per agent, rooted at that session's `cwd`. Directory-scoped queries (`a/b/...`) list live directory state, while bare fuzzy queries share one bounded recursive traversal. Only a workspace's first bare query waits for that traversal; a `tool/result` event marks the settled entries stale, and the next bare query serves them while the replacement builds. The model guidance is a per-agent prompt section contributed only while the addressed agent has a `read` tool; agent disposal releases both the index and the prompt fiber.
+The provider maintains one reusable `WorkspaceFileSearch` per agent, rooted at that session's `cwd`. Directory-scoped queries (`a/b/...`) list live directory state, while bare fuzzy queries share one bounded recursive traversal. Only a workspace's first bare query waits for that traversal; a `tool/result` event marks the settled entries stale, and the next bare query serves them while the replacement builds. The model guidance is a per-agent prompt section contributed only while the addressed agent has a `read` tool; creation awaits prompt installation and rolls back on failure; agent disposal releases both the index and the prompt fiber.
 
 ### Source map
 

+ 1 - 1
packages/context/file-reference-local/README.zh.md

@@ -63,7 +63,7 @@ agent(智能体)及宿主 UI 可以用各 agent 本地工作区中经过排
 
 ### 设计理念
 
-提供方为每个 agent 维护一个可复用的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根。目录范围查询(`a/b/...`)列出实时目录状态,裸模糊查询共享一次有界递归遍历。每个工作区仅首次裸查询会等待该遍历;`tool/result` 事件把已完成的条目标记为陈旧,下一次裸查询在替代品构建期间继续由它作答。模型指引是按 agent 的提示词段,仅在指定 agent 拥有 `read` 工具时贡献;agent dispose(资源释放)时会同时释放索引与提示词 fiber。
+提供方为每个 agent 维护一个可复用的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根。目录范围查询(`a/b/...`)列出实时目录状态,裸模糊查询共享一次有界递归遍历。每个工作区仅首次裸查询会等待该遍历;`tool/result` 事件把已完成的条目标记为陈旧,下一次裸查询在替代品构建期间继续由它作答。模型指引是按 agent 的提示词段,仅在指定 agent 拥有 `read` 工具时贡献;创建会等待提示词安装,安装失败会回滚 agent;agent dispose(资源释放)时会同时释放索引与提示词 fiber。
 
 ### 源码地图
 

+ 5 - 3
packages/context/file-reference-local/src/index.ts

@@ -63,8 +63,9 @@ export class LocalFileReferenceService extends FileReferenceService {
     }
     validateConfig(this.config)
 
-    const installPrompt = (agent: Agent): void => {
-      if (this.promptFibers.has(agent)) return
+    const installPrompt = (agent: Agent): ReturnType<Context['inject']> => {
+      const existing = this.promptFibers.get(agent)
+      if (existing !== undefined) return existing
       const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => {
         scope.systemPrompt.section({
           name: 'context:file-reference',
@@ -73,6 +74,7 @@ export class LocalFileReferenceService extends FileReferenceService {
         })
       })
       this.promptFibers.set(agent, fiber)
+      return fiber
     }
     const disposePrompt = (agent: Agent): void => {
       const fiber = this.promptFibers.get(agent)
@@ -87,7 +89,7 @@ export class LocalFileReferenceService extends FileReferenceService {
       })
     }
     for (const agent of ctx.agents.list()) installPrompt(agent)
-    ctx.on('agent/created', ({ agent }) => { installPrompt(agent) })
+    ctx.on('agent/created', async ({ agent }) => { await installPrompt(agent) })
     ctx.on('agent/disposed', ({ agent }) => {
       this.searches.get(agent)?.dispose()
       this.searches.delete(agent)

+ 25 - 6
packages/context/file-reference-local/tests/service.spec.ts

@@ -31,7 +31,7 @@ async function stubAgent(
   ctx: Context,
   id = 'file-reference-agent',
   includeCwd = true,
-): Promise<{ agent: Agent; dispose: () => void }> {
+): Promise<{ agent: Agent; dispose: () => Promise<void> }> {
   const root = await mkdtemp(join(tmpdir(), 'dsh-file-reference-service-'))
   roots.push(root)
   await writeFile(join(root, 'README.md'), 'readme')
@@ -51,10 +51,29 @@ async function stubAgent(
     cancel() {},
     whenIdle: () => Promise.resolve(),
   } as unknown as Agent
-  return { agent, dispose: ctx.agents.register(agent) }
+  return { agent, dispose: await ctx.agents.register(agent) }
 }
 
 describe('LocalFileReferenceService', () => {
+  it('defers guidance until optional prompt and tool services become available', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(LocalFileReferenceService)
+    try {
+      const { agent } = await stubAgent(ctx, 'deferred-prompt')
+      expect(ctx.agents.get(agent.id)).toBe(agent)
+      await ctx.plugin(SystemPrompt, { personaPrefix: '' })
+      await ctx.plugin(ToolRegistry)
+      ctx.tools.register(defineContentToolFixture({
+        name: 'read', description: 'read a file', parameters: {}, execute: () => Promise.resolve([]),
+      }))
+      expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('serves the addressed workspace and installs read-tool guidance for existing agents', async () => {
     const ctx = await harness()
     const { agent } = await stubAgent(ctx)
@@ -95,7 +114,7 @@ describe('LocalFileReferenceService', () => {
     ctx.emit('session/event', orphan, { type: 'tool/result' } as never)
     expect(invalidate).toHaveBeenCalledOnce()
 
-    dispose()
+    await dispose()
     expect(close).toHaveBeenCalledOnce()
     ctx.emit('agent/disposed', { agent })
   })
@@ -131,7 +150,7 @@ describe('LocalFileReferenceService', () => {
     const fiber = ctx.plugin(LocalFileReferenceService)
     await fiber
     const { agent } = await stubAgent(ctx, 'cwd-fallback', false)
-    ctx.emit('agent/created', { agent })
+    await ctx.serial('agent/created', { agent, source: 'startup' })
     const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockResolvedValue([])
     await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)).resolves.toEqual([])
     await expect(ctx.fileReferences.list(agent, 'src', new AbortController().signal)).resolves.toEqual([])
@@ -151,8 +170,8 @@ describe('LocalFileReferenceService', () => {
     const first = await stubAgent(ctx, 'cleanup-one')
     const second = await stubAgent(ctx, 'cleanup-two')
     expect(inject).toHaveBeenCalledTimes(2)
-    first.dispose()
-    second.dispose()
+    await first.dispose()
+    await second.dispose()
     await vi.waitFor(() => {
       expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: error cleanup')
       expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: string cleanup')

+ 2 - 2
packages/core/agent-loop/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/core/agent-loop/README.md
-README.md: d494f3f6f7c68440d59ec2c1aa8fad2eae0bcda1
-README.zh.md: bb3ca764a688cc01f61aa9b90667312f7d765fb5
+README.md: e53ccb4658ee857f7798ee18343752416b93a890
+README.zh.md: 3974bc56960d9f5c39082affc52937fc9f8b1946

+ 1 - 1
packages/core/agent-loop/README.md

@@ -108,7 +108,7 @@ The loop deep-freezes each derived message identity on its first request and reu
 
 ### Creation and teardown
 
-Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup with the context and Agent passed separately; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; only then start the driver. A caller creating a runtime child sets `options.parentAgent`; the caller Context separately owns the transaction and live handle. A setup throw, commit failure, or owner disposal rolls the transaction back without publishing either id. Teardown runs stop-and-drain, closes the session's write path, unwinds the scope, detaches the agent, then detaches the session, and every detach is bound to the exact entered object so a stale disposer cannot remove a later same-id replacement.
+Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created`; await serial `agent/created` listeners; then release queued input. A caller creating a runtime child sets `options.parentAgent`; the caller Context separately owns the transaction and live handle. Setup, commit, or listener failure and owner disposal roll back the prepared resources. Announcements already delivered remain observable and receive matching disposal notifications. Teardown stops and drains the driver, unwinds the scope, closes the session's write path, detaches the agent, then detaches the session. Every detach binds the exact entered object, so a stale disposer cannot remove a later same-id replacement.
 
 ### Persistence integration
 

+ 1 - 1
packages/core/agent-loop/README.zh.md

@@ -108,7 +108,7 @@ const handle = await ctx.agents.create({
 
 ### 创建与拆除
 
-创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待分别传入上下文与 Agent 的可选 setup;进入两个注册表;依次宣告 `session/created` 与 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。创建运行时子 Agent 的调用方设置 `options.parentAgent`;调用方 Context 则单独拥有事务和存活句柄。Setup 抛出、commit 失败或所有者 dispose 都会回滚事务而不发布任一 id。Teardown 顺序是停止并排空、关闭会话的写路径、撤销作用域、detach agent、再 detach 会话,且每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。
+创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待可选 setup;进入两个注册表;宣告 `session/created`;等待串行 `agent/created` 监听器;随后释放已排队输入。创建运行时子 Agent 的调用方设置 `options.parentAgent`;调用方 Context 则单独拥有事务和存活句柄。Setup、commit、监听器失败或所有者 dispose 都会回滚已准备的资源。已送达的宣告仍可被观察,并有配对的销毁通知。Teardown 停止并排空驱动器、撤销作用域、关闭会话写路径、detach agent,再 detach 会话。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。
 
 ### 持久化集成
 

+ 2 - 1
packages/core/agent-loop/src/agent.ts

@@ -170,7 +170,8 @@ export class ReactLoopAgent implements Agent {
         return await job(maintenance.abort.signal)
       } finally {
         this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn })
-        if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver()
+        const cause = maintenance.abort.signal.reason as AgentCancelCause | undefined
+        if (cause?.kind !== 'disposed' && maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver()
         done.resolve()
       }
     })()

+ 41 - 28
packages/core/agent-loop/src/index.ts

@@ -10,7 +10,6 @@ import { randomUUID } from 'node:crypto'
 import z from '@deepseek-ai/schemastery'
 import { z as zod } from 'zod'
 import { brandString } from '@deepseek-ai/dsh-brand'
-import { emitAgentEvent } from '@deepseek-ai/dsh-agent'
 import type {
   Agent,
   AgentFactory,
@@ -214,8 +213,8 @@ interface PreparedAgent {
   agent: ReactLoopAgent
   /** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */
   signal: AbortSignal
-  /** Enter registries, announce, notify session-start, and start the machine. */
-  publish(source: SessionStartSource): AgentHandle
+  /** Enter both registries and await creation listeners. */
+  publish(source: SessionStartSource): Promise<AgentHandle>
   /** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */
   dispose(): Promise<void>
 }
@@ -569,6 +568,7 @@ export class AgentLoop extends Service implements AgentFactory {
     let detachSession: (() => void) | undefined
     let detachAgent: (() => void) | undefined
     let disposing: Promise<void> | undefined
+    let publication: ReturnType<typeof Promise.withResolvers<void>> | undefined
     const machineReady = Promise.withResolvers<void>()
     // Reverse teardown, memoized so every racing owner awaits one quiescence:
     // stop the machine, drain and close the session's write path, leave the
@@ -582,6 +582,8 @@ export class AgentLoop extends Service implements AgentFactory {
       // disposal rejects with what failed so every racing owner observes it.
       const failures: unknown[] = []
       try {
+        // Creation listeners retain the session and scope through their awaits.
+        if (publication !== undefined) await publication.promise
         // Disposal IS a disposed-cause cancel followed by quiescence. New work
         // sent after this point is the sender's bug — the registries are about
         // to drop the agent, so nothing should still hold it.
@@ -659,22 +661,23 @@ export class AgentLoop extends Service implements AgentFactory {
       return {
         agent,
         signal: abort.signal,
-        publish: (source) => {
-          assertLive()
-          detachSession = agent.ctx.sessions.enter(session)
-          // The mounted backend routes announced live events into the active
-          // write handle by session id; the loop only owns the handle itself.
-          detachAgent = loopCtx.agents.enter(agent, parentAgent)
-          agent.ctx.sessions.announce(session)
-          assertLive()
-          loopCtx.agents.announce(agent)
-          assertLive()
-          // A synchronous announce/session-start listener may have started
-          // teardown; the machine is already live (delivery works from the
-          // session-start extension point), so only the liveness recheck is owed.
-          emitAgentEvent(loopCtx, agent, 'agent/session-start', { source })
-          assertLive()
-          return { agent, dispose }
+        publish: async (source) => {
+          publication = Promise.withResolvers<void>()
+          try {
+            assertLive()
+            detachSession = agent.ctx.sessions.enter(session)
+            // The mounted backend routes announced live events into the active
+            // write handle by session id; the loop only owns the handle itself.
+            detachAgent = loopCtx.agents.enter(agent, parentAgent)
+            agent.ctx.sessions.announce(session)
+            assertLive()
+            await loopCtx.agents.announce(agent, source, abort.signal)
+            assertLive()
+            return { agent, dispose }
+          } finally {
+            publication.resolve()
+            publication = undefined
+          }
         },
         dispose,
       }
@@ -706,14 +709,10 @@ export class AgentLoop extends Service implements AgentFactory {
       await stored?.handle.close().catch(() => {})
       throw error
     }
-    try {
+    return (await this.initializeAgent(prepared, async () => {
       await this.appendUnstoredSuffix(stored, preparation.session)
-      return prepared.publish('startup').agent
-    } catch (error: unknown) {
-      // Rollback swallows a disposal rejection: the setup failure is primary.
-      void prepared.dispose().catch(() => {})
-      throw error
-    }
+      return await prepared.publish('startup')
+    })).agent
   }
 
   /**
@@ -822,11 +821,25 @@ export class AgentLoop extends Service implements AgentFactory {
       await stored?.handle.close().catch(() => {})
       throw error
     }
-    try {
+    return await this.initializeAgent(prepared, async () => {
       const setupCommit = await raceAbort(setup?.(prepared.agent.ctx, prepared.agent), prepared.signal, id)
       setupCommit?.commit()
       await this.appendUnstoredSuffix(stored, session)
-      return prepared.publish(source)
+      return await prepared.publish(source)
+    })
+  }
+
+  private async initializeAgent(prepared: PreparedAgent, initialize: () => Promise<AgentHandle>): Promise<AgentHandle> {
+    try {
+      return await prepared.agent.runMaintenance(async () => {
+        try {
+          return await initialize()
+        } catch (error: unknown) {
+          // Teardown owns inbox cleanup and may already have removed its projection.
+          prepared.agent.cancel({ kind: 'disposed' }, { keepInbox: true })
+          throw error
+        }
+      })
     } catch (error: unknown) {
       // Rollback swallows a disposal rejection (a failing final handle close):
       // the setup failure is the primary error the caller must see.

+ 24 - 0
packages/core/agent-loop/tests/fixtures/serial-created.mjs

@@ -0,0 +1,24 @@
+/** Awaited creation context shared by TypeScript and Python SDK snapshots. */
+import { randomUUID } from 'node:crypto'
+import { setImmediate } from 'node:timers/promises'
+
+export const name = 'serial-created-fixture'
+export const inject = ['agents']
+
+/** Install ordered creation listeners whose context must precede the first turn. */
+export function apply(ctx) {
+  const ready = new WeakSet()
+  ctx.on('agent/created', async ({ agent }) => {
+    await setImmediate()
+    agent.inject({
+      id: randomUUID(),
+      role: 'user',
+      content: [{ type: 'text', text: 'Serial agent creation completed.' }],
+      source: { kind: 'plugin', plugin: name },
+    })
+    ready.add(agent)
+  })
+  ctx.on('agent/created', ({ agent }) => {
+    if (!ready.has(agent)) throw new Error('creation listeners ran out of order')
+  })
+}

+ 13 - 18
packages/core/agent-loop/tests/interception.spec.ts

@@ -21,7 +21,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
 
 /**
  * The interception points introduced by the hooks taxonomy: `agent/pre-step`,
- * `agent/session-start`, `agent/turn-stopping`, and the
+ * `agent/created`, `agent/turn-stopping`, and the
  * `tools/pre-execute` / `tools/post-execute`
  * split with `additionalContexts` buffering. These verify the canonical event
  * API a hook bridge (or a native plugin) programs against, WITHOUT any
@@ -557,30 +557,28 @@ describe('agent/pre-step', () => {
   })
 })
 
-describe('agent/session-start', () => {
+describe('agent/created', () => {
   it('fires once with source "startup" for a fresh create, before the first turn', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
 
     const sources: SessionStartSource[] = []
-    ctx.on('agent/session-start', ({ source }) => void sources.push(source))
+    ctx.on('agent/created', ({ source }) => void sources.push(source))
 
     const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    // fires synchronously at create, before any turn
     expect(sources).toEqual(['startup'])
     expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
 
     send(agent, 'go')
     await waitForIdle(ctx, agent)
-    // still only one session-start
     expect(sources).toEqual(['startup'])
   })
 
-  it('a session-start listener can inject context the first request sees', async () => {
+  it('a creation listener can inject context the first request sees', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
 
-    ctx.on('agent/session-start', ({ agent }) => {
+    ctx.on('agent/created', ({ agent }) => {
       agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } }))
     })
 
@@ -595,20 +593,17 @@ describe('agent/session-start', () => {
     expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
   })
 
-  it('a throwing session-start listener does not abort agent construction', async () => {
+  it('a throwing creation listener rolls back the agent before its first turn', async () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(adapter)
 
-    ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
+    const reason = new Error('creation hook broke')
+    ctx.on('agent/created', () => { throw reason })
 
-    // create must not throw — the listener error is contained/logged
-    const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    expect(agent.id).toBe(SessionId('a1'))
-
-    // and the agent still runs
-    send(agent, 'go')
-    await waitForIdle(ctx, agent)
-    expect(adapter.requests).toHaveLength(1)
+    await expect(ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })).rejects.toBe(reason)
+    expect(ctx.agents.list()).toEqual([])
+    expect(ctx.sessions.list()).toEqual([])
+    expect(adapter.requests).toHaveLength(0)
   })
 })
 
@@ -728,7 +723,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
     name: 'native-guard',
     apply(ctx: Context) {
       // 1. SessionStart: seed a standing instruction.
-      ctx.on('agent/session-start', ({ agent, source }) => {
+      ctx.on('agent/created', ({ agent, source }) => {
         agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } }))
       })
       // 2. PreStep: reject a forbidden prompt, annotate the rest.

+ 8 - 14
packages/core/agent-loop/tests/resume.spec.ts

@@ -589,12 +589,12 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     await ctx2.fiber.dispose()
   })
 
-  it('agent/session-start fires "startup" for createAgent and "resume" for resume()', async () => {
-    // Lifecycle 1: a fresh createAgent emits session-start with source 'startup'.
+  it('agent/created fires "startup" for createAgent and "resume" for resume()', async () => {
+    // Lifecycle 1: a fresh createAgent announces creation with source 'startup'.
     const adapter1 = new MockAdapter([textResponse('a')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
     const sources1: string[] = []
-    ctx1.on('agent/session-start', ({ source }) => void sources1.push(source))
+    ctx1.on('agent/created', ({ source }) => void sources1.push(source))
     const h1 = await ctx1.agents.create({ sessionId: SessionId('start-sess') })
     expect(sources1).toEqual(['startup'])
     h1.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
@@ -602,10 +602,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     await h1.dispose()
     await ctx1.fiber.dispose()
 
-    // Lifecycle 2: resuming the persisted session emits session-start 'resume'.
+    // Lifecycle 2: resuming the persisted session announces creation 'resume'.
     const ctx2 = await mountPersistentHarness(root, new MockAdapter([textResponse('b')]))
     const sources2: string[] = []
-    ctx2.on('agent/session-start', ({ source }) => void sources2.push(source))
+    ctx2.on('agent/created', ({ source }) => void sources2.push(source))
     await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') })
     expect(sources2).toEqual(['resume'])
     await ctx2.fiber.dispose()
@@ -626,11 +626,8 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     })
     ctx.on('agent/created', ({ agent }) => {
       expect(agent.status).toBe('idle')
-      order.push('agent/created')
-    })
-    ctx.on('agent/session-start', ({ agent }) => {
       expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow()
-      order.push('agent/session-start')
+      order.push('agent/created')
     })
 
     const resuming = ctx.agents.resume({
@@ -671,7 +668,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
       'setup-listener:session/created',
       'agent/created',
       'setup-listener:agent/created',
-      'agent/session-start',
     ])
     await handle.dispose()
     await ctx.fiber.dispose()
@@ -722,7 +718,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     const published: string[] = []
     ctx.on('session/created', () => void published.push('session/created'))
     ctx.on('agent/created', () => void published.push('agent/created'))
-    ctx.on('agent/session-start', () => void published.push('agent/session-start'))
 
     await expect(ctx.agents.resume({
       resumeSessionId: sessionId,
@@ -827,7 +822,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     const published: string[] = []
     ctx.on('session/created', () => void published.push('session/created'))
     ctx.on('agent/created', () => void published.push('agent/created'))
-    ctx.on('agent/session-start', () => void published.push('agent/session-start'))
 
     let resuming!: ReturnType<typeof ctx.agents.resume>
     const owner = await ctx.plugin(Object.assign((inner: Context) => {
@@ -846,7 +840,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }))
     await rejection
     expect(opens).toBe(2)
-    expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
+    expect(published).toEqual(['session/created', 'agent/created'])
 
     // Settlement of the abandoned backend open cannot resume the old
     // transaction: the late handle is closed, and no second publication lands
@@ -856,7 +850,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     await expect.poll(() => abandoned.close.mock.calls.length).toBe(1)
     expect(ctx.agents.get(sessionId)).toBe(retry.agent)
     expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
-    expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
+    expect(published).toEqual(['session/created', 'agent/created'])
 
     await retry.dispose()
     await ctx.fiber.dispose()

+ 105 - 44
packages/core/agent-loop/tests/scope-lifecycle.spec.ts

@@ -61,6 +61,97 @@ function disposeCurrentLifecycle(ownerCtx: Context): void {
 }
 
 describe('agent scope lifecycle', () => {
+  it('awaits serial creation before caller readiness', async () => {
+    const ctx = await harness()
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    const order: string[] = []
+    ctx.on('agent/created', async () => {
+      order.push('created:start')
+      entered.resolve(undefined)
+      await release.promise
+      order.push('created:end')
+    })
+    ctx.on('agent/created', () => { order.push('created:second') })
+    const creating = ctx.agentLoop.create(SessionId('serial-start')).then((agent) => {
+      order.push('ready')
+      return agent
+    })
+    try {
+      await entered.promise
+      expect(order).toEqual(['created:start'])
+      release.resolve(undefined)
+      await creating
+      expect(order).toEqual(['created:start', 'created:end', 'created:second', 'ready'])
+    } finally {
+      release.resolve(undefined)
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('retains both entries and scoped effects until an asynchronous creation listener settles on owner unload', async () => {
+    const ctx = await harness()
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    let ownerCtx!: Context
+    const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] }))
+    const order: string[] = []
+    const id = SessionId('serial-owner-dispose')
+    ctx.on('agent/created', async ({ agent }) => {
+      entered.resolve(undefined)
+      await release.promise
+      expect(ctx.agents.get(id)).toBe(agent)
+      expect(ctx.sessions.get(id)).toBe(agent.session)
+      expect(order).toEqual([])
+      order.push('created:end')
+    })
+    ctx.on('agent/disposed', () => { order.push('agent-disposed') })
+    ctx.on('session/disposed', () => { order.push('session-disposed') })
+    const creating = ownerCtx.agents.create({
+      sessionId: id,
+      setup(agentCtx) { agentCtx.effect(() => () => { order.push('scope-disposed') }) },
+    })
+    const rejected = expect(creating).rejects.toThrow('owner disposed during setup')
+    try {
+      await entered.promise
+      const disposing = owner.dispose()
+      expect(ctx.agents.get(id)).toBeDefined()
+      release.resolve(undefined)
+      await rejected
+      await disposing
+      expect(order).toEqual(['created:end', 'scope-disposed', 'agent-disposed', 'session-disposed'])
+      expect(ctx.agents.get(id)).toBeUndefined()
+      expect(ctx.sessions.get(id)).toBeUndefined()
+    } finally {
+      release.resolve(undefined)
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('rolls back both registries and the scope when asynchronous creation fails', async () => {
+    const adapter = new MockAdapter([textResponse('unexpected')])
+    const ctx = await harness(adapter)
+    const reason = new Error('async creation veto')
+    const order: string[] = []
+    ctx.on('agent/created', async ({ agent }) => {
+      agent.followup(createUserMessage({ content: text('queued during creation'), source: { kind: 'user' } }))
+      await Promise.resolve()
+      throw reason
+    })
+    ctx.on('agent/created', () => { order.push('later-listener') })
+    ctx.on('agent/disposed', () => { order.push('agent-disposed') })
+    ctx.on('session/disposed', () => { order.push('session-disposed') })
+    await expect(ctx.agents.create({
+      sessionId: SessionId('async-veto'),
+      setup(agentCtx) { agentCtx.effect(() => () => { order.push('scope-disposed') }) },
+    })).rejects.toBe(reason)
+    expect(order).toEqual(['scope-disposed', 'agent-disposed', 'session-disposed'])
+    expect(ctx.agents.list()).toEqual([])
+    expect(ctx.sessions.list()).toEqual([])
+    expect(adapter.requests).toHaveLength(0)
+    await ctx.fiber.dispose()
+  })
+
   it('rejects an already-aborted creation signal before publishing either object', async () => {
     const ctx = await harness()
     const reason = new Error('cancelled before creation')
@@ -241,12 +332,12 @@ describe('agent scope lifecycle', () => {
     expect(heard).toContain('a-sees:user-message')
   })
 
-  it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => {
+  it('runs setup in the guaranteed slot: scoped world complete before agent/created and the first assembly', async () => {
     const ctx = await harness()
     const order: string[] = []
-    ctx.on('agent/session-start', ({ agent }) => {
-      order.push('session-start')
-      // The scoped section is already registered by the time session-start fires.
+    ctx.on('agent/created', ({ agent }) => {
+      order.push('agent/created')
+      // The scoped section is already registered by the time agent/created fires.
       void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => {
         order.push(`persona:${assembly.sections.find(s => s.name === 'deployment:persona-prefix')?.text}`)
       })
@@ -261,8 +352,7 @@ describe('agent scope lifecycle', () => {
         agentCtx.systemPrompt.section({ name: 'deployment:persona-prefix', order: 0, text: 'You are the child.' })
       },
     })
-    await new Promise(resolve => setTimeout(resolve, 0))
-    expect(order).toEqual(['setup', 'session-start', 'persona:You are the child.'])
+    expect(order).toEqual(['setup', 'agent/created', 'persona:You are the child.'])
     await handle.dispose()
   })
 
@@ -277,7 +367,6 @@ describe('agent scope lifecycle', () => {
       order.push('session/created')
     })
     ctx.on('agent/created', () => void order.push('agent/created'))
-    ctx.on('agent/session-start', () => void order.push('agent/session-start'))
     const acceptedOptions = { provider: 'mock', model: 'mock' }
 
     const creating = ctx.agents.create({
@@ -315,7 +404,6 @@ describe('agent scope lifecycle', () => {
       'setup-listener:session/created',
       'agent/created',
       'setup-listener:agent/created',
-      'agent/session-start',
     ])
     await handle.dispose()
   })
@@ -745,33 +833,7 @@ describe('agent scope lifecycle', () => {
     await ctx.fiber.dispose()
   })
 
-  it('rechecks caller liveness after creation listeners before unlocking the driver', async () => {
-    const ctx = await harness()
-    const starts: string[] = []
-    let ownerCtx!: Context
-    let creating!: ReturnType<typeof ctx.agents.create>
-    ctx.on('agent/session-start', ({ agent }) => void starts.push(agent.id))
-    ctx.on('agent/created', ({ agent }) => {
-      if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx)
-    })
-
-    const owner = await ctx.plugin(Object.assign((inner: Context) => {
-      ownerCtx = inner
-      creating = inner.agents.create({
-        sessionId: SessionId('listener-dispose-s'),
-        agentOptions: { provider: 'mock', model: 'mock' },
-      })
-    }, { inject: ['agents'] }))
-
-    await expect(creating).rejects.toThrow(/owner disposed during setup/)
-    await owner.dispose()
-    expect(starts).toEqual([])
-    expect(ctx.agents.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
-    expect(ctx.sessions.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
-    await ctx.fiber.dispose()
-  })
-
-  it('rechecks caller liveness after session-start before starting the driver', async () => {
+  it('rechecks caller liveness after agent/created before starting the driver', async () => {
     const ctx = await harness()
     let ownerCtx!: Context
     let creating!: ReturnType<typeof ctx.agents.create>
@@ -780,15 +842,15 @@ describe('agent scope lifecycle', () => {
     let scopeDisposed = false
     let observerSawLive = false
     ctx.on('agent/status', ({ agent, status }) => {
-      if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status)
+      if (agent.id === SessionId('agent/created-dispose-s')) statuses.push(status)
     })
-    ctx.on('agent/session-start', ({ agent }) => {
-      if (agent.id !== SessionId('session-start-dispose-s')) return
+    ctx.on('agent/created', ({ agent }) => {
+      if (agent.id !== SessionId('agent/created-dispose-s')) return
       announced = agent
       disposeCurrentLifecycle(ownerCtx)
     })
-    ctx.on('agent/session-start', ({ agent }) => {
-      if (agent.id !== SessionId('session-start-dispose-s')) return
+    ctx.on('agent/created', ({ agent }) => {
+      if (agent.id !== SessionId('agent/created-dispose-s')) return
       expect(ctx.agents.get(agent.id)).toBe(agent)
       expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
       agent.ctx.effect(() => () => { scopeDisposed = true })
@@ -798,7 +860,7 @@ describe('agent scope lifecycle', () => {
     const owner = await ctx.plugin(Object.assign((inner: Context) => {
       ownerCtx = inner
       creating = inner.agents.create({
-        sessionId: SessionId('session-start-dispose-s'),
+        sessionId: SessionId('agent/created-dispose-s'),
         agentOptions: { provider: 'mock', model: 'mock' },
       })
     }, { inject: ['agents'] }))
@@ -810,8 +872,8 @@ describe('agent scope lifecycle', () => {
     expect(observerSawLive).toBe(true)
     expect(scopeDisposed).toBe(true)
     expect(announced.session.snapshotEvents()).toEqual([])
-    expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined()
-    expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
+    expect(ctx.agents.get(SessionId('agent/created-dispose-s'))).toBeUndefined()
+    expect(ctx.sessions.get(SessionId('agent/created-dispose-s'))).toBeUndefined()
     await ctx.fiber.dispose()
   })
 
@@ -820,7 +882,6 @@ describe('agent scope lifecycle', () => {
     const published: string[] = []
     ctx.on('session/created', () => void published.push('session/created'))
     ctx.on('agent/created', () => void published.push('agent/created'))
-    ctx.on('agent/session-start', () => void published.push('agent/session-start'))
     await expect(ctx.agents.create({
       sessionId: SessionId('bad-s'),
       agentOptions: { provider: 'mock', model: 'mock' },

+ 123 - 0
packages/core/agent-loop/tests/serial-listener-review.spec.ts

@@ -0,0 +1,123 @@
+import { setImmediate } from 'node:timers/promises'
+import { afterEach, describe, expect, it } from 'vitest'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
+import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
+import LocalFileReference from '@deepseek-ai/dsh-file-reference-local'
+import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
+import * as SubagentTool from '@deepseek-ai/dsh-tool-subagent'
+import Selection from '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
+import * as Schedule from '@deepseek-ai/dsh-schedule'
+
+const roots: string[] = []
+const contexts: Context[] = []
+afterEach(async () => {
+  for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
+async function core(persistenceRoot?: string) {
+  const ctx = new Context()
+  contexts.push(ctx)
+  await mountAgentLoopTestDependencies(ctx)
+  if (persistenceRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' })
+  await ctx.plugin(AgentLoop, { agents: [] })
+  return ctx
+}
+
+describe('serial creation listener integrations', () => {
+  it('rolls back creation when file-reference prompt installation fails', async () => {
+    const ctx = await core()
+    const errors: string[] = []
+    ctx.logger.error = ((error: unknown) => { errors.push(String(error)) }) as typeof ctx.logger.error
+    await ctx.plugin(LocalFileReference)
+    try {
+      await expect(ctx.agents.create({
+        sessionId: SessionId('review-file-reference'),
+        setup(agentCtx) {
+          agentCtx.systemPrompt.section({ name: 'context:file-reference', order: 0, text: 'occupied' })
+        },
+      })).rejects.toThrow('context:file-reference')
+      expect(errors.some(error => error.includes('context:file-reference'))).toBe(true)
+      expect(ctx.agents.list()).toEqual([])
+      expect(ctx.sessions.list()).toEqual([])
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('rolls back creation when a shared preset tool installation fails', async () => {
+    const ctx = await core()
+    const errors: string[] = []
+    ctx.logger.error = ((error: unknown) => { errors.push(String(error)) }) as typeof ctx.logger.error
+    await ctx.plugin(Selection)
+    await ctx.plugin(SubagentRuntime)
+    await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
+    const preset = createScope(ctx, { preset: 'review' })
+    await preset.ctx.plugin(SubagentTool, { provider: 'spawn', modelSelectionSettings: true, backgroundMode: 'continuable' })
+    try {
+      await expect(ctx.agents.create({
+        sessionId: SessionId('review-tool'),
+        setup(agentCtx) {
+          bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!)
+          agentCtx.tools.register(defineContentToolFixture({ name: 'subagent', description: 'occupied', parameters: {}, execute: () => Promise.resolve([]) }))
+        },
+      })).rejects.toThrow('subagent')
+      expect(errors.some(error => error.includes('subagent'))).toBe(true)
+      expect(ctx.agents.list()).toEqual([])
+      expect(ctx.sessions.list()).toEqual([])
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('starts due Schedule work only after every creation listener finishes', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-serial-schedule-'))
+    roots.push(root)
+    const ctx = await core(root)
+    await ctx.plugin(Schedule)
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    const turn = Promise.withResolvers<undefined>()
+    let turns = 0
+    let created = false
+    let laterCreated = false
+    ctx.on('agent/created', async () => { entered.resolve(undefined); await release.promise; created = true })
+    ctx.on('agent/created', () => { laterCreated = true })
+    ctx.on('session/event', (_session, event) => { if (event.type === 'turn/start') { turns += 1; turn.resolve(undefined) } })
+    const creating = ctx.agents.create({
+      sessionId: SessionId('review-schedule'),
+      setup(_agentCtx, agent) {
+        agent.session.append('schedule/change', {
+          version: 1,
+          operation: 'create',
+          schedule: Schedule.createAfterScheduleRecord(Schedule.ScheduleId('schedule-1'), 'due reminder', 1, Date.now() - 2_000),
+        })
+      },
+    })
+    try {
+      await entered.promise
+      // A full event-loop turn lets the due runtime run if it starts during creation.
+      await setImmediate()
+      expect(turns).toBe(0)
+      expect(laterCreated).toBe(false)
+      release.resolve(undefined)
+      await creating
+      await turn.promise
+      expect(created).toBe(true)
+      expect(laterCreated).toBe(true)
+    } finally {
+      release.resolve(undefined)
+      await creating
+      await ctx.fiber.dispose()
+    }
+  })
+})

+ 2 - 2
packages/core/agent/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/core/agent/README.md
-README.md: 82460df7492d470a55c37c4691af2a7d349144fe
-README.zh.md: 9ece26dbacad4576a72114573af3d77fa41693e8
+README.md: 8ba3dc730790acb0f792173e9ee01d2dc954957e
+README.zh.md: 0718ae5f3f73b1fd0bb5b600de3a08c50e67421e

+ 2 - 2
packages/core/agent/README.md

@@ -104,7 +104,7 @@ The package is built on one separation: the public `Agent` surface and registry
 
 ### Registry and lifecycle
 
-`AgentRegistry` keeps one entry per live agent with its carrier and creator relation. `register()` records an already-constructed agent; the async factory uses the split `enter()`/`announce()` pair so setup and publication stay rollback-covered. A detach requested during a creation dispatch waits for that dispatch to unwind, and each detach is bound to the exact entry, so a stale disposer cannot remove a later same-id replacement. Teardown order is stop-and-drain the loop, unwind the scope, detach the agent, detach the session; the id becomes reusable after private cleanup.
+`AgentRegistry` keeps one entry per live agent with its carrier and creator relation. Await `register()` to finish serial creation listeners with source `startup` before using an already-constructed agent; the async factory uses the split `enter()`/`announce()` pair so setup and initialization stay rollback-covered. A detach requested during creation waits for every awaited listener to settle, and each detach is bound to the exact entry, so a stale disposer cannot remove a later same-id replacement. Teardown stops and drains the loop, unwinds the scope, detaches the agent, then detaches the session; the id becomes reusable after private cleanup.
 
 ### Initiator scope
 
@@ -171,7 +171,7 @@ These limits define when this package needs special care. They are current packa
 
 - **Initiator scope is process-local** — workers, child processes, HTTP, durable queues, and restarts must materialize any required identity explicitly.
 - **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
-- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx, agent)` transaction instead.
+- **Creation listeners share the initialization lifetime.** An `agent/created` listener must not await `agent.whenIdle()` or its own owner's disposal: those operations wait for creation to finish. Return only after required asynchronous tool and prompt installation completes.
 - **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items, and there is no step-only abort that keeps the turn running.
 - **Each additional `UserMessage` carries exactly one `MessageSource`** — contributions from several plugins merged onto one message collapse under one source, so the message cannot name several producers.
 

+ 2 - 2
packages/core/agent/README.zh.md

@@ -104,7 +104,7 @@ await handle.agent.whenIdle()
 
 ### 注册表与生命周期
 
-`AgentRegistry` 为每个实时 agent 保留一个条目,含其载体与创建者关系。`register()` 记录一个已构造完成的 agent;异步工厂使用拆分的 `enter()`/`announce()` 对,使 setup 与发布始终处于回滚保护之下。创建分发期间请求的 detach 会等待该次分发退栈,且每次 detach 都绑定到确切条目,因此陈旧 disposer 无法移除之后出现的同 id 替代项。Teardown 顺序是停止并排空循环、撤销作用域、detach agentdetach 会话;私有清理完成后该 id 即可复用。
+`AgentRegistry` 为每个实时 agent 保留一个条目,含其载体与创建者关系。使用已构造的 agent 前,等待 `register()` 以 `startup` 来源完成串行创建监听器;异步工厂使用拆分的 `enter()`/`announce()` 对,使 setup 与初始化始终受回滚保护。创建期间请求的 detach 会等待所有已调用的异步监听器结算,且每次 detach 都绑定到确切条目,因此陈旧 disposer 无法移除之后出现的同 id 替代项。Teardown 停止并排空循环、撤销作用域、detach agent,再 detach 会话;私有清理完成后该 id 即可复用。
 
 ### 发起方作用域
 
@@ -171,7 +171,7 @@ await handle.agent.whenIdle()
 
 - **发起方作用域只存在于进程内**:worker、子进程、HTTP、持久队列和重启必须显式传递所需身份。
 - **环境身份可能比存活状态更久**:消费方在生命周期敏感工作前,仍要检查 `agent.status`、取消状态和所属能力约定。
-- **`agent/session-start` 不能为启动设置门禁**:它仍是同步且不可 veto 的通知;必须在发布前完成的异步组合属于工厂的 `setup(agentCtx, agent)` 事务
+- **创建监听器共享初始化生命周期。** `agent/created` 监听器不得等待 `agent.whenIdle()` 或自身所有者的 dispose:这些操作要等待创建完成。所需的异步工具与提示词安装完成后,监听器才可返回
 - **`cancel()` 默认清空收件箱**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项,且不存在让轮次继续运行、只中止步骤的操作。
 - **每条附加 `UserMessage` 恰好携带一个 `MessageSource`**:多个插件合并到一条消息上的贡献会归入同一来源,因此该消息无法列出多个生产者。
 

+ 24 - 28
packages/core/agent/src/index.ts

@@ -13,7 +13,7 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { SessionEvent, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
 import type { Agent } from './types.ts'
-import type { AgentOptions } from './runtime-types.ts'
+import type { AgentOptions, SessionStartSource } from './runtime-types.ts'
 
 export * from './runtime-types.ts'
 export * from './types.ts'
@@ -107,7 +107,7 @@ export interface CreateAgentOptions {
    * the exact publication boundary. Everything registered through `agentCtx`
    * (scoped tools, prompt sections/variables, `restrict()`, listeners, awaited
    * child plugins) exists before `session/created`, `agent/created`,
-   * `agent/session-start`, and the first prompt assembly. A setup
+   * and the first prompt assembly. A setup
    * throw/rejection, commit throw, or owner disposal rolls the scope back
    * without publishing either id.
    *
@@ -172,8 +172,8 @@ export interface AgentFactory {
   /**
    * Create a new agent on a caller-supplied session id. Async because creation
    * awaits unpublished setup, invokes its optional synchronous commit, inserts
-   * both session and agent, emits their creation notifications in order, emits
-   * `agent/session-start`, and only then starts the loop. The sequence is
+   * both session and agent, announces session creation, and awaits serial
+   * `agent/created` listeners before releasing queued work. The sequence is
    * rollback-covered, but notifications delivered before a later listener
    * failure remain observable; every agent or session creation announcement
    * that began is paired by `agent/disposed` or `session/disposed` during
@@ -185,7 +185,7 @@ export interface AgentFactory {
    * ownership from the factory object's registration context.
    * @param ownerCtx - caller-bound context that owns the transaction and live handle.
    * @param options - agent/session identity, configuration, optional live parent, and setup.
-   * @returns the owned handle after setup, both announcements, and loop start complete.
+   * @returns the owned handle after setup and both creation announcements complete.
    */
   createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
   /**
@@ -413,16 +413,16 @@ export class AgentRegistry extends Service {
   }
 
   /**
-   * Register a live agent. Throws if an agent with the same id is already
-   * registered. Emits `agent/created` on registration and `agent/disposed`
+   * Register a live agent with source `startup`. Rejects if the id is already registered or a
+   * serial `agent/created` listener fails. Emits `agent/disposed`
    * when the calling fiber is disposed — both with the agent's scope carrier
    * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the
    * emits are scope-filtered regardless of which context invoked `register`
    * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always
    * requires passing the carrier). The entry is a runtime root; factory-backed
-   * creation uses `options.parentAgent` for child ownership. Returns the disposer.
+   * creation uses `options.parentAgent` for child ownership. Await the registration before using the agent.
    * @param agent - the already-constructed agent to record in the store.
-   * @returns the EXACT Cordis effect disposer (single-shot; a repeat call
+   * @returns the awaitable Cordis effect disposer (single-shot; a repeat call
    *   returns undefined without awaiting an in-flight teardown). Exact
    *   identity is load-bearing: a composite (generator) effect that owns a
    *   teardown ORDER — the agent factory's lifecycle chain — must yield THIS
@@ -431,13 +431,11 @@ export class AgentRegistry extends Service {
    *   owner unload, unregistering the agent (and emitting `agent/disposed`)
    *   while its final turn is still draining.
    */
-  register(agent: Agent): () => void {
-    const dispose = this.ctx.effect(function* (this: AgentRegistry) {
+  register(agent: Agent): ReturnType<Context['effect']> {
+    return this.ctx.effect(async function* (this: AgentRegistry) {
       yield this.enter(agent, undefined)
-      this.announce(agent)
+      await this.announce(agent, 'startup')
     }.bind(this), 'agents.register()')
-    // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
-    return dispose
   }
 
   /**
@@ -452,8 +450,8 @@ export class AgentRegistry extends Service {
    *   the resumed session's durable parent lineage.
    * @returns an idempotent closure that removes this exact entry and emits
    *   `agent/disposed` with listener failures contained. When called from a
-   *   synchronous `agent/created` listener, removal and disposal wait until
-   *   that creation dispatch unwinds.
+   *   `agent/created` listener, removal and disposal wait until the serial
+   *   creation dispatch settles.
    */
   enter(agent: Agent, owner: Agent | undefined): () => void {
     const id = agent.id
@@ -482,7 +480,7 @@ export class AgentRegistry extends Service {
       // live entry, and disposal must follow creation. A listener may own
       // the advanced detach capability, so make that ordering structural:
       // visibility and the paired disposal are deferred until announce()'s
-      // synchronous dispatch has unwound.
+      // serial dispatch has settled.
       if (entry.announcing) {
         entry.detachRequested = true
         return
@@ -526,11 +524,14 @@ export class AgentRegistry extends Service {
   /**
    * Announce an agent previously inserted with {@link enter}.
    * @param agent - the live inserted agent to announce.
+   * @param source - fresh creation, resume, clear, or compaction source.
+   * @param signal - optional factory initialization cancellation signal passed to listeners.
+   * @returns completion of the serial creation listeners; a listener failure rejects.
    * @throws if `agent` is not the exact live registry entry for its id, or its
    *   creation announcement already began (including a reentrant call from a
    *   creation listener).
    */
-  announce(agent: Agent): void {
+  async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): Promise<void> {
     const entry = this.store.get(agent.id)
     if (entry === undefined || entry.agent !== agent) {
       throw new Error(`agent "${agent.id}" is not live in this registry`)
@@ -542,17 +543,12 @@ export class AgentRegistry extends Service {
     // lifecycle edge; detach still pairs a partially delivered first edge.
     entry.announcing = true
     entry.announced = true
-    const args: unknown[] = [entry.carrier, 'agent/created', { agent: entry.agent }]
     try {
-      for (const callback of this.ctx.events.dispatch('emit', args)) {
-        // A synchronous creation failure vetoes publication and rolls back.
-        // Returned-promise rejection happens after this synchronous boundary, so
-        // observe and report it instead of leaking an unhandled rejection.
-        const returned: unknown = callback(...args)
-        void Promise.resolve(returned).catch((error: unknown) => {
-          this.ctx.logger.warn(`agent "${entry.id}": agent/created listener rejected: ${String(error)}`)
-        })
-      }
+      await this.ctx.serial(entry.carrier, 'agent/created', {
+        agent: entry.agent,
+        source,
+        ...signal === undefined ? {} : { signal },
+      })
     } finally {
       entry.announcing = false
       if (entry.detachRequested) this.detachEntered(entry)

+ 11 - 21
packages/core/agent/src/runtime-types.ts

@@ -244,18 +244,21 @@ declare module './types.ts' {
 
 declare module '@deepseek-ai/cordis' {
   interface Events {
-    // ---- lifecycle (emit) ----
+    // ---- lifecycle ----
     /**
-     * A fully configured agent and live session were published. Setup is
-     * composition-only; `agent/session-start` is the first startup-driving extension point.
-     * Synchronous listener failure vetoes publication, while returned-promise
-     * rejection is reported. Detach requested during dispatch waits until every
-     * creation listener has observed the stable entry.
+     * An entered agent is ready for per-agent initialization after factory setup.
+     * Listeners run in order and are awaited before creation resolves. AgentLoop
+     * holds queued input until all listeners finish. A throw or rejection fails
+     * creation and skips later listeners. Disposal retains the scope and session
+     * until dispatch settles; listeners must not await agent.whenIdle() or their
+     * own owner's disposal.
      * @param payload.agent - the newly registered agent with its live session and completed setup.
+     * @param payload.source - fresh creation, resume, clear, or compaction source.
+     * @param payload.signal - factory initialization cancellation signal, when provided.
      * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-     * @mode emit
+     * @mode serial
      */
-    'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
+    'agent/created'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource; signal?: AbortSignal }): undefined | Promise<undefined>
     /**
      * An agent left the registry; AgentLoop emits this after driver quiescence
      * and scoped-registration unwind, but before session detachment. Custom
@@ -302,19 +305,6 @@ declare module '@deepseek-ai/cordis' {
      * @mode emit
      */
     'agent/inbox/discarded'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
-    // ---- session lifecycle (emit) ----
-    /**
-     * The session lifecycle began, once before the first turn. Use
-     * `agent.inject()` to seed model-facing context. This is a notification, not
-     * a veto; disposal requested by a lifecycle owner is rechecked before the
-     * driver starts.
-     * @param payload.agent - the agent whose session lifecycle began.
-     * @param payload.source - why the session started (fresh startup, resume, …).
-     * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
-     * @mode emit
-     */
-    'agent/session-start'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void
-
     // ---- the machine's extension points ----
     /**
      * Reject a proposed step or replace the messages that enter it. Calling

+ 49 - 20
packages/core/agent/tests/agent.spec.ts

@@ -45,7 +45,7 @@ describe('AgentRegistry', () => {
     await agentFiber
     await ctx.plugin(TypertRegistry)
     const agent = stubAgent('remote-agent')
-    const disposeAgent = ctx.agents.register(agent)
+    const disposeAgent = await ctx.agents.register(agent)
 
     const lookup = ctx.typert.lookups.get('agent')
     expect(lookup).toMatchObject({
@@ -58,7 +58,7 @@ describe('AgentRegistry', () => {
     const context = ctx.typert.contexts.getHost('agent')
     expect(context?.resolve(agent.id)).toBe(agent.ctx)
 
-    disposeAgent()
+    await disposeAgent()
     expect(lookup?.resolve(agent.id)).toBeUndefined()
     await agentFiber.dispose()
     expect(ctx.typert.lookups.get('agent')).toBeUndefined()
@@ -73,13 +73,13 @@ describe('AgentRegistry', () => {
     ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
 
     const agent = stubAgent('a1')
-    const dispose = ctx.agents.register(agent)
+    const dispose = await ctx.agents.register(agent)
     expect(ctx.agents.get(agent.id)).toBe(agent)
     expect(ctx.agents.list()).toEqual([agent])
     expect(ctx.agents.roots()).toEqual([agent])
-    expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
+    await expect(Promise.resolve(ctx.agents.register(stubAgent('a1')))).rejects.toThrow(/already registered/)
 
-    dispose()
+    await dispose()
     expect(ctx.agents.get(agent.id)).toBeUndefined()
     expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
   })
@@ -100,9 +100,9 @@ describe('AgentRegistry', () => {
     const root = stubAgent('root')
     const child = stubAgent('child')
     const detachRoot = ctx.agents.enter(root, undefined)
-    ctx.agents.announce(root)
+    await ctx.agents.announce(root, 'startup')
     const detachChild = ctx.agents.enter(child, root)
-    ctx.agents.announce(child)
+    await ctx.agents.announce(child, 'startup')
 
     expect(ctx.agents.list()).toEqual([root, child])
     expect(ctx.agents.roots()).toEqual([root])
@@ -123,35 +123,64 @@ describe('AgentRegistry', () => {
     ctx.on('agent/created', () => { throw new Error('creation veto') })
     ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
 
-    expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
+    await expect(Promise.resolve(ctx.agents.register(stubAgent('vetoed')))).rejects.toThrow('creation veto')
     expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined()
     expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
   })
 
-  it('contains asynchronous creation rejection and every disposal-listener failure', async () => {
+  it('rolls back asynchronous creation rejection and contains every disposal-listener failure', async () => {
     const ctx = new Context()
     await ctx.plugin(AgentRegistry)
     const warnings: string[] = []
     const heard: string[] = []
     ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
-    ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never)
+    ctx.on('agent/created', () => Promise.reject(new Error('created async')))
+    ctx.on('agent/created', () => { heard.push('unreachable') })
     ctx.on('agent/disposed', () => { throw new Error('disposed sync') })
     ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never)
     ctx.on('agent/disposed', ({ agent }) => void heard.push(agent.id))
 
-    const dispose = ctx.agents.register(stubAgent('contained'))
-    await Promise.resolve()
-    dispose()
+    await expect(Promise.resolve(ctx.agents.register(stubAgent('contained')))).rejects.toThrow('created async')
     await Promise.resolve()
 
     expect(heard).toEqual(['contained'])
+    expect(ctx.agents.list()).toEqual([])
     expect(warnings).toEqual([
-      'agent "contained": agent/created listener rejected: Error: created async',
       'agent "contained": agent/disposed listener threw: Error: disposed sync',
       'agent "contained": agent/disposed listener rejected: Error: disposed async',
     ])
   })
 
+  it('awaits each creation listener before the next listener and registration completion', async () => {
+    const ctx = new Context()
+    await ctx.plugin(AgentRegistry)
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    const order: string[] = []
+    ctx.on('agent/created', async () => {
+      order.push('first:start')
+      entered.resolve(undefined)
+      await release.promise
+      order.push('first:end')
+    })
+    ctx.on('agent/created', () => { order.push('second') })
+    const agent = stubAgent('serial')
+    const registration = ctx.agents.register(agent)
+    const ready = Promise.resolve(registration).then(() => { order.push('ready') })
+    try {
+      await entered.promise
+      expect(order).toEqual(['first:start'])
+      await expect(ctx.agents.announce(agent, 'startup')).rejects.toThrow('already announced')
+      release.resolve(undefined)
+      await ready
+      expect(order).toEqual(['first:start', 'first:end', 'second', 'ready'])
+    } finally {
+      release.resolve(undefined)
+      await registration()
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => {
     const ctx = new Context()
     await ctx.plugin(AgentRegistry)
@@ -162,8 +191,8 @@ describe('AgentRegistry', () => {
     const first = stubAgent('split')
     const detachFirst = ctx.agents.enter(first, undefined)
     expect(lifecycle).toEqual([])
-    ctx.agents.announce(first)
-    expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/)
+    await ctx.agents.announce(first, 'startup')
+    await expect(ctx.agents.announce(first, 'startup')).rejects.toThrow(/already announced/)
     detachFirst()
     detachFirst()
 
@@ -171,7 +200,7 @@ describe('AgentRegistry', () => {
     const detachReplacement = ctx.agents.enter(replacement, undefined)
     detachFirst()
     expect(ctx.agents.get(replacement.id)).toBe(replacement)
-    expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
+    await expect(ctx.agents.announce(first, 'startup')).rejects.toThrow(/not live/)
     detachReplacement()
     expect(lifecycle).toEqual(['created:split', 'disposed:split'])
   })
@@ -189,7 +218,7 @@ describe('AgentRegistry', () => {
     ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`))
     ctx.on('agent/disposed', () => void order.push('disposed'))
     const detach = ctx.agents.enter(agent, undefined)
-    ctx.agents.announce(agent)
+    await ctx.agents.announce(agent, 'startup')
     expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed'])
     expect(ctx.agents.get(agent.id)).toBeUndefined()
   })
@@ -296,12 +325,12 @@ describe('AgentRegistry factory seam', () => {
     const { factory, calls } = stubFactory()
     ctx.agents.setFactory(factory)
     const parent = stubAgent('parent')
-    const unregister = ctx.agents.register(parent)
+    const unregister = await ctx.agents.register(parent)
 
     await ctx.agents.create({ sessionId: SessionId('child'), parentAgent: parent })
 
     expect(calls.create[0]?.options.parentAgent).toBe(parent)
-    unregister()
+    await unregister()
   })
 
   it('rejects a second factory and clears the slot with its owner (HMR)', async () => {

+ 0 - 1
packages/core/scope/src/scoped-events.generated.ts

@@ -18,7 +18,6 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
   'agent/pre-step': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/request': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/request-error': args => (args[0] as Record<string, unknown>)['agent'],
-  'agent/session-start': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/status': args => (args[0] as Record<string, unknown>)['agent'],
   'agent/turn-stopping': args => (args[0] as Record<string, unknown>)['agent'],
   'approval/request': args => (args[0] as Record<string, unknown>)['agent'],

+ 1 - 2
packages/core/scope/tests/invariant.spec.ts

@@ -45,13 +45,12 @@ describe('scoped-dispatch invariants', () => {
       source: { kind: 'user' },
     })
     const agentRows = {
-      'agent/created': [{ agent }],
+      'agent/created': [{ agent, source: 'startup' }],
       'agent/disposed': [{ agent }],
       'agent/status': [{ agent, status: 'idle' }],
       'agent/inbox/inserted': [{ agent, message }],
       'agent/inbox/claimed': [{ agent, message, turn: 1 }],
       'agent/inbox/discarded': [{ agent, message }],
-      'agent/session-start': [{ agent, source: 'startup' }],
       'agent/pre-step': [{ agent, messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })],
       'agent/request': [{ agent, turn: 1, step: 1, signal }, () => Promise.resolve(config)],
       'agent/assistant-stream': [{

+ 1 - 1
packages/experimental/agent-team/src/index.ts

@@ -108,7 +108,7 @@ export class TeamService extends TypertRemoteService {
     this.tasks = new TeamTaskBoard(this.journal, this.config.maxTasks)
 
     ctx.on('session/event', (session, event) => { this.mailbox.observeSessionEvent(session, event) })
-    ctx.on('agent/session-start', ({ agent }) => { this.scheduleRecovery(agent) })
+    ctx.on('agent/created', ({ agent }) => { this.scheduleRecovery(agent) })
     ctx.on('agent/status', ({ agent }) => {
       const membership = this.roster.tryMembership(agent)
       if (membership !== undefined) this.activity.notify(membership.id)

+ 12 - 19
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -301,21 +301,22 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'the handle after setup, rollback-covered publication, and loop start complete.',
       },
       {
-        signature: 'register(agent: Agent): () => void',
-        description: 'Register a live agent. Throws if an agent with the same id is already registered. Emits `agent/created` on registration and `agent/disposed` when the calling fiber is disposed — both with the agent\'s scope carrier (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the emits are scope-filtered regardless of which context invoked `register` (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always requires passing the carrier). The entry is a runtime root; factory-backed creation uses `options.parentAgent` for child ownership. Returns the disposer.',
+        signature: 'register(agent: Agent): ReturnType<Context[\'effect\']>',
+        description: 'Register a live agent with source `startup`. Rejects if the id is already registered or a serial `agent/created` listener fails. Emits `agent/disposed` when the calling fiber is disposed — both with the agent\'s scope carrier (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the emits are scope-filtered regardless of which context invoked `register` (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always requires passing the carrier). The entry is a runtime root; factory-backed creation uses `options.parentAgent` for child ownership. Await the registration before using the agent.',
         parameters: [{ name: 'agent', description: 'the already-constructed agent to record in the store.' }],
-        returns: 'the EXACT Cordis effect disposer (single-shot; a repeat call returns undefined without awaiting an in-flight teardown). Exact identity is load-bearing: a composite (generator) effect that owns a teardown ORDER — the agent factory\'s lifecycle chain — must yield THIS function so Cordis nests the unregistration at that yield position; yielding a wrapper would leave it disposing as a concurrent sibling on owner unload, unregistering the agent (and emitting `agent/disposed`) while its final turn is still draining.',
+        returns: 'the awaitable Cordis effect disposer (single-shot; a repeat call returns undefined without awaiting an in-flight teardown). Exact identity is load-bearing: a composite (generator) effect that owns a teardown ORDER — the agent factory\'s lifecycle chain — must yield THIS function so Cordis nests the unregistration at that yield position; yielding a wrapper would leave it disposing as a concurrent sibling on owner unload, unregistering the agent (and emitting `agent/disposed`) while its final turn is still draining.',
       },
       {
         signature: 'enter(agent: Agent, owner: Agent | undefined): () => void',
         description: 'Insert an already-constructed agent without announcing it. This is the advanced ordered-lifecycle primitive used by the async agent factory: it first completes setup while the agent is unpublished, then assigns the returned detach closure into its pre-installed composite teardown before calling announce. Ordinary callers use register.',
         parameters: [{ name: 'agent', description: 'the prepared, unpublished agent.' }, { name: 'owner', description: 'explicitly supplied live runtime owner, or undefined for a top-level runtime root. This is runtime ownership, not the resumed session\'s durable parent lineage.' }],
-        returns: 'an idempotent closure that removes this exact entry and emits `agent/disposed` with listener failures contained. When called from a synchronous `agent/created` listener, removal and disposal wait until that creation dispatch unwinds.',
+        returns: 'an idempotent closure that removes this exact entry and emits `agent/disposed` with listener failures contained. When called from a `agent/created` listener, removal and disposal wait until the serial creation dispatch settles.',
       },
       {
-        signature: 'announce(agent: Agent): void',
+        signature: 'async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): Promise<void>',
         description: 'Announce an agent previously inserted with enter.',
-        parameters: [{ name: 'agent', description: 'the live inserted agent to announce.' }],
+        parameters: [{ name: 'agent', description: 'the live inserted agent to announce.' }, { name: 'source', description: 'fresh creation, resume, clear, or compaction source.' }, { name: 'signal', description: 'optional factory initialization cancellation signal passed to listeners.' }],
+        returns: 'completion of the serial creation listeners; a listener failure rejects.',
         throws: ['if `agent` is not the exact live registry entry for its id, or its creation announcement already began (including a reentrant call from a creation listener).'],
       },
       {
@@ -3187,11 +3188,11 @@ export const EVENT_API: readonly EventApiEntry[] = [
   },
   {
     name: 'agent/created',
-    mode: 'emit',
-    signature: '\'agent/created\'(this: Scoped<Agent>, payload: { agent: Agent }): void',
-    summary: 'A fully configured agent and live session were published.',
-    description: 'A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving extension point. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry.',
-    parameters: [{ name: 'payload', description: '.agent - the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }],
+    mode: 'serial',
+    signature: '\'agent/created\'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource; signal?: AbortSignal }): undefined | Promise<undefined>',
+    summary: 'An entered agent is ready for per-agent initialization after factory setup.',
+    description: 'An entered agent is ready for per-agent initialization after factory setup. Listeners run in order and are awaited before creation resolves. AgentLoop holds queued input until all listeners finish. A throw or rejection fails creation and skips later listeners. Disposal retains the scope and session until dispatch settles; listeners must not await agent.whenIdle() or their own owner\'s disposal.',
+    parameters: [{ name: 'payload', description: '.signal - factory initialization cancellation signal, when provided. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }],
   },
   {
     name: 'agent/disposed',
@@ -3257,14 +3258,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
     description: 'Handle one failed model-request attempt before the loop retries or closes its step. A listener returns `{ kind: \'retry\' }` without calling `next()` when it owns recovery, or calls `next()` to delegate. The default `undefined` leaves the failure terminal.',
     parameters: [{ name: 'payload', description: '.signal - the turn abort signal. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }],
   },
-  {
-    name: 'agent/session-start',
-    mode: 'emit',
-    signature: '\'agent/session-start\'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void',
-    summary: 'The session lifecycle began, once before the first turn.',
-    description: 'The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts.',
-    parameters: [{ name: 'payload', description: '.source - why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.' }],
-  },
   {
     name: 'agent/status',
     mode: 'emit',

+ 1 - 1
packages/feedback/command-feedback/tests/command-feedback.spec.ts

@@ -57,7 +57,7 @@ async function harness(): Promise<Harness> {
   await ctx.plugin(SessionStore)
   const plugin = await ctx.plugin(commandFeedback)
   const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return { ctx, agent, session, plugin }
 }
 

+ 3 - 3
packages/feedback/command-feedback/tests/loader-composition.spec.ts

@@ -26,7 +26,7 @@ afterEach(async () => {
 })
 
 /** Register one idle agent over a store-owned session, as an app's spine does. */
-function agent(ctx: Context): Agent {
+async function agent(ctx: Context): Promise<Agent> {
   const scope = ctx.plugin(() => {})
   const id = SessionId('feedback-loader-agent')
   const session = ctx.sessions.create(id)
@@ -46,7 +46,7 @@ function agent(ctx: Context): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 
@@ -83,7 +83,7 @@ describe('/feedback real Loader composition through cordis.yml', () => {
     await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
     await context.loader.await()
 
-    const owner = agent(context)
+    const owner = await agent(context)
     const signal = new AbortController().signal
 
     // Discoverable through the composed registry, as a UI adapter finds it.

+ 1 - 1
packages/fs/tool-present/tests/built-errors.e2e.ts

@@ -32,7 +32,7 @@ try {
   const scope = ctx.plugin(() => {})
   const session = Session.create('built-present', [], { version: SESSION_FORMAT_VERSION, id: 'built-present', createdAt: 0, cwd: root, isSeeded: false })
   const owner = { id: 'built-present', session, ctx: scope.ctx, options: {}, status: 'idle' }
-  ctx.agents.register(owner)
+  await ctx.agents.register(owner)
   const events = []
   ctx.on('tools/result', (_exec, result) => events.push(result))
   let n = 0

+ 1 - 1
packages/fs/tool-present/tests/present.spec.ts

@@ -46,7 +46,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise<Agent> {
     whenIdle: () => Promise.resolve(),
   }
   await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, value) }, { inject: ['tools'] }))
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 

+ 3 - 3
packages/fs/tool-str-replace-editor/tests/tools.spec.ts

@@ -27,7 +27,7 @@ afterEach(async () => {
   for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
 })
 
-function agent(ctx: Context, cwd: string): Agent {
+async function agent(ctx: Context, cwd: string): Promise<Agent> {
   const id = SessionId(`str-replace-editor-owner-${callNumber}`)
   const scope = ctx.plugin(() => {})
   const session = Session.create(id, [], {
@@ -48,7 +48,7 @@ function agent(ctx: Context, cwd: string): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle: () => Promise.resolve(),
   }
-  ctx.agents.register(value)
+  await ctx.agents.register(value)
   return value
 }
 
@@ -88,7 +88,7 @@ async function setup(
   }
   if (options.fsPolicy === true) await ctx.plugin(FsPolicy)
   const fiber = await ctx.plugin(ToolStrReplaceEditor, config)
-  return { ctx, root, fiber, owner: agent(ctx, root) }
+  return { ctx, root, fiber, owner: await agent(ctx, root) }
 }
 
 describe('tool-str-replace-editor', () => {

+ 1 - 1
packages/goal/command-goal/tests/command-goal.spec.ts

@@ -52,7 +52,7 @@ async function harness(): Promise<Harness> {
   await ctx.plugin(GoalService)
   const plugin = await ctx.plugin(commandGoal)
   const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`)
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return { ctx, agent, session, plugin }
 }
 

+ 1 - 2
packages/goal/goal-round-driver/src/index.ts

@@ -248,9 +248,8 @@ export function apply(ctx: Context): void {
       disarm(state)
     })
 
-    ctx.on('agent/created', ({ agent }) => { stateFor(agent) })
     ctx.on('agent/disposed', ({ agent }) => { states.delete(agent) })
-    ctx.on('agent/session-start', ({ agent }) => {
+    ctx.on('agent/created', ({ agent }) => {
       const state = stateFor(agent)
       state.attempt = undefined
       state.competingQueued = false

+ 1 - 1
packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts

@@ -937,7 +937,7 @@ describe('same-session goal driving', () => {
   it('resets process-local scheduling state at a session-start edge', async () => {
     const test = await harness([textResponse('after explicit resume')])
     const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 })
-    agentEvents(test.ctx, test.agent).emit('agent/session-start', { source: 'resume' })
+    await agentEvents(test.ctx, test.agent).serial('agent/created', { source: 'resume' })
     await Promise.resolve()
 
     expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 })

+ 2 - 2
packages/goal/goal/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/goal/goal/README.md
-README.md: 7810fbb5f78aab73b2959538f1dc8fe64b2b5d62
-README.zh.md: 75e2cbec7dfb280e31f0d33686717901e29b2598
+README.md: 8c282dd94ce0548eed426e33f08e509a0cafd800
+README.zh.md: a5b3f7d71365fd01fd99556024cbf948af81c8a8

+ 1 - 1
packages/goal/goal/README.md

@@ -96,7 +96,7 @@ This section explains how the service realizes the behavior above; the observabl
 
 - **Event-sourced state.** Every mutation appends a durable `goal/change` event (version 1) carrying the complete post-mutation snapshot; `clear` writes a revisioned tombstone. The session log is the only durable authority.
 - **Compare-and-set mutations.** `ctx.goals` accepts only the exact live `Agent` registered under its id. `get()` returns a detached `GoalView`; mutations take a `GoalRef { id, revision }` and reject stale refs. Creation resolves the deployment default internally before committing.
-- **Activation is process-local.** `armed` and `disarmed` live in a per-session cache and are never persisted. A fresh cache and every `agent/session-start` edge disarm continuation even when replay finds an active durable phase; `disarm()` removes authority without writing a revision or emitting a mutation.
+- **Activation is process-local.** `armed` and `disarmed` live in a per-session cache and are never persisted. A fresh cache and every `agent/created` edge disarm continuation even when replay finds an active durable phase; `disarm()` removes authority without writing a revision or emitting a mutation.
 - **Strict replay.** The fold derives lifecycle mutations only from `goal/change` and rejects malformed shapes, discontinuous revisions, illegal phase transitions, non-monotonic per-goal timestamps, and non-sequential admitted rounds. Positive rounds advance only on admitted goal-sourced `user/message` events, and mutation timestamps clamp against the preceding update when wall time moves backward.
 - **Projection unit.** The package requires the projection registry and registers a strict `goal` unit. Its host state retains replay validation data and the first failure, while its client view exposes the latest valid whole goal or `null`; `GoalService` rejects access after a retained replay failure.
 

+ 1 - 1
packages/goal/goal/README.zh.md

@@ -96,7 +96,7 @@ view.activation                        // 'armed' | 'disarmed' — not persisted
 
 - **事件溯源状态。** 每次变更都追加持久的 `goal/change` 事件(版本 1),携带变更后的完整快照;clear 写入带 revision 的 tombstone。会话日志是唯一的持久权威。
 - **比较并设置的变更。** `ctx.goals` 只接受以对应 id 注册的完全相同的活跃 `Agent` 实例。`get()` 返回脱离状态的 `GoalView`;变更携带 `GoalRef { id, revision }` 并拒绝陈旧引用。创建在提交前于内部解析部署默认值。
-- **续行启用状态是进程本地的。** `armed` 与 `disarmed` 保存在每会话缓存中,绝不持久化。新缓存与每次 `agent/session-start` 边界都会停用续行,即使回放发现持久 phase 为 active;`disarm()` 移除续行权限,不写入 revision 也不发出变更事件。
+- **续行启用状态是进程本地的。** `armed` 与 `disarmed` 保存在每会话缓存中,绝不持久化。新缓存与每次 `agent/created` 边界都会停用续行,即使回放发现持久 phase 为 active;`disarm()` 移除续行权限,不写入 revision 也不发出变更事件。
 - **严格回放。** 折叠只从 `goal/change` 派生生命周期变更,并拒绝形状错误、不连续 revision、非法 phase 转换、每目标时间戳非单调,以及不连续的已准入 Round。只有已准入的来源为 goal 的 `user/message` 事件会推进正数 Round;挂钟时间倒退时,变更时间戳会限制在不早于上一次更新的值。
 - **投影单元。** 本包要求提供投影注册表,并注册一个严格的 `goal` 单元。其宿主状态保留回放校验数据与第一次失败,客户端视图提供最新有效的完整 goal 或 `null`;保留回放失败后,`GoalService` 会拒绝访问。
 

+ 1 - 1
packages/goal/goal/src/index.ts

@@ -252,7 +252,7 @@ export class GoalService extends TypertRemoteService {
     this.resolved = {
       defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
     }
-    ctx.on('agent/session-start', ({ agent }) => {
+    ctx.on('agent/created', ({ agent }) => {
       this.setActivation(agent.session, 'disarmed')
     })
     ctx.sessionProjections.register(goalProjectionDefinition)

+ 9 - 9
packages/goal/goal/tests/goal.spec.ts

@@ -87,7 +87,7 @@ async function harness(config: { defaultMaxGoalRounds?: number } = {}) {
   await ctx.plugin(AgentRegistry)
   await ctx.plugin(GoalService, config)
   const stub = stubAgent(`goal-test-${Math.random()}`, undefined, ctx)
-  ctx.agents.register(stub.agent)
+  await ctx.agents.register(stub.agent)
   return { ctx, ...stub }
 }
 
@@ -165,7 +165,7 @@ describe('GoalService creation and replay', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(SessionProjectionRegistry)
     const stub = stubAgent('goal-direct-construction')
-    ctx.agents.register(stub.agent)
+    await ctx.agents.register(stub.agent)
     const goals = new GoalService(ctx)
     await new Promise(resolve => setImmediate(resolve))
     expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({
@@ -193,7 +193,7 @@ describe('GoalService creation and replay', () => {
     await ctx.plugin(SessionProjectionRegistry)
     await ctx.plugin(GoalService)
     const resumed = stubAgent('seeded-goal', first.session.snapshotEvents())
-    ctx.agents.register(resumed.agent)
+    await ctx.agents.register(resumed.agent)
     expect(ctx.goals.get(resumed.agent)).toMatchObject({
       id: created.id,
       roundsStarted: 2,
@@ -208,12 +208,12 @@ describe('GoalService creation and replay', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(GoalService)
     const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')), ctx)
-    ctx.agents.register(parent.agent)
+    await ctx.agents.register(parent.agent)
     const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 })
     appendRound(parent.session, goal, 1)
 
     const child = stubAgentForSession(ctx.sessions.fork(parent.session), ctx)
-    ctx.agents.register(child.agent)
+    await ctx.agents.register(child.agent)
     expect(ctx.goals.get(child.agent)).toMatchObject({
       id: goal.id,
       objective: goal.objective,
@@ -233,7 +233,7 @@ describe('GoalService creation and replay', () => {
     })
     let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' })
     expect(goal.activation).toBe('armed')
-    agentEvents(ctx, agent).emit('agent/session-start', { source: 'resume' })
+    await agentEvents(ctx, agent).serial('agent/created', { source: 'resume' })
     expect(ctx.goals.get(agent)?.activation).toBe('disarmed')
     goal = ctx.goals.resume(agent, goal)
     expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 })
@@ -264,7 +264,7 @@ describe('GoalService creation and replay', () => {
     const fiber = await ctx.plugin(GoalService)
     const first = ctx.goals
     const stub = stubAgent('goal-hmr')
-    ctx.agents.register(stub.agent)
+    await ctx.agents.register(stub.agent)
     const goal = ctx.goals.create(stub.agent, { objective: 'survive service reload' })
 
     await fiber.dispose()
@@ -472,7 +472,7 @@ describe('GoalService mutations', () => {
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(GoalService)
     const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')), ctx)
-    ctx.agents.register(stub.agent)
+    await ctx.agents.register(stub.agent)
     let observed: ReturnType<GoalService['get']>
     ctx.on('session/event', (session, event) => {
       if (session === stub.session && event.type === 'goal/change') observed = ctx.goals.get(stub.agent)
@@ -492,7 +492,7 @@ describe('GoalService mutations', () => {
     await ctx.plugin(GoalService)
     const stub = stubAgent('goal-independent-injection')
     stub.agent.inject = () => { throw new Error('injection must not be called') }
-    ctx.agents.register(stub.agent)
+    await ctx.agents.register(stub.agent)
 
     expect(ctx.goals.create(stub.agent, { objective: 'persist directly' })).toMatchObject({
       objective: 'persist directly',

+ 5 - 6
packages/goal/goal/tests/projection.spec.ts

@@ -29,7 +29,7 @@ interface Bench {
 }
 
 /** Register a minimal registry-compatible live agent over a store session. */
-function liveAgent(ctx: Context, session: Session): Agent {
+async function liveAgent(ctx: Context, session: Session): Promise<Agent> {
   const status: AgentStatus = 'idle'
   const agent: Agent = {
     id: session.id,
@@ -46,7 +46,7 @@ function liveAgent(ctx: Context, session: Session): Agent {
     runMaintenance: task => task(new AbortController().signal),
     whenIdle() { return Promise.resolve() },
   }
-  ctx.agents.register(agent)
+  await ctx.agents.register(agent)
   return agent
 }
 
@@ -57,7 +57,7 @@ async function harness(withGoal: boolean): Promise<Bench> {
   await ctx.plugin(SessionProjectionRegistry)
   if (withGoal) await ctx.plugin(GoalService)
   const session = ctx.sessions.create()
-  const agent = liveAgent(ctx, session)
+  const agent = await liveAgent(ctx, session)
   return {
     ctx,
     session,
@@ -231,9 +231,8 @@ describe('goal projection unit', () => {
     expect(state).toBeDefined()
     Object.assign(state!, { failure })
 
-    expect(() => {
-      agentEvents(bench.ctx, bench.agent).emit('agent/session-start', { source: 'resume' })
-    }).not.toThrow()
+    await expect(agentEvents(bench.ctx, bench.agent).serial('agent/created', { source: 'resume' }))
+      .resolves.toBeUndefined()
     expect(() => bench.ctx.goals.get(bench.agent)).toThrow(failure)
     expect(bench.tailValues().goal).toMatchObject({ goal: { objective: 'poisoned replay' } })
   })

+ 5 - 5
packages/goal/tool-goal/tests/tool-goal.spec.ts

@@ -101,7 +101,7 @@ async function harness(config: toolGoal.Config = {}) {
   await ctx.plugin(GoalService)
   const fiber = await ctx.plugin(toolGoal, config)
   const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx)
-  ctx.agents.register(root.agent)
+  await ctx.agents.register(root.agent)
   return { ctx, fiber, root }
 }
 
@@ -256,7 +256,7 @@ describe('goal tool execution authority', () => {
 
     const child = stubAgent('goal-tool-child')
     ctx.agents.enter(child.agent, root.agent)
-    ctx.agents.announce(child.agent)
+    await ctx.agents.announce(child.agent, 'startup')
     openTurn(child, { kind: 'user' })
     const childResult = await execute(ctx, 'create_goal', { objective: 'child goal' }, child.agent)
     expect(childResult.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
@@ -290,7 +290,7 @@ describe('goal tool execution authority', () => {
       isSeeded: true,
     }, SessionLogOffset(root.session.seq))
     const fork = stubAgent(forkId, forkSession)
-    ctx.agents.register(fork.agent)
+    await ctx.agents.register(fork.agent)
     expect(ctx.goals.get(fork.agent)).toMatchObject({ id: created.id, activation: 'disarmed' })
 
     openTurn(fork, { kind: 'user' }, '继续这个目标')
@@ -345,7 +345,7 @@ describe('goal tool execution authority', () => {
   it('rejects an initiator different from exec.agent', async () => {
     const { ctx, root } = await harness()
     const other = stubAgent('goal-tool-other')
-    ctx.agents.register(other.agent)
+    await ctx.agents.register(other.agent)
     openTurn(other, { kind: 'user' })
     const result = await execute(ctx, 'get_goal', {}, other.agent, root.agent)
     expect(result.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED')
@@ -447,7 +447,7 @@ describe('goal tool state transitions', () => {
     let turn = openTurn(root, { kind: 'user' })
     const created = ctx.goals.create(root.agent, { objective: 'continue later' })
     closeTurn(root, turn)
-    agentEvents(ctx, root.agent).emit('agent/session-start', { source: 'resume' })
+    await agentEvents(ctx, root.agent).serial('agent/created', { source: 'resume' })
     expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed')
     turn = openTurn(root, { kind: 'user' }, '继续')
     const resumed = await execute(ctx, 'update_goal', {

+ 2 - 2
packages/hooks/hooks-claude-code/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/hooks/hooks-claude-code/README.md
-README.md: fc7bbf753059cb0ec5de48d57f42547b5f2c6770
-README.zh.md: 2e771d2a72c2e356d7c1233492e439075264404b
+README.md: 8a9f235019d588306ba33e77cf57e9c8f41e88bd
+README.zh.md: c7e3607b57a719024ce6df9527c40da2d6d5b1f1

+ 1 - 1
packages/hooks/hooks-claude-code/README.md

@@ -84,7 +84,7 @@ This section explains the design decisions behind the bridge and points at the c
 
 ### Hook point mapping
 
-Each supported event programs against one harness extension point: `SessionStart` emits context into the new session (`agent/session-start`), `UserPromptSubmit` and `PreToolUse` are waterfalls that can reject the incoming action (`agent/pre-step`, `tools/pre-execute`), `PostToolUse` is a waterfall that can block with feedback or add context to the downstream decision (`tools/post-execute`), and `Stop` is a serial listener whose blocking result forces another step through `steer()` (`agent/turn-stopping`). The two subagent events emit into the child lifecycle (`subagent/start`, `subagent/end`): start injects context into a live in-process child, stop observes only. Context-only hooks always delegate via `next()` before folding a sourced message into the downstream decision, so a later listener can still reject or rewrite; blocking decisions map to `deny` (`ask` for `PreToolUse`). The per-event wiring lives in [`src/index.ts`](src/index.ts).
+Each supported event programs against one harness extension point: `SessionStart` adds context through awaited `agent/created` initialization before the first turn, `UserPromptSubmit` and `PreToolUse` are waterfalls that can reject the incoming action (`agent/pre-step`, `tools/pre-execute`), `PostToolUse` is a waterfall that can block with feedback or add context to the downstream decision (`tools/post-execute`), and `Stop` is a serial listener whose blocking result forces another step through `steer()` (`agent/turn-stopping`). The two subagent events emit into the child lifecycle (`subagent/start`, `subagent/end`): start injects context into a live in-process child, stop observes only. Context-only hooks always delegate via `next()` before folding a sourced message into the downstream decision, so a later listener can still reject or rewrite; blocking decisions map to `deny` (`ask` for `PreToolUse`). The per-event wiring lives in [`src/index.ts`](src/index.ts).
 
 ### Payloads and environment
 

+ 1 - 1
packages/hooks/hooks-claude-code/README.zh.md

@@ -84,7 +84,7 @@ kind: "package-reference"
 
 ### Hook 点映射
 
-每个受支持事件都面向一个 harness 扩展点:`SessionStart` 向新会话发射上下文(`agent/session-start`),`UserPromptSubmit` 与 `PreToolUse` 是能拒绝传入动作的 waterfall(瀑布式事件)(`agent/pre-step`、`tools/pre-execute`),`PostToolUse` 是能带反馈阻塞或向下游决策添加上下文的 waterfall(`tools/post-execute`),`Stop` 是串行监听器,其阻塞结果通过 `steer()` 强制再执行一步(`agent/turn-stopping`)。两个 subagent 事件面向 child 生命周期发射(`subagent/start`、`subagent/end`):start 向仍在运行的同进程 child 注入上下文,stop 只观测。仅提供上下文的 hook 总是先通过 `next()` 委托,再把带来源的消息折叠进下游决策,因此后续监听器仍可拒绝或改写;阻塞决策映射为 `deny`(`PreToolUse` 为 `ask`)。逐事件接线位于 [`src/index.ts`](src/index.ts)。
+每个受支持事件都面向一个 harness 扩展点:`SessionStart` 在首个轮次前通过需等待的 `agent/created` 初始化加入上下文,`UserPromptSubmit` 与 `PreToolUse` 是能拒绝传入动作的 waterfall(瀑布式事件)(`agent/pre-step`、`tools/pre-execute`),`PostToolUse` 是能带反馈阻塞或向下游决策添加上下文的 waterfall(`tools/post-execute`),`Stop` 是串行监听器,其阻塞结果通过 `steer()` 强制再执行一步(`agent/turn-stopping`)。两个 subagent 事件面向 child 生命周期发射(`subagent/start`、`subagent/end`):start 向仍在运行的同进程 child 注入上下文,stop 只观测。仅提供上下文的 hook 总是先通过 `next()` 委托,再把带来源的消息折叠进下游决策,因此后续监听器仍可拒绝或改写;阻塞决策映射为 `deny`(`PreToolUse` 为 `ask`)。逐事件接线位于 [`src/index.ts`](src/index.ts)。
 
 ### 载荷与环境
 

+ 6 - 6
packages/hooks/hooks-claude-code/src/index.ts

@@ -199,18 +199,18 @@ export function apply(ctx: Context, config: Config): void {
     return [ours, ...theirs ?? []]
   }
 
-  // SessionStart injects context when its detached hook resolves; a slow hook
-  // may miss the first request.
-  // TODO(session-start-gating): add a startup gate before promising first-turn delivery.
-  ctx.on('agent/session-start', ({ agent, source }) => {
-    detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal })
+  ctx.on('agent/created', async ({ agent, source, signal }) => {
+    const ownerSignal = signal === undefined ? detached.signal : AbortSignal.any([signal, detached.signal])
+    const run = runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: ownerSignal })
       .then((merged) => {
         const context = contextFrom(merged)
         if (context) agent.inject(context)
       })
       .catch((error: unknown) => {
         ctx.logger.warn(`hooks-claude-code: SessionStart hook failed: ${String(error)}`)
-      }))
+      })
+    detached.track(run)
+    await run
   })
 
   // --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no

+ 25 - 15
packages/hooks/hooks-claude-code/tests/coverage-cases.ts

@@ -23,7 +23,11 @@ const testToolSignal = new AbortController().signal
  * fallbacks, contextFrom-empty, and the detached-listener catch handlers. */
 
 const dirs: string[] = []
-afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
+const contexts: Context[] = []
+afterEach(async () => {
+  for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+  for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
+})
 
 function subagentCarrier(ctx: Context) {
   return scopeTarget(ctx as unknown as SubagentRuntime, undefined)
@@ -40,6 +44,7 @@ function hooks(d: string, h: unknown): string {
 type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number; sessionRoot?: string }
 async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
   const ctx = new Context()
+  contexts.push(ctx)
   await mountAgentLoopTestDependencies(ctx)
   if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
   await ctx.plugin(AgentLoop, { agents: [] })
@@ -238,7 +243,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
         },
         session: { id: SessionId('child-x'), header: { id: 'child-x' } },
       } as unknown as Parameters<typeof ctx.agents.register>[0]
-      ctx.agents.register(child)
+      await ctx.agents.register(child)
       ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true })
       await waitFor(() => injected.includes('child guidance'))
       expect(injected).toContain('child guidance')
@@ -254,7 +259,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, new MockAdapter([]))
       const warn = vi.fn(); ctx.logger.warn = warn as never
       const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
-      ctx.agents.register(child)
+      await ctx.agents.register(child)
       ctx.emit(subagentCarrier(ctx), 'subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true })
       await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
       expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
@@ -627,24 +632,29 @@ export function defineCoverageCases(group: CoverageGroup): void {
 
   })
 
-  if (group === 'edge-paths') describe('hooks-claude-code coverage — detached-listener catch handlers', () => {
+  if (group === 'edge-paths') describe('hooks-claude-code coverage — lifecycle error handling', () => {
     it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => {
       const d = dir()
       const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n')
       const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
       const adapter = new MockAdapter([textResponse('ok')])
       const ctx = await harness(path, adapter)
-      const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      // Make inject throw, forcing the SessionStart .catch path.
-      const original = agent.inject.bind(agent)
-      let threw = false
-      agent.inject = (() => { threw = true; throw new Error('inject boom') })
-      await waitFor(() => threw)
-      expect(threw).toBe(true)
-      agent.inject = original
-      agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
-      await waitForIdle(ctx, agent)
-      expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject
+      const warn = vi.fn(); ctx.logger.warn = warn as never
+      try {
+        const { agent } = await ctx.agents.create({
+          sessionId: SessionId('a1'),
+          agentOptions: { provider: 'mock', model: 'mock' },
+          setup(_agentCtx, agent) {
+            vi.spyOn(agent, 'inject').mockImplementationOnce(() => { throw new Error('inject boom') })
+          },
+        })
+        expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
+        agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
+        await waitForIdle(ctx, agent)
+        expect(adapter.requests).toHaveLength(1)
+      } finally {
+        await ctx.fiber.dispose()
+      }
     })
   })
 

+ 2 - 2
packages/hooks/hooks-codex/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/hooks/hooks-codex/README.md
-README.md: 77fd99835340b0629b927d57b177a1fbe826c4df
-README.zh.md: 90606678fee694555007ee4b0c5d9cc56733ece0
+README.md: 14c7df7a87faf4735ed95bb1f94e597b721f0a00
+README.zh.md: b26eebe4ca1a21909e2cd2f6ea0e9ef6a2c87463

+ 1 - 1
packages/hooks/hooks-codex/README.md

@@ -80,7 +80,7 @@ This section explains the design decisions behind the bridge and points at the c
 
 ### Hook point mapping
 
-Each supported event programs against one harness extension point: `SessionStart` emits context into the new session (`agent/session-start`), `UserPromptSubmit` and `PreToolUse` are waterfalls that can reject the incoming action (`agent/pre-step`, `tools/pre-execute`), `PostToolUse` is a waterfall that can block with feedback or add context to the downstream decision (`tools/post-execute`), and `Stop` is a serial listener whose blocking result forces another step through `steer()` (`agent/turn-stopping`). Context-only hooks always delegate via `next()` before folding a sourced message into the downstream decision, so a later listener can still reject or rewrite; blocking decisions map to `deny` (`PreToolUse` has no `allow` or `ask`). The per-event wiring lives in [`src/index.ts`](src/index.ts).
+Each supported event programs against one harness extension point: `SessionStart` adds context through awaited `agent/created` initialization before the first turn, `UserPromptSubmit` and `PreToolUse` are waterfalls that can reject the incoming action (`agent/pre-step`, `tools/pre-execute`), `PostToolUse` is a waterfall that can block with feedback or add context to the downstream decision (`tools/post-execute`), and `Stop` is a serial listener whose blocking result forces another step through `steer()` (`agent/turn-stopping`). Context-only hooks always delegate via `next()` before folding a sourced message into the downstream decision, so a later listener can still reject or rewrite; blocking decisions map to `deny` (`PreToolUse` has no `allow` or `ask`). The per-event wiring lives in [`src/index.ts`](src/index.ts).
 
 ### Payloads and environment
 

+ 1 - 1
packages/hooks/hooks-codex/README.zh.md

@@ -80,7 +80,7 @@ kind: "package-reference"
 
 ### Hook 点映射
 
-每个受支持事件都面向一个 harness 扩展点:`SessionStart` 向新会话发射上下文(`agent/session-start`),`UserPromptSubmit` 与 `PreToolUse` 是能拒绝传入动作的 waterfall(瀑布式事件)(`agent/pre-step`、`tools/pre-execute`),`PostToolUse` 是能带反馈阻塞或向下游决策添加上下文的 waterfall(`tools/post-execute`),`Stop` 是串行监听器,其阻塞结果通过 `steer()` 强制再执行一步(`agent/turn-stopping`)。仅提供上下文的 hook 总是先通过 `next()` 委托,再把带来源的消息折叠进下游决策,因此后续监听器仍可拒绝或改写;阻塞决策映射为 `deny`(`PreToolUse` 没有 `allow` 或 `ask`)。逐事件接线位于 [`src/index.ts`](src/index.ts)。
+每个受支持事件都面向一个 harness 扩展点:`SessionStart` 在首个轮次前通过需等待的 `agent/created` 初始化加入上下文,`UserPromptSubmit` 与 `PreToolUse` 是能拒绝传入动作的 waterfall(瀑布式事件)(`agent/pre-step`、`tools/pre-execute`),`PostToolUse` 是能带反馈阻塞或向下游决策添加上下文的 waterfall(`tools/post-execute`),`Stop` 是串行监听器,其阻塞结果通过 `steer()` 强制再执行一步(`agent/turn-stopping`)。仅提供上下文的 hook 总是先通过 `next()` 委托,再把带来源的消息折叠进下游决策,因此后续监听器仍可拒绝或改写;阻塞决策映射为 `deny`(`PreToolUse` 没有 `allow` 或 `ask`)。逐事件接线位于 [`src/index.ts`](src/index.ts)。
 
 ### 载荷与环境
 

+ 6 - 6
packages/hooks/hooks-codex/src/index.ts

@@ -181,16 +181,16 @@ export function apply(ctx: Context, config: Config): void {
     return [ours, ...theirs ?? []]
   }
 
-  // SessionStart injects plain stdout when its detached hook resolves; a slow
-  // hook may miss the first request.
-  // TODO(session-start-gating): add a startup gate before promising first-turn delivery.
-  ctx.on('agent/session-start', ({ agent, source }) => {
-    detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
+  ctx.on('agent/created', async ({ agent, source, signal }) => {
+    const ownerSignal = signal === undefined ? detached.signal : AbortSignal.any([signal, detached.signal])
+    const run = runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: ownerSignal })
       .then((merged) => {
         const context = contextFrom(merged)
         if (context) agent.inject(context)
       })
-      .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }))
+      .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) })
+    detached.track(run)
+    await run
     /* jscpd:ignore-end */
   })
 

+ 37 - 11
packages/hooks/hooks-codex/tests/bridge.spec.ts

@@ -22,7 +22,11 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
  */
 
 const dirs: string[] = []
-afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
+const contexts: Context[] = []
+afterEach(async () => {
+  for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+  for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
+})
 
 function configDir(): string {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-'))
@@ -41,6 +45,7 @@ function writeHooks(dir: string, hooks: unknown): void {
 
 async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
   const ctx = new Context()
+  contexts.push(ctx)
   await mountAgentLoopTestDependencies(ctx)
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(LocalSubprocessRuntime)
@@ -66,6 +71,23 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
 }
 
 describe('hooks-codex bridge', () => {
+  it('awaits a registry-announced resume hook without a creation signal', async () => {
+    const dir = configDir()
+    const capture = script(dir, 'resume.sh', '#!/usr/bin/env bash\necho "resumed context"\n')
+    writeHooks(dir, { SessionStart: [{ matcher: 'resume', hooks: [{ type: 'command', command: capture }] }] })
+    const ctx = await harness(dir, new MockAdapter([]))
+    const session = ctx.sessions.create(SessionId('registry-resume'))
+    const inject = vi.fn()
+    const agent = { id: session.id, session, ctx, inject } as unknown as Agent
+
+    ctx.effect(() => ctx.agents.enter(agent, undefined))
+    await ctx.agents.announce(agent, 'resume')
+
+    expect(inject).toHaveBeenCalledWith(expect.objectContaining({
+      content: [{ type: 'text', text: 'resumed context' }],
+    }))
+  })
+
   it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => {
     const dir = configDir()
     const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\necho "codex blocked it" >&2\nexit 2\n')
@@ -212,16 +234,20 @@ describe('hooks-codex bridge', () => {
     ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
     const warn = vi.fn()
     ctx.logger.warn = warn as never
-    await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires agent/session-start
-    await waitFor(() => existsSync(marker))
-    const pid = Number(readFileSync(pidFile, 'utf8').trim())
-    await fiber.dispose()
-    // Disposal reaches quiescence only after the aborted run settles and the process is reaped, so
-    // `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain.
-    expect(() => process.kill(pid, 0)).toThrow()
-    // runHook resolves an aborted run as a non-blocking error, so draining must
-    // not log a rejected continuation.
-    expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
+    const creating = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
+    try {
+      await waitFor(() => existsSync(marker))
+      const pid = Number(readFileSync(pidFile, 'utf8').trim())
+      await fiber.dispose()
+      await creating
+      // The aborted hook must be reaped before bridge disposal resolves.
+      expect(() => process.kill(pid, 0)).toThrow()
+      expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
+    } finally {
+      await fiber.dispose()
+      await creating
+      await ctx.fiber.dispose()
+    }
   })
 
   it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {

+ 18 - 5
packages/hooks/hooks-codex/tests/coverage-cases.ts

@@ -18,7 +18,11 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
 const testToolSignal = new AbortController().signal
 
 const dirs: string[] = []
-afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
+const contexts: Context[] = []
+afterEach(async () => {
+  for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+  for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
+})
 function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d }
 function sh(d: string, name: string, body: string): string {
   const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p
@@ -30,6 +34,7 @@ function hooks(d: string, h: unknown): string {
 type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string }
 async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
   const ctx = new Context()
+  contexts.push(ctx)
   await mountAgentLoopTestDependencies(ctx)
   if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot })
   await ctx.plugin(AgentLoop, { agents: [] })
@@ -353,10 +358,18 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const adapter = new MockAdapter([textResponse('ok')])
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       const warn = vi.fn(); ctx.logger.warn = warn as never
-      const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.inject = (() => { throw new Error('inject boom') })
-      await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed')))
-      expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
+      try {
+        await ctx.agents.create({
+          sessionId: SessionId('a1'),
+          agentOptions: { provider: 'mock', model: 'mock' },
+          setup(_agentCtx, agent) {
+            vi.spyOn(agent, 'inject').mockImplementationOnce(() => { throw new Error('inject boom') })
+          },
+        })
+        expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed'))
+      } finally {
+        await ctx.fiber.dispose()
+      }
     })
   })
 

+ 29 - 29
packages/jobs/jobs-local/tests/jobs.spec.ts

@@ -135,8 +135,8 @@ describe('LocalJobRegistry.start', () => {
 
     const served = stubAgent(ctx, 'served', scopeOf(withControls.ctx))
     const unserved = stubAgent(ctx, 'unserved', scopeOf(withoutControls.ctx))
-    ctx.agents.register(served)
-    ctx.agents.register(unserved)
+    await ctx.agents.register(served)
+    await ctx.agents.register(unserved)
 
     expect(() => ctx.jobs.start(producer({ owner: served }).spec)).not.toThrow()
     expect(() => ctx.jobs.start(producer({ owner: unserved }).spec))
@@ -154,7 +154,7 @@ describe('LocalJobRegistry.start', () => {
     // holds them and every owner's read includes it.
     await attachControllerIn(ctx)
     const scoped = stubAgent(ctx, 'scoped', scopeOf(createScope(ctx, {}).ctx))
-    ctx.agents.register(scoped)
+    await ctx.agents.register(scoped)
 
     expect(() => ctx.jobs.start(producer({ owner: scoped }).spec)).not.toThrow()
     expect(() => ctx.jobs.start(producer().spec)).not.toThrow()
@@ -239,17 +239,17 @@ describe('LocalJobRegistry.start', () => {
   it('isolates exact owners, replacement objects with the same session id, and the unowned bucket', async () => {
     const ctx = await harness({ maxConcurrentJobsPerOwner: 1 })
     const oldOwner = stubAgent(ctx, 'shared-session')
-    const detachOld = ctx.agents.register(oldOwner)
+    const detachOld = await ctx.agents.register(oldOwner)
     const oldTask = producer({ owner: oldOwner })
     ctx.jobs.start(oldTask.spec)
 
     const otherOwner = stubAgent(ctx, 'other-session')
-    ctx.agents.register(otherOwner)
+    await ctx.agents.register(otherOwner)
     expect(() => ctx.jobs.start(producer({ owner: otherOwner }).spec)).not.toThrow()
 
-    detachOld()
+    await detachOld()
     const replacement = stubAgent(ctx, 'shared-session')
-    ctx.agents.register(replacement)
+    await ctx.agents.register(replacement)
     expect(() => ctx.jobs.start(producer({ owner: replacement }).spec)).not.toThrow()
 
     ctx.jobs.start(producer().spec)
@@ -561,7 +561,7 @@ describe('LocalJobRegistry owner isolation', () => {
   it('fences read/kill/wait to the owning session and keeps unowned jobs open', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const other = stubAgent(ctx, 'other')
 
     const owned = ctx.jobs.start(producer({ owner }).spec)
@@ -582,8 +582,8 @@ describe('LocalJobRegistry owner isolation', () => {
     const ctx = await harness()
     const alice = stubAgent(ctx, 'alice')
     const bob = stubAgent(ctx, 'bob')
-    ctx.agents.register(alice)
-    ctx.agents.register(bob)
+    await ctx.agents.register(alice)
+    await ctx.agents.register(bob)
 
     const aliceTask = ctx.jobs.start(producer({ owner: alice }).spec)
     const bobTask = ctx.jobs.start(producer({ owner: bob }).spec)
@@ -615,7 +615,7 @@ describe('LocalJobRegistry owner isolation', () => {
     expect(ctx.jobs.list(ghost)).toEqual([])
 
     // A later valid registration must still attach cleanup for the same object.
-    ctx.agents.register(ghost)
+    await ctx.agents.register(ghost)
     const cancels: (string | undefined)[] = []
     let settle!: (outcome: JobOutcome) => void
     const id = ctx.jobs.start({
@@ -636,11 +636,11 @@ describe('LocalJobRegistry owner isolation', () => {
   it('rejects a stale owner instance after another agent reuses its id', async () => {
     const ctx = await harness()
     const staleOwner = stubAgent(ctx, 'owner')
-    const unregisterStale = ctx.agents.register(staleOwner)
-    unregisterStale()
+    const unregisterStale = await ctx.agents.register(staleOwner)
+    await unregisterStale()
 
     const currentOwner = stubAgent(ctx, 'owner')
-    ctx.agents.register(currentOwner)
+    await ctx.agents.register(currentOwner)
     const current = producer({ owner: currentOwner })
     ctx.jobs.start(current.spec) // Attach the current owner's cleanup first.
 
@@ -665,7 +665,7 @@ describe('LocalJobRegistry owner cleanup', () => {
   it('drains the owner: cancels live jobs, awaits settlement, drops snapshots', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
 
     // The producer settles only when cancelled — models a child that stops on request.
     let settle!: (outcome: JobOutcome) => void
@@ -693,7 +693,7 @@ describe('LocalJobRegistry owner cleanup', () => {
   it('publishes the settled visible set before announcing completion', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const p = producer({ owner })
     ctx.jobs.start(p.spec)
     // Registered after start so only the settlement's notifications are ordered.
@@ -713,7 +713,7 @@ describe('LocalJobRegistry owner cleanup', () => {
   it('reports a teardown-cancelled record so completion reporters stay quiet', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const seen: JobSnapshot[] = []
     ctx.jobs.onJobDone(snapshot => void seen.push(snapshot))
 
@@ -738,7 +738,7 @@ describe('LocalJobRegistry owner cleanup', () => {
   it('attaches one cleanup per owner and drains all owned jobs with the scope', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
 
     const first = producer({ owner })
     const second = producer({ owner })
@@ -755,7 +755,7 @@ describe('LocalJobRegistry owner cleanup', () => {
   it('does not let an old scope cleanup cancel a same-id/session replacement job', async () => {
     const ctx = await harness()
     const oldOwner = stubAgent(ctx, 'owner')
-    const detachOld = ctx.agents.register(oldOwner)
+    const detachOld = await ctx.agents.register(oldOwner)
     const cancels: string[] = []
 
     function start(owner: Agent, label: string): JobId {
@@ -772,9 +772,9 @@ describe('LocalJobRegistry owner cleanup', () => {
     }
 
     start(oldOwner, 'old job')
-    detachOld()
+    await detachOld()
     const replacement = stubAgent(ctx, 'owner')
-    ctx.agents.register(replacement)
+    await ctx.agents.register(replacement)
     const replacementId = start(replacement, 'replacement job')
 
     await disposeAgentScope(oldOwner)
@@ -791,7 +791,7 @@ describe('LocalJobRegistry owner cleanup', () => {
     const tasksFiber = await ctx.plugin(LocalJobRegistry)
     ctx.jobs.attachController('test-controller')
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const ownerCleanupEffects = () => owner.ctx.fiber.getEffects()
       .filter(effect => effect.label === 'jobs.ownerCleanup()')
 
@@ -815,7 +815,7 @@ describe('LocalJobRegistry owner cleanup', () => {
     const ctx = await harness()
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const seen: JobSnapshot[] = []
     ctx.jobs.onJobDone(snapshot => void seen.push(snapshot))
 
@@ -927,7 +927,7 @@ describe('LocalJobRegistry disposal', () => {
     const tasksFiber = await ctx.plugin(LocalJobRegistry)
     ctx.jobs.attachController('test-controller')
     const owner = stubAgent(ctx, 'owner')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     let settle!: (outcome: JobOutcome) => void
     ctx.jobs.start({
       kind: 'bash',
@@ -962,7 +962,7 @@ describe('LocalJobRegistry disposal', () => {
       },
     })
     const owner = stubAgent(ctx, 'joined', scopeOf(standing.ctx))
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     expect(() => ctx.jobs.start(producer({ owner }).spec)).not.toThrow()
 
     await mount.dispose()
@@ -994,7 +994,7 @@ describe('LocalJobRegistry.onJobsChanged', () => {
   it('fires after registration, the stopping transition, and settlement', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'alice')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const seen: (string | undefined)[] = []
     ctx.jobs.onJobsChanged(changed => void seen.push(changed?.id))
 
@@ -1028,8 +1028,8 @@ describe('LocalJobRegistry.onJobsChanged', () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'alice')
     const bystander = stubAgent(ctx, 'bob')
-    ctx.agents.register(owner)
-    ctx.agents.register(bystander)
+    await ctx.agents.register(owner)
+    await ctx.agents.register(bystander)
     const p = producer({ owner })
     ctx.jobs.start(p.spec)
 
@@ -1087,7 +1087,7 @@ describe('LocalJobRegistry teardown change notifications', () => {
   it('announces the stopping transition during owner teardown, before settlement', async () => {
     const ctx = await harness()
     const owner = stubAgent(ctx, 'alice')
-    ctx.agents.register(owner)
+    await ctx.agents.register(owner)
     const p = producer({ owner })
     const id = ctx.jobs.start(p.spec)
 

+ 27 - 27
packages/jobs/tool-jobs/tests/tool-jobs.spec.ts

@@ -16,7 +16,7 @@ import { statusLine } from '@deepseek-ai/dsh-tool-jobs'
 
 const testToolSignal = new AbortController().signal
 
-const agentRegistryDisposers = new WeakMap<Agent, () => void>()
+const agentRegistryDisposers = new WeakMap<Agent, () => Promise<void>>()
 const agentScopeFibers = new WeakMap<Agent, { dispose: () => Promise<void> }>()
 
 async function setup(config: ToolJobs.Config = {}) {
@@ -41,7 +41,7 @@ interface FakeDelivery {
  * A fake agent with the shared agent/session identity, registered in
  * `ctx.agents` with a dedicated lifecycle scope.
  */
-function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Agent {
+async function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Promise<Agent> {
   const scopeFiber = ctx.plugin(() => {})
   const id = SessionId(sessionId)
   const agent = {
@@ -52,15 +52,15 @@ function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {})
     status: delivery.status ?? 'running',
     session: { id, header: { version: 0, id, createdAt: 0 } },
   } as unknown as Agent
-  agentRegistryDisposers.set(agent, ctx.agents.register(agent))
+  agentRegistryDisposers.set(agent, await ctx.agents.register(agent))
   agentScopeFibers.set(agent, scopeFiber)
   return agent
 }
 
-function detachAgent(agent: Agent): void {
+async function detachAgent(agent: Agent): Promise<void> {
   const dispose = agentRegistryDisposers.get(agent)
   if (dispose === undefined) throw new Error(`missing registry disposer for agent "${agent.id}"`)
-  dispose()
+  await dispose()
 }
 
 /** Dispose the agent's own lifecycle scope, which is what drains its owned jobs. */
@@ -353,7 +353,7 @@ describe('job_list', () => {
     const { ctx } = await setup()
     expect(text(await call(ctx, 'job_list', {}))).toBe('(no background jobs)')
 
-    const alice = fakeAgent(ctx, 'sess-alice')
+    const alice = await fakeAgent(ctx, 'sess-alice')
     ctx.jobs.start(producer({ owner: alice, label: 'pnpm test' }).spec)
     ctx.jobs.start(producer({ kind: 'subagent', label: 'open research' }).spec)
     const p = producer({ owner: alice, label: 'build' })
@@ -377,7 +377,7 @@ describe('job_list', () => {
       'bash-2 [bash] completed — build',
     ].join('\n'))
     // A different caller sees only the unowned job.
-    const bob = fakeAgent(ctx, 'sess-bob')
+    const bob = await fakeAgent(ctx, 'sess-bob')
     expect(text(await call(ctx, 'job_list', {}, bob))).toBe('subagent-1 [subagent] running — open research')
   })
 })
@@ -536,7 +536,7 @@ describe('completion notices across scoped mounts', () => {
       inject,
       session: { id: SessionId('sess-scoped'), header: { version: 0, id: SessionId('sess-scoped'), createdAt: 0 } },
     } as unknown as Agent
-    const dispose = ctx.agents.register(owner)
+    const dispose = await ctx.agents.register(owner)
 
     try {
       // No waiter: `settle()` leaves `reported` false, which is the only path
@@ -548,7 +548,7 @@ describe('completion notices across scoped mounts', () => {
 
       expect(inject).toHaveBeenCalledTimes(1)
     } finally {
-      dispose()
+      await dispose()
     }
   })
 })
@@ -558,7 +558,7 @@ describe('completion notice delivery', () => {
     const { ctx } = await setup()
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
     const p = producer({ owner, label: 'pnpm test' })
     ctx.jobs.start(p.spec)
 
@@ -572,7 +572,7 @@ describe('completion notice delivery', () => {
     const { ctx } = await setup({ completionDelivery: 'quiet' })
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
     const p = producer({ owner })
     ctx.jobs.start(p.spec)
 
@@ -586,7 +586,7 @@ describe('completion notice delivery', () => {
     const { ctx } = await setup({ maxConsecutiveWakes: 2 })
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
 
     await settleTasks(ctx, owner, 3)
     // A woken turn that starts another job is the self-exciting case: the
@@ -599,7 +599,7 @@ describe('completion notice delivery', () => {
     const { ctx } = await setup({ maxConsecutiveWakes: 1 })
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
 
     await settleTasks(ctx, owner, 2)
     expect(followup).toHaveBeenCalledTimes(1)
@@ -616,7 +616,7 @@ describe('completion notice delivery', () => {
     const { ctx } = await setup()
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
     let settle!: (outcome: JobOutcome) => void
     ctx.jobs.start({
       kind: 'bash',
@@ -641,7 +641,7 @@ describe('completion notice delivery', () => {
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
     const inject = vi.fn()
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
     ctx.jobs.start({
       kind: 'bash',
       label: 'broken producer',
@@ -666,7 +666,7 @@ describe('completion notice delivery', () => {
   it('keeps the budget spent when the owner only claims plugin notices', async () => {
     const { ctx } = await setup({ maxConsecutiveWakes: 1 })
     const followup = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { followup, status: 'idle' })
+    const owner = await fakeAgent(ctx, 'sess-1', { followup, status: 'idle' })
 
     await settleTasks(ctx, owner, 1)
     emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
@@ -685,7 +685,7 @@ describe('completion notices', () => {
   it('injects a notice into the owning agent when an unreported job settles', async () => {
     const { ctx } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const p = producer({ owner, label: 'pnpm test' })
     ctx.jobs.start(p.spec)
 
@@ -708,7 +708,7 @@ describe('completion notices', () => {
   it('preserves job ids and collection guidance in bounded completion notices', async () => {
     const { ctx } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const first = producer({
       owner,
       kind: 'subagent',
@@ -762,7 +762,7 @@ describe('completion notices', () => {
       await tick()
     }
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const target = producer({
       owner,
       kind: 'pty-send',
@@ -783,7 +783,7 @@ describe('completion notices', () => {
   it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
     const { ctx } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
     const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
     ctx.jobs.start(tiny.spec)
@@ -804,7 +804,7 @@ describe('completion notices', () => {
   it('suppresses the notice for a job the model already killed', async () => {
     const { ctx } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const p = producer({ owner })
     ctx.jobs.start(p.spec)
 
@@ -817,7 +817,7 @@ describe('completion notices', () => {
   it('suppresses the notice when a wait returned the terminal state', async () => {
     const { ctx } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
     const p = producer({ owner, kind: 'subagent' })
     ctx.jobs.start(p.spec)
 
@@ -842,13 +842,13 @@ describe('completion notices', () => {
     // terminal state, so the notice lands in the old owner's (detached)
     // session instead of throwing or re-routing.
     const oldInject = vi.fn()
-    const oldOwner = fakeAgent(ctx, 'shared', { inject: oldInject })
+    const oldOwner = await fakeAgent(ctx, 'shared', { inject: oldInject })
     const p = producer({ owner: oldOwner })
     ctx.jobs.start(p.spec)
 
-    detachAgent(oldOwner)
+    await detachAgent(oldOwner)
     const replacementInject = vi.fn()
-    fakeAgent(ctx, 'shared', { inject: replacementInject })
+    await fakeAgent(ctx, 'shared', { inject: replacementInject })
     p.settle({ status: 'completed' })
     await tick()
 
@@ -859,7 +859,7 @@ describe('completion notices', () => {
   it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => {
     const { ctx } = await setup()
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
-    const owner = fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } })
     const p = producer({ owner })
     ctx.jobs.start(p.spec)
     p.settle({ status: 'completed' })
@@ -872,7 +872,7 @@ describe('completion notices', () => {
   it('keeps using the exact owner after the agent registry is gone', async () => {
     const { ctx, agentsFiber } = await setup()
     const inject = vi.fn()
-    const owner = fakeAgent(ctx, 'sess-1', { inject })
+    const owner = await fakeAgent(ctx, 'sess-1', { inject })
 
     // Settlement must not depend on a later registry lookup: the exact owner
     // supplied at start remains the destination while its own scope is live.

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