ソースを参照

refactor(agent): align delivery method names

Turtle 1 ヶ月 前
コミット
b02b438667
100 ファイル変更392 行追加374 行削除
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.i18n.yaml
  5. 6 6
      .agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md
  6. 6 6
      .agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md
  7. 1 1
      docs/agent-lifecycle.md
  8. 2 2
      docs/architecture.i18n.yaml
  9. 1 1
      docs/architecture.md
  10. 1 1
      docs/architecture.zh.md
  11. 2 2
      docs/cookbook/extension-cookbook.i18n.yaml
  12. 7 7
      docs/cookbook/extension-cookbook.md
  13. 7 7
      docs/cookbook/extension-cookbook.zh.md
  14. 2 2
      docs/cordis-catalog/events.md
  15. 6 6
      docs/core-data-structures/core.md
  16. 1 1
      docs/core-data-structures/persistence.md
  17. 1 1
      docs/core-data-structures/session-reference.md
  18. 1 1
      docs/defensive-patterns.md
  19. 2 2
      docs/i18n/style-samples.md
  20. 0 0
      examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
  21. 0 0
      examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
  22. 5 5
      examples/cordis-agent/tests/cordis-tools.e2e.ts
  23. 2 2
      examples/headless-agent/tests/code-mode.e2e.ts
  24. 1 1
      examples/headless-agent/tests/coding-task.e2e.ts
  25. 1 1
      examples/headless-agent/tests/compaction.e2e.ts
  26. 1 1
      examples/headless-agent/tests/full-loop.e2e.ts
  27. 2 2
      examples/headless-agent/tests/resume.e2e.ts
  28. 1 1
      examples/headless-agent/tests/todo-write.e2e.ts
  29. 5 5
      packages/bash/tool-bash/tests/integration.spec.ts
  30. 5 5
      packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
  31. 1 1
      packages/context/session-reference/README.md
  32. 4 4
      packages/context/time-context/tests/time-context.spec.ts
  33. 4 4
      packages/context/workspace-context/tests/workspace-context.e2e.ts
  34. 4 4
      packages/context/workspace-context/tests/workspace-context.spec.ts
  35. 18 2
      packages/cordis/tool-cordis/src/api-catalog.ts
  36. 2 0
      packages/cordis/tool-cordis/tests/inspect.spec.ts
  37. 1 1
      packages/cordis/tool-cordis/tests/integration.spec.ts
  38. 0 0
      packages/core/agent-loop/README.md
  39. 8 8
      packages/core/agent-loop/src/agent.ts
  40. 1 1
      packages/core/agent-loop/src/inbox.ts
  41. 1 1
      packages/core/agent-loop/tests/agent-initiator.spec.ts
  42. 6 6
      packages/core/agent-loop/tests/agent.spec.ts
  43. 2 2
      packages/core/agent-loop/tests/cancel.spec.ts
  44. 5 5
      packages/core/agent-loop/tests/config-session-id.spec.ts
  45. 4 4
      packages/core/agent-loop/tests/contract-regressions.spec.ts
  46. 3 3
      packages/core/agent-loop/tests/coverage-edges.spec.ts
  47. 4 4
      packages/core/agent-loop/tests/inbox-invariant.spec.ts
  48. 3 3
      packages/core/agent-loop/tests/interception.spec.ts
  49. 4 4
      packages/core/agent-loop/tests/loop.spec.ts
  50. 3 3
      packages/core/agent-loop/tests/properties.spec.ts
  51. 2 2
      packages/core/agent-loop/tests/request-cache.e2e.ts
  52. 1 1
      packages/core/agent-loop/tests/request-reconstruction.spec.ts
  53. 1 1
      packages/core/agent-loop/tests/request-recovery.spec.ts
  54. 6 6
      packages/core/agent-loop/tests/resume.spec.ts
  55. 3 3
      packages/core/agent-loop/tests/scope-lifecycle.spec.ts
  56. 16 16
      packages/core/agent-loop/tests/tool-calls.spec.ts
  57. 2 2
      packages/core/agent-loop/tests/tool-order.spec.ts
  58. 2 2
      packages/core/agent-loop/tests/turn-stop.spec.ts
  59. 4 4
      packages/core/agent/README.md
  60. 7 7
      packages/core/agent/src/types.ts
  61. 3 3
      packages/core/agent/tests/agent.spec.ts
  62. 4 4
      packages/examples/agent-spine-demo/tests/agent-core.spec.ts
  63. 1 1
      packages/examples/cli-demo/src/cli.ts
  64. 1 1
      packages/examples/cli-demo/tests/cli.spec.ts
  65. 2 2
      packages/fs/tool-fs/tests/fs-tools.e2e.ts
  66. 2 2
      packages/goal/command-goal/tests/command-goal.spec.ts
  67. 1 1
      packages/goal/goal-session/src/index.ts
  68. 1 1
      packages/goal/goal-session/src/prompt.ts
  69. 15 15
      packages/goal/goal-session/tests/goal-session.spec.ts
  70. 2 2
      packages/goal/goal/tests/goal.spec.ts
  71. 1 1
      packages/goal/tool-goal/README.md
  72. 1 1
      packages/goal/tool-goal/src/authority.ts
  73. 2 2
      packages/goal/tool-goal/tests/tool-goal.spec.ts
  74. 18 18
      packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts
  75. 10 10
      packages/hooks/hooks-claude/tests/bridge.spec.ts
  76. 30 30
      packages/hooks/hooks-claude/tests/coverage-cases.ts
  77. 6 6
      packages/hooks/hooks-codex/tests/bridge.spec.ts
  78. 34 34
      packages/hooks/hooks-codex/tests/coverage-cases.ts
  79. 1 1
      packages/host/runtime/src/api-proxy.ts
  80. 5 5
      packages/host/runtime/tests/host-runtime.spec.ts
  81. 1 1
      packages/llm/llm-retry/tests/loader-composition.spec.ts
  82. 13 13
      packages/llm/llm-retry/tests/retry.spec.ts
  83. 4 4
      packages/plan/plan-mode/tests/integration.spec.ts
  84. 3 3
      packages/pty/pty-local/tests/index.spec.ts
  85. 1 1
      packages/pty/pty-local/tests/local.spec.ts
  86. 2 2
      packages/pty/pty/tests/service.spec.ts
  87. 1 1
      packages/pty/tool-pty/tests/loader-composition.spec.ts
  88. 1 1
      packages/pty/tool-pty/tests/tools.spec.ts
  89. 1 1
      packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts
  90. 2 2
      packages/subagent/subagent-fork/tests/multi-subagent.spec.ts
  91. 7 7
      packages/subagent/subagent-fork/tests/subagent-fork.spec.ts
  92. 1 1
      packages/subagent/subagent-inprocess/README.md
  93. 1 1
      packages/subagent/subagent-inprocess/src/index.ts
  94. 4 4
      packages/subagent/subagent-inprocess/tests/structured.spec.ts
  95. 1 1
      packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts
  96. 1 1
      packages/subagent/subagent-spawn/tests/spawn.e2e.ts
  97. 2 2
      packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts
  98. 2 2
      packages/tasks/tasks/tests/tasks.spec.ts
  99. 2 2
      packages/todo/tool-todo/tests/integration.spec.ts
  100. 1 1
      packages/ui/acp/README.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.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
-2026-07-22-unified-send-and-coalesced-user-messages.md: 7ee51ed18cbf6a12136abe67f412251c4c1f0eb3
-2026-07-22-unified-send-and-coalesced-user-messages.zh.md: d260f1ba396cf9b6a90773b218a8df3d158d95c9
+2026-07-22-unified-send-and-coalesced-user-messages.md: bf0ae468c4783b73e2dbd0e1bc50b9bd2f50cb3f
+2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 17913d2636e3ee5e5ae69f9c554935ba861d14d9

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md

@@ -12,7 +12,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj
 
 ## Decision
 
-**One acceptance mechanism, four intent helpers.** The concrete loop resolves `send`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `send` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface also exposes that mechanism as `acceptInput(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering.
+**One acceptance mechanism, four intent helpers.** The concrete loop resolves `followup`, `queue`, `steer`, and `inject` into one (`target` × `wakeup`) acceptance mechanism. `followup` is `next-turn`/wakeup, `queue` is `next-turn`/no-wakeup, `steer` is `next-step`/wakeup, and `inject` is `next-step`/no-wakeup. The public structural interface exposes that mechanism as `send(ResolvedAgentInput)` for callers that already have fully resolved routing; every field is mandatory, and the discriminated input type excludes attached contexts from injection. The [intent-named delivery decision](2026-07-24-intent-named-agent-delivery.md) owns that superseding interface choice. Internally, `wakeup` means “make the model run”: wake a parked driver for an ordinary item or force a continuation for running steering.
 
 **inject keeps its mechanism.** `inject` appends durable model-facing context at the current log position (deferred behind an executing tool batch), or opens a one-shot `injection` turn when idle. It bypasses the FIFOs entirely, accepts no attached contexts, and defaults its source to `{ kind: 'plugin', plugin: '' }`, never `{ kind: 'user' }`.
 
@@ -34,9 +34,9 @@ Separately, `context/message` and `user/message` had converged: the surface proj
 
 ## Consequences
 
-The concrete driver has one delivery mechanism. Four common helpers hide its (`target` × `wakeup`) matrix behind caller intent, while `acceptInput` exposes the fully resolved matrix for advanced callers. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`.
+The concrete driver has one delivery mechanism. Four common helpers hide its (`target` × `wakeup`) matrix behind caller intent, while `send` exposes the fully resolved matrix for advanced callers. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every “human prompt?” check simplify to a `source` test. The goal fold's channel split moves from event type to `source.round`, and every consumer that filtered `context/message` filters `user/message` by source. The turn-enclosure and reconstruction invariants are unchanged: an idle injection still wraps a one-shot turn, now emitting `user/message` instead of `context/message`.
 
-Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is left hanging). `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` both at the in-turn stop point and on the post-turn drain of late steering, and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like public steering. Injection validates its payload before opening an idle one-shot turn; `InjectOptions` omits attached contexts, while the non-waking next-step variant of `ResolvedAgentInput` requires an empty context tuple.
+Internally, `wakeup` is the “should the model run” signal, so the inbox distinguishes `hasWakingQueued` (drives the loop and idle/quiescence decisions) from `hasQueued` (anything to dequeue): a lone `queue()` item stays parked at idle and rides along the next waking follow-up, and `whenIdle`/`cancel` settle quiescence off the waking signal (a lone quiet item takes `whenIdle`'s fast path, so no waiter is left hanging). `SendOptions.meta` on a queued or steering message is carried onto the durable `user/message`/`steering/message`, matching injection; it is intentionally absent from the live `AgentMessage`, which carries only routing facts. Every enqueued id gets exactly one terminal lifecycle event: a terminal stop that drops pending steering emits `agent/inbox/discard` both at the in-turn stop point and on the post-turn drain of late steering, and disposal discards any still-pending items before the loop exits. The `agent/inbox/*` payload is frozen so a listener cannot mutate the shared correlation object mid-dispatch, and a loop-authored continuation reason is snapshotted and frozen like public steering. Injection validates its payload before opening an idle one-shot turn; `InjectOptions` omits attached contexts, while the non-waking next-step variant of `ResolvedAgentInput` requires an empty context tuple.
 
 ## Related
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md

@@ -12,7 +12,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
 
 ## 决策
 
-**一种接受机制,四种意图辅助方法。** 具体循环把 `send`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`send` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口还将该机制暴露为 `acceptInput(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。
+**一种接受机制,四种意图辅助方法。** 具体循环把 `followup`、`queue`、`steer` 和 `inject` 解析到同一个(`target` × `wakeup`)接受机制中。`followup` 是 `next-turn`/wakeup,`queue` 是 `next-turn`/no-wakeup,`steer` 是 `next-step`/wakeup,`inject` 是 `next-step`/no-wakeup。公开的结构化接口将该机制暴露为 `send(ResolvedAgentInput)`;调用方若已持有完全解析的路由信息,即可使用该方法。使用时必须提供所有字段,可辨识输入类型也不允许注入携带附加上下文。取代旧接口的选择由[按意图命名的投递决策](2026-07-24-intent-named-agent-delivery.md)负责说明。内部的 `wakeup` 表示「让模型运行」:为一条普通消息唤醒处于停泊状态的驱动器,或强制运行中的 steering 继续执行。
 
 **inject 保留其机制。** `inject` 在当前日志位置追加持久、面向模型的上下文(在执行中的工具批处理之后延迟处理),或在空闲时开启一个一次性的 `injection` 轮次。它完全绕过 FIFO,不接受附加上下文,并把来源默认设为 `{ kind: 'plugin', plugin: '' }`,绝不是 `{ kind: 'user' }`。
 
@@ -34,9 +34,9 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send`
 
 ## 后果
 
-具体驱动器只有一个投递机制。四种常用辅助方法以调用方意图封装其(`target` × `wakeup`)矩阵,而 `acceptInput` 则向高级调用方暴露完全解析后的矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。
+具体驱动器只有一个投递机制。四种常用辅助方法以调用方意图封装其(`target` × `wakeup`)矩阵,而 `send` 则向高级调用方暴露完全解析后的矩阵。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处「是否人类提示词?」检查都简化为一次 `source` 判断。goal 折叠的通道区分从事件类型改到 `source.round`,此前过滤 `context/message` 的每个消费方都改为按来源过滤 `user/message`。轮次封闭与重建的不变量保持不变:空闲状态下的一次注入仍然封装成一个一次性轮次,只是现在发出 `user/message` 而非 `context/message`。
 
-在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会让任何等待者悬而未决)。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。注入会在打开空闲状态的一次性轮次之前校验其载荷;`InjectOptions` 不包含附加上下文,而 `ResolvedAgentInput` 中不唤醒的下一步变体要求使用空上下文元组。
+在内部,`wakeup` 是“模型是否应当运行”的信号,因此 inbox 区分 `hasWakingQueued`(驱动 loop 以及空闲/静默判定)与 `hasQueued`(是否有任何可 dequeue 的项):一个孤立的 `queue()` 项会停泊在空闲状态,并随下一条会唤醒驱动器的后续消息一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默(一个孤立的静默项走 `whenIdle` 的快速路径,因此不会让任何等待者悬而未决)。排队消息或 steering 消息上的 `SendOptions.meta` 会被带到持久的 `user/message`/`steering/message` 上,与注入保持一致;它有意不放在实时的 `AgentMessage` 上,后者只携带路由事实。每个已入队的 id 都恰好得到一个终止性生命周期事件:一次会丢弃待处理 steering 项的终止性停止会为它发出 `agent/inbox/discard`,既在轮次内的停止点,也在轮次结束后对迟到 steering 的清空时;dispose(资源释放)会在 loop 退出前丢弃所有仍在等待的项。`agent/inbox/*` 的事件载荷已被冻结,因此监听器无法在分发中途修改共享的关联对象,而由 loop 生成的继续原因会像公开 steering 一样被快照并冻结。注入会在打开空闲状态的一次性轮次之前校验其载荷;`InjectOptions` 不包含附加上下文,而 `ResolvedAgentInput` 中不唤醒的下一步变体要求使用空上下文元组。
 
 ## 相关
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.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
-2026-07-24-intent-named-agent-delivery.md: 54ea6353a516830795d51c395706241a4570260d
-2026-07-24-intent-named-agent-delivery.zh.md: 0d903a0d5dd383a154ade02ee8bed607d1f986b1
+2026-07-24-intent-named-agent-delivery.md: 32b0502350063610efff746cbef779e8225055eb
+2026-07-24-intent-named-agent-delivery.zh.md: ce8860b397497f4de587a9373d1cd300cf7dab29

+ 6 - 6
.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md

@@ -14,14 +14,14 @@ Sharing helper implementations through an abstract `Agent` class also makes the
 
 `Agent` is a structural interface with four intent-named delivery helpers:
 
-- `send()` queues an ordinary turn and wakes the driver.
+- `followup()` queues an ordinary turn and wakes the driver.
 - `queue()` queues an ordinary turn without waking an idle driver.
 - `steer()` targets the running turn and requests another step; while idle it becomes a waking ordinary turn.
 - `inject()` appends model-facing context without running the model.
 
-`send`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` is absent: ordinary `send` already names the established common operation, and “follow-up” is false for a session's first message.
+`followup`, `queue`, and `steer` accept `SendOptions`; `inject` accepts `InjectOptions`, which omits attached contexts because injection has no inbox item to own them. `followup` names the waking next-turn operation used for both initial prompts and later independent prompts.
 
-`Agent` also exposes `acceptInput(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The name says what the synchronous boundary guarantees: acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery.
+`Agent` also exposes `send(ResolvedAgentInput)` for callers that already hold the complete route. Every field is mandatory: content, source, contexts, metadata (possibly `undefined`), target, and wakeup. The discriminated union requires the empty context tuple for non-waking next-step injection. `ReactLoopAgent` implements this method once, and all four helpers resolve their defaults before delegating to it. The method accepts the delivery facts as one resolved input; acceptance can still lead to later dequeue, discard, or durable injection rather than eventual delivery.
 
 The target/wakeup matrix is an explicit advanced part of the structural `Agent` interface, not the ordinary helper options and not a base-class implementation seam. With one concrete adapter, a protected subclass seam would be hypothetical; callers and tests use the same public interface.
 
@@ -29,11 +29,11 @@ The target/wakeup matrix is an explicit advanced part of the structural `Agent`
 
 **Keep the resolved primitive private.** This minimizes the public method count, but forces adapters that already hold exact target/wakeup facts to reverse-map them into helper calls and removes the reusable type for that resolved state.
 
-**Use configurable `send` as the primitive.** Even mandatory routing arguments would make the common method carry advanced concerns. Keeping `send` semantic preserves its simple defaulted call shape; the separate discriminated input type rejects attached contexts on injection.
+**Use configurable `send(content, options)` as the primitive.** Optional routing fields would let advanced-looking calls silently become ordinary sends. One mandatory discriminated input keeps the resolved route explicit and rejects attached contexts on injection.
 
-**Rename the primitive to `sendInternal` or `addMessageAdvanced`.** A public method must not describe itself as internal. `addMessageAdvanced` is also inaccurate because acceptance may wake, queue, steer, inject, or later discard work; `acceptInput` names the synchronous guarantee instead.
+**Name the primitive `acceptInput`, `sendInternal`, or `addMessageAdvanced`.** `acceptInput` describes the synchronous acceptance boundary but not the caller's delivery action. A public method must not describe itself as internal, and `addMessageAdvanced` is inaccurate because the input may later be discarded.
 
-**Keep `followup` as the waking-turn helper.** Existing production callers use `send`, while `followup` has no TypeScript caller and does not describe the first ordinary message. Reusing `send` preserves the familiar intent without retaining an alias.
+**Use `send(content, options)` as the waking-turn helper.** This reserves the shortest delivery name for one preset and forces callers with complete target/wakeup facts through a less direct primitive name. `followup` distinguishes the next-turn/wakeup intent while leaving `send` for the resolved operation.
 
 **Bind source first through a public sender object.** A source-bound adapter can make attribution explicit for repeated producers, but it adds another public object and does not simplify one-off human input. The existing source default remains, with the standing requirement that non-human producers label their content.
 

+ 6 - 6
.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.zh.md

@@ -14,14 +14,14 @@ Status: implemented
 
 `Agent` 是一个结构化接口,提供四种按意图命名的投递辅助方法:
 
-- `send()` 将一个普通轮次入队并唤醒驱动器。
+- `followup()` 将一个普通轮次入队并唤醒驱动器。
 - `queue()` 将一个普通轮次入队,但不唤醒空闲驱动器。
 - `steer()` 以运行中的轮次为目标并请求另一个步骤;空闲时,它会变成一个唤醒式普通轮次。
 - `inject()` 追加面向模型的上下文,但不运行模型。
 
-`send`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。接口不提供 `followup`:普通 `send` 已经为既有的常见操作命名,而「follow-up」不适用于会话的第一条消息
+`followup`、`queue` 和 `steer` 接收 `SendOptions`;`inject` 接收 `InjectOptions`,后者不包含附加上下文,因为注入没有 inbox 项来拥有它们。`followup` 为唤醒式下一轮操作命名,这项操作既用于初始提示词,也用于后续的独立提示词
 
-`Agent` 还公开 `acceptInput(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。这个名称说明同步边界所保证的事实:接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。
+`Agent` 还公开 `send(ResolvedAgentInput)`,供已经持有完整路由的调用方使用。每个字段都必须提供:内容、来源、上下文、元数据(可以是 `undefined`)、目标和唤醒标志。对于目标为下一步且不触发唤醒的注入,可辨识联合类型要求上下文为空元组。`ReactLoopAgent` 统一实现这个方法;四个辅助方法都会先解析各自的默认值,再委托给它。调用方以一个解析后的输入向该方法提交各项投递事实;接受之后,工作仍可能在稍后出队、被丢弃或持久注入,而不是最终必然送达。
 
 结构化 `Agent` 接口显式包含面向高级用法的 target/wakeup 矩阵;该矩阵不属于普通辅助方法的选项,也不是基类实现 seam。只有一个具体适配器时,protected 子类 seam 只是假想的;调用方和测试使用同一个公开接口。
 
@@ -29,11 +29,11 @@ Status: implemented
 
 **让解析后的原语保持私有。** 这会把公开方法数量降到最低,但会迫使已经持有精确 target/wakeup 路由信息的适配器将其反向映射为辅助方法调用,也会移除表示该解析后状态的可复用类型。
 
-**使用可配置的 `send` 作为原语。** 即使强制提供所有路由参数,也会让这个常用方法承载高级用法的复杂性。让 `send` 只表达语义意图,可以保留其带默认值的简单调用形式;单独的可辨识输入类型则会拒绝为注入附加上下文。
+**使用可配置的 `send(content, options)` 作为原语。** 可选路由字段会让看似高级的调用悄然变成普通投递。一个各字段均为必填项的可辨识输入既能让解析后的路由保持显式,也会拒绝为注入附加上下文。
 
-**把原语命名为 `sendInternal` 或 `addMessageAdvanced`。** 公开方法不应在名称中把自己称为内部方法。`addMessageAdvanced` 也不准确,因为接受操作可能唤醒、排队、中途引导、注入,或在之后丢弃工作;`acceptInput` 描述的则是同步边界所保证的事实
+**把原语命名为 `acceptInput`、`sendInternal` 或 `addMessageAdvanced`。** `acceptInput` 描述了同步接受边界,却没有描述调用方的投递操作。公开方法不应在名称中把自己称为内部方法,`addMessageAdvanced` 也不准确,因为输入可能在之后被丢弃
 
-**保留 `followup` 作为唤醒轮次的辅助方法。** 现有生产调用方使用 `send`,而 `followup` 没有 TypeScript 调用方,也无法描述第一条普通消息。复用 `send` 可以保留熟悉的意图,同时不保留别名
+**使用 `send(content, options)` 作为唤醒轮次的辅助方法。** 这会让最简短的投递名称只表示一种预设操作,并迫使持有完整 target/wakeup 信息的调用方改用一个不够直接的原语名称。`followup` 明确区分下一轮/唤醒意图,并把 `send` 留给解析后的操作
 
 **先通过公开的发送方对象绑定来源。** 对于重复产生消息的来源,来源绑定适配器可以明确标注归属,但它会增加一个公开对象,也不会简化一次性的人类输入。现有的来源默认值予以保留,同时继续要求非人类生产方标注其内容。
 

+ 1 - 1
docs/agent-lifecycle.md

@@ -17,7 +17,7 @@ sequenceDiagram
   participant Session
   participant Persistence
   participant SDK as UI or SDK listener
-  User->>Agent: send(content)
+  User->>Agent: followup(content)
   Agent-->>SDK: <code>agent/inbox/enqueue</code>
   Agent->>Driver: queued work wakes driver
   Driver-->>SDK: <code>agent/status</code> running

+ 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
-architecture.md: 937d0e5dc2d41140506ecb9483e1b2d35abcb41f
-architecture.zh.md: 255035c4018b9a4edc788441225d47cb43757c76
+architecture.md: 76c58e03282ef6d736da7d65b05c534c05c4c318
+architecture.zh.md: dddccf1e9238e617a453395731ee3f620ba5749d

+ 1 - 1
docs/architecture.md

@@ -131,7 +131,7 @@ Session events are turn-enclosed; reload closes an interrupted tail with a synth
 
 ### Agent Handles
 
-`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use `send()`, `queue()`, `steer()`, and `inject()`; callers may use mandatory-field `acceptInput()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown.
+`ctx.agents` returns `AgentHandle { agent, dispose() }`. Plugins use intent helpers `followup()`, `queue()`, `steer()`, and `inject()`; callers with exact routing facts use mandatory-field `send()` ([decision](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). `cancel()` and `whenIdle()` control lifecycle. Caller, provider, and handle co-own teardown.
 
 ### Agent Scope
 

+ 1 - 1
docs/architecture.zh.md

@@ -131,7 +131,7 @@ forever:
 
 ### Agent 句柄
 
-`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`、`queue()`、`steer()` 和 `inject()`;调用方可以使用各字段均为必填项的 `acceptInput()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。
+`ctx.agents` 返回 `AgentHandle { agent, dispose() }`。插件使用按意图命名的辅助方法 `followup()`、`queue()`、`steer()` 和 `inject()`;持有确切路由信息的调用方使用各字段均为必填项的 `send()`([决策](../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md))。`cancel()` 和 `whenIdle()` 控制生命周期。调用方、提供方和句柄共同拥有拆卸过程。
 
 ### Agent 作用域
 

+ 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
-extension-cookbook.md: 056be4298ed2bec2b78ed777d58f1f8a60a34b78
-extension-cookbook.zh.md: 41cdd4a7d14f32494d1dd5ae4a63c098d5640bdc
+extension-cookbook.md: c13b46e06a3b34512cd371e6a4868a6e932a575f
+extension-cookbook.zh.md: aeb5f905278c07344c68d80da05dc5daf299b4f6

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

@@ -36,7 +36,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an
 
 ## A UI plugin
 
-A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.send()` / `agent.steer()`.
+A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`.
 
 ```ts
 import type { Context } from 'cordis'
@@ -54,13 +54,13 @@ export function apply(ctx: Context) {
       render(event.data.chunk.text)
     }
   })
-  onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }]))
+  onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }]))
 }
 ```
 
 ## A client-driver plugin (external protocol bridge)
 
-A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `send()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence.
+A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `followup()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence.
 
 `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam.
 
@@ -99,9 +99,9 @@ Every product feature maps to a listener on a documented extension seam — the
 |---|---|
 | Hook system (user + project level) | listeners on `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation` — each interception waterfall returns a typed Decision; the `dsh-hooks-claude` / `dsh-hooks-codex` bridges map hook config files onto these seams |
 | `/goal` | `ctx.goals` owns durable state, `dsh-goal-session` schedules same-session rounds through the public `Agent`, and separate command/tool producers expose human/model control |
-| `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue |
+| `/loop` | on the `turn/end` session event, `followup()` the next iteration; or force-continue |
 | Dynamic workflow | `ctx.workflows` + the worker-thread 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 terminal `agent/turn-stop` |
-| Queued + steering messages | core `Agent.send()` / `Agent.steer()` |
+| Queued + steering messages | core `Agent.followup()` / `Agent.steer()` |
 | Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) |
 | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing |
 | AGENTS.md (root) | a section provider reading the file |
@@ -118,8 +118,8 @@ Every product feature maps to a listener on a documented extension seam — the
 | MCP | one plugin per server: discover tools → `ctx.tools.register()` |
 | Skills | section + tool registration; `inject()` skill content on invocation |
 | Memory | section provider + tool |
-| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy |
-| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `send()` |
+| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → `followup(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy |
+| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `followup()` |
 | Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` |
 | Model adapters | `LlmAdapter` subclass via `registerAdapter` (`dsh-llm-deepseek`, `dsh-llm-pi-ai`) |
 | Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works |

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

@@ -36,7 +36,7 @@ export function apply(ctx: Context) {
 
 ## UI 插件
 
-UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.send()` / `agent.steer()` 将输入驱动回去。
+UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。
 
 ```ts
 import type { Context } from 'cordis'
@@ -54,13 +54,13 @@ export function apply(ctx: Context) {
       render(event.data.chunk.text)
     }
   })
-  onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }]))
+  onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup([{ type: 'text', text }]))
 }
 ```
 
 ## 客户端驱动插件(外部协议桥接)
 
-*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。
+*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `followup()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。
 
 `packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。
 
@@ -99,9 +99,9 @@ export function apply(ctx: Context) {
 |---|---|
 | 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 |
 | `/goal` | `ctx.goals` 管理持久状态,`dsh-goal-session` 通过公共 `Agent` 调度同会话回合,独立的命令/工具生产方分别提供人类/模型控制 |
-| `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 |
+| `/loop` | 在 `turn/end` 会话事件上 `followup()` 下一次迭代;或强制继续 |
 | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 |
-| 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` |
+| 排队消息 + steering(中途引导) | 核心 `Agent.followup()` / `Agent.steer()` |
 | 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) |
 | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 |
 | AGENTS.md(根目录) | 一个读取该文件的 section provider |
@@ -118,8 +118,8 @@ export function apply(ctx: Context) {
 | MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` |
 | Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 |
 | 记忆 | section provider + 工具 |
-| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `send(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 |
-| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `send()` |
+| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `followup(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 |
+| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `followup()` |
 | 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` |
 | 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) |
 | 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 |

+ 2 - 2
docs/cordis-catalog/events.md

@@ -146,7 +146,7 @@ Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/t
 
 ### `agent/inbox/enqueue` — emit
 
-A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs and does not emit this.
+A detached, frozen item entered the agent's inbox (queued or steering FIFO). Source defaults are already applied, so `message` holds the exact accepted values. This is the enqueue-time live signal; the durable record is the eventual `user/message`/`steering/message`. Injection through `agent.inject()` or equivalent `send()` routing bypasses the FIFOs and does not emit this.
 
 ```ts cordis-catalog
 /**
@@ -154,7 +154,7 @@ A detached, frozen item entered the agent's inbox (queued or steering FIFO). Sou
  * FIFO). Source defaults are already applied, so `message` holds the exact
  * accepted values. This is the enqueue-time live signal; the durable record
  * is the eventual `user/message`/`steering/message`. Injection through
- * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs
+ * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs
  * and does not emit this.
  * @param agent - the agent whose inbox received the item.
  * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).

+ 6 - 6
docs/core-data-structures/core.md

@@ -361,7 +361,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types
 
 ```ts type-equiv
 /**
- * Options for {@link Agent.send}, {@link Agent.queue}, and {@link Agent.steer}.
+ * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}.
  * An omitted source attests direct human input as `{ kind: 'user' }` and may
  * authorize policy consumers, so non-human producers must label their content.
  */
@@ -392,7 +392,7 @@ The advanced acceptance form makes every default explicit and rules out attached
 
 ```ts type-equiv
 /**
- * Fully specified input for {@link Agent.acceptInput}. Unlike the intent-named
+ * Fully specified input for {@link Agent.send}. Unlike the intent-named
  * helpers, this form applies no defaults: callers provide content, source,
  * contexts, metadata (including explicit `undefined`), target, and wakeup.
  * The union excludes attached contexts from non-waking next-step injection.
@@ -423,7 +423,7 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t
 ```ts type-equiv
 /**
  * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id`
- * is the value returned by the accepting helper or {@link Agent.acceptInput},
+ * is the value returned by the accepting helper or {@link Agent.send},
  * stable across this message's enqueue, dequeue, and discard events. Source
  * defaults, when applicable, are already applied, so these are the exact values
  * the item was accepted with.
@@ -433,7 +433,7 @@ The `agent/inbox/*` live events carry one accepted message; injection bypasses t
  * `steering/message`, not live-event routing data.
  */
 interface AgentMessage {
-  /** The id returned by the accepting helper or {@link Agent.acceptInput}. */
+  /** The id returned by the accepting helper or {@link Agent.send}. */
   id: AgentMessageId
   content: ContentBlock[]
   source: MessageSource
@@ -489,7 +489,7 @@ interface Agent {
    * @param options - source, attached contexts, and durable model-hidden meta.
    * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
    */
-  send(content: ContentBlock[], options?: SendOptions): AgentMessageId
+  followup(content: ContentBlock[], options?: SendOptions): AgentMessageId
 
   /**
    * Queue an ordinary message without waking an idle driver. The item retains
@@ -539,7 +539,7 @@ interface Agent {
    * @param input - the resolved content, attribution, context, metadata, and routing facts.
    * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable.
    */
-  acceptInput(input: ResolvedAgentInput): AgentMessageId
+  send(input: ResolvedAgentInput): AgentMessageId
 
   /**
    * Clear queued and steering work — unless `keepInbox` — and abort the active

+ 1 - 1
docs/core-data-structures/persistence.md

@@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite
 
 ## The flush checkpoint
 
-`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `send()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush.
+`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `followup()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush.
 
 ## Crash recovery preserves an interrupted turn
 

+ 1 - 1
docs/core-data-structures/session-reference.md

@@ -36,7 +36,7 @@ interface SessionReferenceCandidate {
 
 ## Prepared messages
 
-Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `send()` or `steer()` call.
+Preparation preserves readable current-message content and returns at most one aggregated context. The host binds `contexts` to that exact `followup()` or `steer()` call.
 
 ```ts type-equiv
 /** Message payload and the zero-or-one durable snapshot contexts bound to it. */

+ 1 - 1
docs/defensive-patterns.md

@@ -12,7 +12,7 @@ When an interface documents two valid ways to signal something — an adapter ma
 
 ## Async state is not synchronous state
 
-`agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly.
+`agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items. The guard cuts both ways: if the awaited transition can never occur (EOF with no work submitted → never `running`), the wait hangs — handle the "nothing to wait for" branch explicitly.
 
 ## Dispose must reach quiescence, not just request it
 

+ 2 - 2
docs/i18n/style-samples.md

@@ -28,9 +28,9 @@
 
 **dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。
 
-> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-send result: several queued sends run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items.
+> **Async state is not synchronous state** — `agent.followup()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) instead of treating status as a per-follow-up result: several queued follow-ups run as consecutive turns under one `running` interval, while cancellation or disposal can discard unstarted items.
 
-**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `send()` 的结果:多次排队的 `send()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。
+**异步状态不等同于同步瞬时状态**:调用 `agent.followup()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要把状态当作逐次 `followup()` 的结果:多次排队的 `followup()` 会作为连续轮次运行,但可能共用一个 `running` 区间;取消或资源释放还可能丢弃尚未启动的队列项。
 
 ## ③ 测试政策清单
 

ファイルの差分が大きいため隠しています
+ 0 - 0
examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl


ファイルの差分が大きいため隠しています
+ 0 - 0
examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl


+ 5 - 5
examples/cordis-agent/tests/cordis-tools.e2e.ts

@@ -42,7 +42,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
     const log = vi.spyOn(console, 'log').mockImplementation(() => {})
     const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
-    agent.send([{
+    agent.followup([{
       type: 'text',
       text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' '
         + 'cordis event and logs every change with console.log. Reply "mounted" once done.',
@@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
     })
     expect(resultText(mid)).toContain('dyn-')
 
-    agent.send([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }])
+    agent.followup([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }])
     await waitForIdle(ctx, agent)
 
     const after = await ctx.tools.execute({
@@ -72,7 +72,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
     ctx = await cordisHarness()
     const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
-    agent.send([{
+    agent.followup([{
       type: 'text',
       text: 'Give yourself a new tool: use cordis_mount to mount a plugin with '
         + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) '
@@ -119,7 +119,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
     ctx = await cordisHarness()
     const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
-    agent.send([{
+    agent.followup([{
       type: 'text',
       text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls '
         + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with '
@@ -144,7 +144,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif
       .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text))
     expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true)
 
-    agent.send([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }])
+    agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }])
     await waitForIdle(ctx, agent)
 
     // The consumer must have been parked by cordis itself: service gone,

+ 2 - 2
examples/headless-agent/tests/code-mode.e2e.ts

@@ -311,7 +311,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
     ctx = await codeModeHarness(workdir)
     const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
-    agent.send([{
+    agent.followup([{
       type: 'text',
       text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, '
         + 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), '
@@ -363,7 +363,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
       agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
     })
 
-    handle.agent.send([{
+    handle.agent.followup([{
       type: 'text',
       text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?',
     }])

+ 1 - 1
examples/headless-agent/tests/coding-task.e2e.ts

@@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test
     ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
     const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
-    agent.send([{
+    agent.followup([{
       type: 'text',
       text: 'In the current directory, `node add.test.js` fails because add.js has a bug. '
         + 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. '

+ 1 - 1
examples/headless-agent/tests/compaction.e2e.ts

@@ -46,7 +46,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
     })
     const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
-    agent.send([{
+    agent.followup([{
       type: 'text',
       text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a '
         + 'time using cat (a separate bash command for each). After reading all four, tell me how '

+ 1 - 1
examples/headless-agent/tests/full-loop.e2e.ts

@@ -30,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas
     ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
     const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
-    agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }])
+    agent.followup([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }])
     await waitForIdle(ctx, agent)
 
     const events = [...agent.session.events]

+ 2 - 2
examples/headless-agent/tests/resume.e2e.ts

@@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
       sessionId: SESSION_ID,
       agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
     })).agent
-    first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
+    first.followup([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
     await waitForIdle(ctx, first)
     await ctx.fiber.dispose()
     ctx = undefined
@@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
     // The prior user turn is in the rehydrated log before the model is asked.
     expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET)
 
-    resumed.send([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }])
+    resumed.followup([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }])
     await waitForIdle(ctx, resumed)
 
     // The model recalls it — only possible from the resumed history.

+ 1 - 1
examples/headless-agent/tests/todo-write.e2e.ts

@@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a
     ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT })
     const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
-    agent.send([{ type: 'text', text:
+    agent.followup([{ type: 'text', text:
       'Use the todo_write tool to record a plan of exactly two steps: first '
       + '"inspect the failing test" (in_progress), then "apply the fix" (pending). '
       + 'Send both in one todo_write call, then reply with the single word DONE.' }])

+ 5 - 5
packages/bash/tool-bash/tests/integration.spec.ts

@@ -109,7 +109,7 @@ describe('bash tool through the agent loop', () => {
     const location = ctx.sessionPersistence.locate(agent.session.header)
     expect(location?.kind).toBe('jsonl')
 
-    agent.send([{ type: 'text', text: 'inspect the current session' }])
+    agent.followup([{ type: 'text', text: 'inspect the current session' }])
     await waitForIdle(ctx, agent)
 
     const result = findEvent(events(agent), 'tool/result')
@@ -128,7 +128,7 @@ describe('bash tool through the agent loop', () => {
     const ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'run echo integration-ok' }])
+    agent.followup([{ type: 'text', text: 'run echo integration-ok' }])
     await waitForIdle(ctx, agent)
 
     const log = events(agent)
@@ -160,7 +160,7 @@ describe('bash tool through the agent loop', () => {
     const ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'run exit 9' }])
+    agent.followup([{ type: 'text', text: 'run exit 9' }])
     await waitForIdle(ctx, agent)
 
     const toolResult = findEvent(events(agent), 'tool/result')
@@ -180,7 +180,7 @@ describe('bash tool through the agent loop', () => {
     const ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }])
+    agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }])
     await waitForIdle(ctx, agent)
 
     const firstResult = findEvent(events(agent), 'tool/result')
@@ -200,7 +200,7 @@ describe('bash tool through the agent loop', () => {
     expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' })
 
     // The next turn collects the output through the generic task tool.
-    agent.send([{ type: 'text', text: 'collect it' }])
+    agent.followup([{ type: 'text', text: 'collect it' }])
     await waitForIdle(ctx, agent)
     const readResult = findEvent(events(agent), 'tool/result', 'last')
     expect(readResult.data.isError).toBe(false)

+ 5 - 5
packages/compact/compact-basic/tests/compact-loop-repro.spec.ts

@@ -197,7 +197,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
         provider: 'unconfigured-agent-fallback',
         model: 'unconfigured-agent-fallback',
       })
-      agent.send([{ type: 'text', text: 'do a routed multi-step task' }])
+      agent.followup([{ type: 'text', text: 'do a routed multi-step task' }])
       await waitForIdle(ctx, agent)
 
       expect(agent.session.requestHeader()?.config.model).toBe('mock')
@@ -215,7 +215,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
     const { ctx } = await harness(8)
     try {
       const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'do tool work' }])
+      agent.followup([{ type: 'text', text: 'do tool work' }])
       await waitForIdle(ctx, agent)
 
       const events = [...agent.session.events]
@@ -241,7 +241,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
     const { ctx } = await harness(8)
     try {
       const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'do a long multi-step task' }])
+      agent.followup([{ type: 'text', text: 'do a long multi-step task' }])
       await waitForIdle(ctx, agent)
 
       const events = [...agent.session.events]
@@ -297,7 +297,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
         })
         seedOverflowHistory(agent)
 
-        agent.send([{ type: 'text', text: 'continue from history' }])
+        agent.followup([{ type: 'text', text: 'continue from history' }])
         await agent.whenIdle()
 
         expect(adapter.conversationRequests).toHaveLength(2)
@@ -360,7 +360,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
     try {
       const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' })
       seedOverflowHistory(agent)
-      agent.send([{ type: 'text', text: 'continue from history' }])
+      agent.followup([{ type: 'text', text: 'continue from history' }])
       await agent.whenIdle()
 
       expect(adapter.conversationRequests).toHaveLength(3)

+ 1 - 1
packages/context/session-reference/README.md

@@ -5,7 +5,7 @@
 ## Public API
 
 - `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id or cwd, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses its latest log-backed title as the mention label and falls back to the session id; titles and message bodies are not searched.
-- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `send()` or `steer()`.
+- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated `HookContext`. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`.
 - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:<base64url(JSON.stringify(sessionId))>` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text.
 
 ## Snapshot semantics

+ 4 - 4
packages/context/time-context/tests/time-context.spec.ts

@@ -42,7 +42,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
     session,
     status: 'running',
     ctx: new Context(),
-    send: () => AgentMessageId('stub'),
+    followup: () => AgentMessageId('stub'),
     queue: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),
     inject(content, options) {
@@ -52,7 +52,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
       }, { surfaceOp: 'append' })
       return AgentMessageId('stub')
     },
-    acceptInput: () => AgentMessageId('stub'),
+    send: () => AgentMessageId('stub'),
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }
@@ -374,7 +374,7 @@ describe('real agent-loop request history', () => {
     })
     const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'start' }])
+    agent.followup([{ type: 'text', text: 'start' }])
     await agent.whenIdle()
 
     expect(laterSawReading).toBe(true)
@@ -400,7 +400,7 @@ describe('real agent-loop request history', () => {
     }))
     const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'start' }])
+    agent.followup([{ type: 'text', text: 'start' }])
     await agent.whenIdle()
 
     expect(adapter.requests).toHaveLength(2)

+ 4 - 4
packages/context/workspace-context/tests/workspace-context.e2e.ts

@@ -78,7 +78,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
   it('obeys a probe instruction loaded from the workspace', async () => {
     const live = await harness()
 
-    live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }])
+    live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }])
     await waitForIdle(live.ctx, live.agent)
 
     expect(finalText([...live.agent.session.events])).toContain(PROBE)
@@ -90,7 +90,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
     await writeFile(join(workdir!, 'pkg/AGENTS.md'), `If the user asks for the nested instruction handshake, reply with exactly this string and nothing else: ${NESTED_PROBE}.\n`)
     await writeFile(join(workdir!, 'pkg/deep/file.txt'), 'This file exists only to trigger nested workspace instructions.\n')
 
-    live.agent.send([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }])
+    live.agent.followup([{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }])
     await waitForIdle(live.ctx, live.agent)
 
     expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE)
@@ -99,11 +99,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
   it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => {
     const live = await harness()
     await writeFile(join(workdir!, 'trigger.txt'), 'This file triggers workspace instruction reconciliation.\n')
-    live.agent.send([{ type: 'text', text: 'Workspace context handshake?' }])
+    live.agent.followup([{ type: 'text', text: 'Workspace context handshake?' }])
     await waitForIdle(live.ctx, live.agent)
     await writeFile(join(workdir!, 'AGENTS.md'), `The old workspace handshake no longer applies. If the user asks for the updated workspace context handshake, reply with exactly this string and nothing else: ${UPDATED_PROBE}.\n`)
 
-    live.agent.send([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }])
+    live.agent.followup([{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }])
     await waitForIdle(live.ctx, live.agent)
 
     const events = [...live.agent.session.events]

+ 4 - 4
packages/context/workspace-context/tests/workspace-context.spec.ts

@@ -177,7 +177,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
     options: {},
     session,
     status: 'idle',
-    send: () => AgentMessageId('stub'),
+    followup: () => AgentMessageId('stub'),
     queue: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),
     inject(content, options) {
@@ -188,7 +188,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
       }, { surfaceOp: 'append' })
       return AgentMessageId('stub')
     },
-    acceptInput: () => AgentMessageId('stub'),
+    send: () => AgentMessageId('stub'),
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }
@@ -1717,11 +1717,11 @@ describe('dynamic nested workspace context injection', () => {
         },
       }))
 
-      agent.send([{ type: 'text', text: 'read and abort' }])
+      agent.followup([{ type: 'text', text: 'read and abort' }])
       await agent.whenIdle()
       expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
 
-      agent.send([{ type: 'text', text: 'retry the read' }])
+      agent.followup([{ type: 'text', text: 'retry the read' }])
       await agent.whenIdle()
 
       const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')

+ 18 - 2
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -899,7 +899,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
     name: 'agent/inbox/enqueue',
     mode: 'emit',
     signature: '\'agent/inbox/enqueue\'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void',
-    jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
+    jsDoc: '/**\n * A detached, frozen item entered the agent\'s inbox (queued or steering\n * FIFO). Source defaults are already applied, so `message` holds the exact\n * accepted values. This is the enqueue-time live signal; the durable record\n * is the eventual `user/message`/`steering/message`. Injection through\n * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs\n * and does not emit this.\n * @param agent - the agent whose inbox received the item.\n * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
     summary: 'A detached, frozen item entered the agent\'s inbox (queued or steering FIFO).',
   },
   {
@@ -1181,7 +1181,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
 export const TYPE_API: readonly TypeApiEntry[] = [
   {
     name: 'Agent',
-    declaration: 'export interface Agent {\n    readonly id: SessionId;\n    readonly options: AgentOptions;\n    readonly session: Session;\n    readonly status: AgentStatus;\n    readonly ctx: Context;\n    send(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n    queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n    steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n    inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n    acceptInput(input: ResolvedAgentInput): AgentMessageId;\n    cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n    whenIdle(): Promise<void>;\n}',
+    declaration: 'export interface Agent {\n    readonly id: SessionId;\n    readonly options: AgentOptions;\n    readonly session: Session;\n    readonly status: AgentStatus;\n    readonly ctx: Context;\n    followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n    queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n    steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n    inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n    send(input: ResolvedAgentInput): AgentMessageId;\n    cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n    whenIdle(): Promise<void>;\n}',
   },
   {
     name: 'AgentCancelCause',
@@ -1547,6 +1547,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'JsonValue',
     declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n    [key: string]: JsonValue;\n};',
   },
+  {
+    name: 'LlmAdapter',
+    declaration: 'export abstract class LlmAdapter {\n    providerInfo(provider: string): LlmProviderInfo;\n    listModels(_provider: string): Promise<readonly LlmModelInfo[]>;\n    resolveModelContext(_provider: string, _model: string): Promise<LlmModelContext | undefined>;\n    abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
+  },
   {
     name: 'LlmCallConfig',
     declaration: 'export interface LlmCallConfig {\n    provider: string;\n    model: string;\n    temperature?: number;\n    maxTokens?: number;\n    stop?: string[];\n}',
@@ -1755,6 +1759,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SendOptions',
     declaration: 'export interface SendOptions {\n    source?: MessageSource;\n    contexts?: HookContext[];\n    meta?: JsonValue;\n}',
   },
+  {
+    name: 'Session',
+    declaration: 'export class Session {\n    get surface(): SessionSurface;\n    readonly header: SessionHeader;\n    get id(): SessionId;\n    constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n    get events(): readonly SessionEvent[];\n    get seq(): number;\n    append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n        opts: SurfaceIntent\n    ] : [\n    ]): SessionEvent<T>;\n    requestHeader(): EpochHeader | undefined;\n    deriveMessages(): Message[];\n    deriveEventMessage(event: SessionEvent): Message | null;\n}',
+  },
   {
     name: 'SessionAvailability',
     declaration: 'export type SessionAvailability = \'live\' | \'persisted\';',
@@ -1887,6 +1895,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SessionSearchRequest',
     declaration: 'export interface SessionSearchRequest {\n    query: string;\n    sessionFilters?: readonly SessionResultFilter[];\n    eventFilters?: readonly SessionEventMetadataFilter[];\n    limit?: number;\n    cursor?: SessionSearchCursor;\n}',
   },
+  {
+    name: 'SessionSurface',
+    declaration: 'export interface SessionSurface {\n    readonly nodes: readonly number[];\n    readonly replaceGeneration: number;\n}',
+  },
   {
     name: 'SessionSurfaceSnapshot',
     declaration: 'export interface SessionSurfaceSnapshot {\n    session: SessionHeader;\n    capturedThroughSeq: number | null;\n    events: SurfaceEvent[];\n}',
@@ -2019,6 +2031,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SurfaceEventType',
     declaration: 'export type SurfaceEventType = \'user/message\' | \'assistant/message\' | \'tool/result\' | \'steering/message\';',
   },
+  {
+    name: 'SurfaceIntent',
+    declaration: 'export interface SurfaceIntent {\n    surfaceOp: SurfaceOp;\n    sourceEventSeqs?: number[];\n}',
+  },
   {
     name: 'SurfaceOp',
     declaration: 'export type SurfaceOp = \'append\' | {\n    op: \'replace\';\n    start: number;\n    end: number;\n};',

+ 2 - 0
packages/cordis/tool-cordis/tests/inspect.spec.ts

@@ -61,6 +61,8 @@ describe('cordis_inspect', () => {
     // generated TYPE_API — a consumer can see field types, not just names).
     expect(report).toContain('type shapes (referenced by the signatures above')
     expect(report).toContain('export interface ToolExecution')
+    expect(report).toContain('export class Session')
+    expect(report).toContain('export interface SessionSurface')
     // A type only reachable through a NOT-live service (e.g. bash) is scoped out.
     expect(report).not.toContain('export interface BashRunResult')
     // The inherited ctx surface closes the section.

+ 1 - 1
packages/cordis/tool-cordis/tests/integration.spec.ts

@@ -47,7 +47,7 @@ describe('cordis tools through the agent loop', () => {
     const ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
+    agent.followup([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }])
     await waitForIdle(ctx, agent)
 
     const log = agent.session.events

ファイルの差分が大きいため隠しています
+ 0 - 0
packages/core/agent-loop/README.md


+ 8 - 8
packages/core/agent-loop/src/agent.ts

@@ -232,7 +232,7 @@ export class ReactLoopAgent implements Agent {
   }
 
   /** Accept one fully resolved agent input through the concrete driver's routing matrix. */
-  acceptInput(input: ResolvedAgentInput): AgentMessageId {
+  send(input: ResolvedAgentInput): AgentMessageId {
     this.assertNotDisposed()
     const id = AgentMessageId(randomUUID())
     const { target, wakeup } = input
@@ -251,8 +251,8 @@ export class ReactLoopAgent implements Agent {
     return id
   }
 
-  send(content: ContentBlock[], options?: SendOptions): AgentMessageId {
-    return this.acceptInput({
+  followup(content: ContentBlock[], options?: SendOptions): AgentMessageId {
+    return this.send({
       content,
       target: 'next-turn',
       wakeup: true,
@@ -263,7 +263,7 @@ export class ReactLoopAgent implements Agent {
   }
 
   queue(content: ContentBlock[], options?: SendOptions): AgentMessageId {
-    return this.acceptInput({
+    return this.send({
       content,
       target: 'next-turn',
       wakeup: false,
@@ -274,7 +274,7 @@ export class ReactLoopAgent implements Agent {
   }
 
   steer(content: ContentBlock[], options?: SendOptions): AgentMessageId {
-    return this.acceptInput({
+    return this.send({
       content,
       target: 'next-step',
       wakeup: true,
@@ -285,7 +285,7 @@ export class ReactLoopAgent implements Agent {
   }
 
   inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId {
-    return this.acceptInput({
+    return this.send({
       content,
       target: 'next-step',
       wakeup: false,
@@ -488,9 +488,9 @@ export class ReactLoopAgent implements Agent {
     if (this._status !== 'disposed') {
       // Snapshot any still-pending inbox items, then CLEAR and mark disposed
       // BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit
-      // order so a re-entrant send()/cancel() from a discard listener throws
+      // order so a re-entrant followup()/cancel() from a discard listener throws
       // `disposed` (or finds an empty inbox) instead of leaking or double-
-      // discarding an id. `send()` emits enqueue unconditionally, so the discard
+      // discarding an id. `followup()` emits enqueue unconditionally, so the discard
       // is unconditional too (even on an unpublished rollback) to keep every
       // enqueued id matched.
       const discarded = this.#inbox.pending()

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

@@ -58,7 +58,7 @@ export class Inbox {
    * True while a queued message wants to wake the driver — the "should the loop
    * run" signal read by the idle wait's fast path, the loop's idle-publish
    * check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this
-   * false, so the driver stays parked until a waking send (or a waking item
+   * false, so the driver stays parked until a waking follow-up (or a waking item
    * ahead of it in FIFO order) drives the loop; the quiet item then rides along.
    */
   get hasWakingQueued(): boolean {

+ 1 - 1
packages/core/agent-loop/tests/agent-initiator.spec.ts

@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
 }
 
 function send(agent: Agent, text: string): void {
-  agent.send([{ type: 'text', text }])
+  agent.followup([{ type: 'text', text }])
 }
 
 /** Adapter that holds both drivers at the same awaited continuation. */

+ 6 - 6
packages/core/agent-loop/tests/agent.spec.ts

@@ -48,7 +48,7 @@ function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): P
 }
 
 function send(agent: Agent, text: string) {
-  agent.send([{ type: 'text', text }])
+  agent.followup([{ type: 'text', text }])
 }
 
 describe('Agent', () => {
@@ -83,7 +83,7 @@ describe('Agent', () => {
     await ctx.fiber.dispose()
   })
 
-  it('acceptInput exposes the fully resolved delivery path without applying helper defaults', async () => {
+  it('send exposes the fully resolved delivery path without applying helper defaults', async () => {
     const adapter = new MockAdapter([textResponse('accepted')])
     const ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -92,7 +92,7 @@ describe('Agent', () => {
       if (subject === agent) enqueued.resolve(message)
     })
 
-    const id = agent.acceptInput({
+    const id = agent.send({
       content: [{ type: 'text', text: 'advanced input' }],
       source: { kind: 'plugin', plugin: 'advanced-caller' },
       contexts: [],
@@ -117,7 +117,7 @@ describe('Agent', () => {
     await ctx.fiber.dispose()
   })
 
-  it('send() throws after disposal', async () => {
+  it('followup() throws after disposal', async () => {
     const adapter = new MockAdapter(['hang'])
     const ctx = await harness(adapter)
     let agent!: Agent
@@ -129,7 +129,7 @@ describe('Agent', () => {
     await fiber.dispose()
     await driverDone(agent)
 
-    expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
+    expect(() => { agent.followup([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
   })
 
   it('disposal discards still-pending inbox items so every id gets a terminal event', async () => {
@@ -490,7 +490,7 @@ describe('Agent', () => {
     const { agent } = prepared
     prepared.markPublished()
     const dispose = prepared.startDriver()
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await new Promise(r => setTimeout(r, 30))
     expect(agent.status).toBe('running')
 

+ 2 - 2
packages/core/agent-loop/tests/cancel.spec.ts

@@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter) {
 }
 
 function send(agent: Agent, text: string) {
-  agent.send([{ type: 'text', text }])
+  agent.followup([{ type: 'text', text }])
 }
 
 /** Resolve on the agent's next idle transition (event-based, not status poll). */
@@ -63,7 +63,7 @@ describe('Agent.cancel()', () => {
     ctx.on('agent/cancel-requested', (subject, cause) => {
       if (subject !== agent) return
       seen.push(`first:${cause.kind}`)
-      subject.send([{ type: 'text', text: 'queued by cancel observer' }])
+      subject.followup([{ type: 'text', text: 'queued by cancel observer' }])
       throw new Error('observer failed')
     })
     ctx.on('agent/cancel-requested', (subject, cause) => {

+ 5 - 5
packages/core/agent-loop/tests/config-session-id.spec.ts

@@ -98,7 +98,7 @@ describe('config-driven session id', () => {
       first = ctx.agents.get(SessionId('config-exact-reload'))
     }
     expect(first).toBeDefined()
-    first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
+    first!.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
     await waitForIdle(ctx, first!)
     await firstLoop.dispose()
 
@@ -110,7 +110,7 @@ describe('config-driven session id', () => {
     }
     expect(second).toBeDefined()
     expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
-    second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
+    second!.followup([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
     await waitForIdle(ctx, second!)
     await ctx.sessions.flush(second!.session)
     const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
@@ -335,7 +335,7 @@ describe('config-driven session id', () => {
     expect(a1.id).toBe(a1.session.id)
     expect(a1.session.id).toMatch(idPattern)
     expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
-    a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
+    a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     await ctx1.fiber.dispose()
 
@@ -354,7 +354,7 @@ describe('config-driven session id', () => {
     expect(a2.id).toBe(a2.session.id)
     expect(a2.session.id).toMatch(idPattern)
     expect(a2.session.id).not.toBe(a1.session.id)
-    a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
+    a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
     await waitForIdle(ctx2, a2)
     await ctx2.fiber.dispose()
   })
@@ -375,7 +375,7 @@ describe('config-driven session id', () => {
     await ctx1.plugin(SessionPersistenceJsonl, { root })
     ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
     const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent
-    a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
+    a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     await ctx1.fiber.dispose()
 

+ 4 - 4
packages/core/agent-loop/tests/contract-regressions.spec.ts

@@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
 }
 
 function send(agent: Agent, text: string) {
-  agent.send([{ type: 'text', text }])
+  agent.followup([{ type: 'text', text }])
 }
 
 describe('session log records what agent/step-result actually produced', () => {
@@ -818,7 +818,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
       source: { kind: 'plugin', plugin: 'context-source' },
       meta: { version: 1 },
     }]
-    agent.send(content, { source, contexts })
+    agent.followup(content, { source, contexts })
     content[0]!.text = 'caller-mutated-send'
     source.plugin = 'caller-mutated-source'
     contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-context' }
@@ -874,7 +874,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
       notifiedContexts = info.contexts
     })
 
-    agent.send([{ type: 'text', text: 'start' }])
+    agent.followup([{ type: 'text', text: 'start' }])
     await entered.promise
     expect(agent.status).toBe('running')
     const content = [{ type: 'text' as const, text: 'accepted-steer' }]
@@ -991,7 +991,7 @@ describe('turn numbering continues across seeded sessions', () => {
 
     const turns: number[] = []
     ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
-    forked.send([{ type: 'text', text: 'continue' }])
+    forked.followup([{ type: 'text', text: 'continue' }])
     await new Promise<void>((resolve) => {
       ctx2.on('agent/status', (subject, status) => {
         if (subject === forked && status === 'idle') resolve()

+ 3 - 3
packages/core/agent-loop/tests/coverage-edges.spec.ts

@@ -38,7 +38,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
 }
 
 function send(agent: Agent, text: string) {
-  agent.send([{ type: 'text', text }])
+  agent.followup([{ type: 'text', text }])
 }
 
 describe('inbox acceptance', () => {
@@ -50,10 +50,10 @@ describe('inbox acceptance', () => {
     ctx.on('agent/inbox/enqueue', () => { queued += 1 })
 
     expect(() => {
-      agent.send([{ type: 'text', text: 'first', bad: 1n } as never])
+      agent.followup([{ type: 'text', text: 'first', bad: 1n } as never])
     }).toThrow(/losslessly JSON-serializable/)
     expect(() => {
-      agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
+      agent.followup([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
     }).toThrow(/losslessly JSON-serializable/)
     expect(queued).toBe(0)
     expect(agent.session.events).toHaveLength(0)

+ 4 - 4
packages/core/agent-loop/tests/inbox-invariant.spec.ts

@@ -55,7 +55,7 @@ describe('inbox FIFO-conservation invariant', () => {
       return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
     })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(adapter.requests).toHaveLength(2)
@@ -80,7 +80,7 @@ describe('inbox FIFO-conservation invariant', () => {
       return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
     })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
@@ -110,7 +110,7 @@ describe('inbox FIFO-conservation invariant', () => {
       return { action: 'stop' as const }
     })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(discards).toEqual([1]) // the dropped steering item was reported
@@ -142,7 +142,7 @@ describe('inbox FIFO-conservation invariant', () => {
       agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } })
     })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     // The prompt plus the late steer both enqueued; both are matched (the prompt

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

@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
 }
 
 function send(agent: Agent, text: string) {
-  agent.send([{ type: 'text', text }])
+  agent.followup([{ type: 'text', text }])
 }
 
 function events(agent: Agent): SessionEvent[] {
@@ -128,7 +128,7 @@ describe('agent/prompt-submit', () => {
         ? downstream
         : { ...downstream, content: [{ type: 'text', text: 'rewritten request' }] }
     })
-    agent.send([{ type: 'text', text: 'original request' }], {
+    agent.followup([{ type: 'text', text: 'original request' }], {
       contexts: [{
         content: [{ type: 'text', text: 'untrusted prefix' }],
         source: { kind: 'plugin', plugin: 'prefix' },
@@ -203,7 +203,7 @@ describe('agent/prompt-submit', () => {
     const reasons: TurnEndReason[] = []
     ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
 
-    agent.send([{ type: 'text', text: 'do something' }], {
+    agent.followup([{ type: 'text', text: 'do something' }], {
       contexts: [{ content: [{ type: 'text', text: 'must be dropped' }], source: { kind: 'plugin', plugin: 'test' } }],
     })
     await waitForIdle(ctx, agent)

+ 4 - 4
packages/core/agent-loop/tests/loop.spec.ts

@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
 }
 
 function send(agent: Agent, text: string) {
-  agent.send([{ type: 'text', text }])
+  agent.followup([{ type: 'text', text }])
 }
 
 describe('agent loop', () => {
@@ -539,7 +539,7 @@ describe('agent loop', () => {
     }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } })
+    agent.followup([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } })
     await waitForIdle(ctx, agent)
 
     const user = agent.session.events.find(e => e.type === 'user/message')
@@ -1083,9 +1083,9 @@ describe('agent loop', () => {
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
     const idle = waitForIdle(ctx, agent)
-    agent.send([{ type: 'text', text: 'user message' }])
+    agent.followup([{ type: 'text', text: 'user message' }])
     await Promise.resolve()
-    agent.send(
+    agent.followup(
       [{ type: 'text', text: 'plugin message' }],
       { source: { kind: 'plugin', plugin: 'test' } },
     )

+ 3 - 3
packages/core/agent-loop/tests/properties.spec.ts

@@ -115,7 +115,7 @@ describe('agent loop scheduling properties', () => {
           const { seen: trace } = recordStatus(ctx, agent)
           const idle = nextIdle(ctx, agent)
           // Send all in one synchronous tick: they queue before the loop wakes.
-          for (const text of texts) agent.send([{ type: 'text', text }])
+          for (const text of texts) agent.followup([{ type: 'text', text }])
           await idle
 
           // No message lost: every send appears as a user/message, in order.
@@ -142,7 +142,7 @@ describe('agent loop scheduling properties', () => {
           const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
           for (const text of texts) {
             const idle = nextIdle(ctx, agent)
-            agent.send([{ type: 'text', text }])
+            agent.followup([{ type: 'text', text }])
             await idle
           }
           // Each send was drained at a separate turn start: N turns, 1..N.
@@ -171,7 +171,7 @@ describe('agent loop scheduling properties', () => {
           for (const step of steps) {
             const idle = nextIdle(ctx, agent)
             lastIdle = idle
-            agent.send([{ type: 'text', text: step.text }])
+            agent.followup([{ type: 'text', text: step.text }])
             if (step.settle) await idle
           }
           await lastIdle

+ 2 - 2
packages/core/agent-loop/tests/request-cache.e2e.ts

@@ -73,10 +73,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (
     const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
     // Turn 1: forces a tool call → at least two steps (two model requests).
-    agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
+    agent.followup([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
     await waitForIdle(ctx, agent)
     // Turn 2: a follow-up over the same (longer) prefix.
-    agent.send([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
+    agent.followup([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
     await waitForIdle(ctx, agent)
 
     const usages = [...agent.session.events]

+ 1 - 1
packages/core/agent-loop/tests/request-reconstruction.spec.ts

@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
 }
 
 function send(agent: Agent, text: string) {
-  agent.send([{ type: 'text', text }])
+  agent.followup([{ type: 'text', text }])
 }
 
 /** Assert `previous` is a strict value-prefix of `current`. */

+ 1 - 1
packages/core/agent-loop/tests/request-recovery.spec.ts

@@ -114,7 +114,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
 }
 
 function send(agent: Agent): void {
-  agent.send([{ type: 'text', text: 'go' }])
+  agent.followup([{ type: 'text', text: 'go' }])
 }
 
 function contextError(message = 'context too large'): LlmError {

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

@@ -125,7 +125,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     const adapter1 = new MockAdapter([textResponse('a')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
     const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
-    a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
+    a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     await ctx1.fiber.dispose()
 
@@ -153,7 +153,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
     const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
     expect(sources1).toEqual(['startup'])
-    a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
+    a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     await ctx1.fiber.dispose()
 
@@ -459,7 +459,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     const adapter1 = new MockAdapter([textResponse('answer')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
     const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
-    a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
+    a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
     // Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose).
@@ -482,7 +482,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     const adapter1 = new MockAdapter([textResponse('answer')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
     const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
-    a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
+    a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
     await ctx1.sessions.flush(a1.session)
@@ -510,7 +510,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     const adapter1 = new MockAdapter([textResponse('first answer')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
     const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
-    a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
+    a1.followup([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
     await waitForIdle(ctx1, a1)
     const events1 = [...a1.session.events]
     const seqs1 = events1.map(e => e.seq)
@@ -537,7 +537,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
 
     // …and a new turn continues numbering (turn 2) with contiguous seqs.
-    a2.send([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
+    a2.followup([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
     await waitForIdle(ctx2, a2)
     const allSeqs = a2.session.events.map(e => e.seq)
     expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates

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

@@ -203,11 +203,11 @@ describe('agent scope lifecycle', () => {
       if (event.type === 'user/message') heard.push('a-sees:user-message')
     })
 
-    b.send(text('for b'))
+    b.followup(text('for b'))
     await waitForIdle(ctx, b)
     expect(heard).toEqual([]) // nothing of b's leaked into a's scope
 
-    a.send(text('for a'))
+    a.followup(text('for a'))
     await waitForIdle(ctx, a)
     expect(heard).toContain('a-sees:a:running')
     expect(heard).toContain('a-sees:user-message')
@@ -934,7 +934,7 @@ describe('agent scope lifecycle', () => {
         if (event.type === 'turn/start') { off(); resolve() }
       })
     })
-    agent.send(text('work'))
+    agent.followup(text('work'))
     await turnOpen
     await owner.dispose()
     expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true'])

+ 16 - 16
packages/core/agent-loop/tests/tool-calls.spec.ts

@@ -105,7 +105,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
     ctx.tools.register(gated.tool)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 3)
     expect(gated.started).toEqual(['1', '2', '3'])
     gated.release('1'); gated.release('2'); gated.release('3')
@@ -133,7 +133,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
       async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
     }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
@@ -169,7 +169,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
     }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => replacement.started.length === 1)
     await new Promise(r => setTimeout(r, 5))
     expect(replacement.started).toEqual(['1'])
@@ -200,7 +200,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
     })
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => initial.started.length === 2)
     initial.release('1')
     await until(() => events(agent).some(event =>
@@ -226,7 +226,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
     ctx.tools.register(gated.tool)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 2)
     gated.release('2')
     await new Promise(r => setTimeout(r, 5))
@@ -248,7 +248,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
     const gated = gatedParallelTool('p')
     ctx.tools.register(gated.tool)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 2)
     gated.release('2'); gated.release('1')
     await waitForIdle(ctx, agent)
@@ -294,7 +294,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
     ctx.tools.register(gated.tool)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 2)
     await new Promise(r => setTimeout(r, 5))
     expect(gated.started).toEqual(['1', '2'])
@@ -323,7 +323,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
     const gated = gatedParallelTool('p')
     ctx.tools.register(gated.tool)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 1)
     await new Promise(r => setTimeout(r, 5))
     expect(gated.started).toEqual(['1'])
@@ -349,7 +349,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
     const gated = gatedParallelTool('p')
     ctx.tools.register(gated.tool)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 1)
     await new Promise(r => setTimeout(r, 5))
     expect(gated.started).toEqual(['1'])
@@ -376,7 +376,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
     ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() })
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 3)
     gated.release('3'); gated.release('2'); gated.release('1')
     await waitForIdle(ctx, agent)
@@ -397,7 +397,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
       ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 2)
     gated.release('2'); gated.release('1')
     await waitForIdle(ctx, agent)
@@ -435,7 +435,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
     })
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 1)
     gated.release('1')
     await waitForIdle(ctx, agent)
@@ -465,7 +465,7 @@ describe('tool-call scheduler: abort handling', () => {
       }
     })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(gated.started).toEqual([])
@@ -497,7 +497,7 @@ describe('tool-call scheduler: abort handling', () => {
       return next()
     })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(gated.started).toEqual([])
@@ -527,7 +527,7 @@ describe('tool-call scheduler: abort handling', () => {
     }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 2)
     agent.cancel({ kind: 'user' })
     gated.release('1')
@@ -578,7 +578,7 @@ describe('tool-call scheduler: abort handling', () => {
     }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await until(() => gated.started.length === 2)
     agent.cancel({ kind: 'user' })
     gated.release('1')

+ 2 - 2
packages/core/agent-loop/tests/tool-order.spec.ts

@@ -58,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf
   const ctx = await harness(adapter, toolOrder)
   for (const name of registrationOrder) registerNamed(ctx, name)
   const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-  agent.send([{ type: 'text', text: 'go' }])
+  agent.followup([{ type: 'text', text: 'go' }])
   await waitForIdle(ctx, agent)
   return { ctx, agent, adapter }
 }
@@ -100,7 +100,7 @@ describe('loop-level canonical tool order', () => {
     const errors: Error[] = []
     ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
     expect(adapter.requests).toHaveLength(0)
     expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])

+ 2 - 2
packages/core/agent-loop/tests/turn-stop.spec.ts

@@ -34,7 +34,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
 }
 
 function send(agent: Agent, text = 'go'): Promise<void> {
-  agent.send([{ type: 'text', text }])
+  agent.followup([{ type: 'text', text }])
   return agent.whenIdle()
 }
 
@@ -116,7 +116,7 @@ describe('agent/turn-stop', () => {
     ctx.on('session/flush', (session) => {
       if (session !== agent.session || queued) return
       queued = true
-      agent.send([{ type: 'text', text: 'ordinary queued follow-up' }])
+      agent.followup([{ type: 'text', text: 'ordinary queued follow-up' }])
     })
 
     await send(agent)

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

@@ -54,13 +54,13 @@ Turn and step boundaries and the model token stream are durable `session/event`
 
 ### Agent interface (`types.ts`)
 
-`Agent` is a structural interface. `send()`, `queue()`, `steer()`, and `inject()` name common caller intents; `acceptInput(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `send()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content.
+`Agent` is a structural interface. `followup()`, `queue()`, `steer()`, and `inject()` name common caller intents; `send(ResolvedAgentInput)` exposes the same acceptance path when a caller already has exact routing facts ([decision](../../../.agents/notes/implemented/architecture/2026-07-24-intent-named-agent-delivery.md)). Every `ResolvedAgentInput` field is mandatory, and its discriminated union excludes attached contexts from non-waking next-step injection. FIFO acceptance returns an opaque `AgentMessageId` carried by that item's `agent/inbox/enqueue`/`dequeue`/`discard` events. The driver snapshots content, resolved source, attached contexts, and model-hidden metadata as one detached, deeply frozen lossless-JSON record before notification and enqueue; invalid data throws synchronously. The helpers apply defaults: omitting `options.source` on `followup()`, `queue()`, or `steer()` attests direct human input as `{ kind: 'user' }`, so every non-human producer labels its content.
 
-- `agent.send(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
+- `agent.followup(content, options?)` — queue one independent FIFO message as its own turn and wake the driver. After admission, separate contexts become injected `user/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
 - `agent.queue(content, options?)` — queue the same ordinary message without waking an idle driver. A lone queued item leaves `whenIdle()` resolved and rides along before the next waking message.
 - `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; while idle, create a waking ordinary turn. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
 - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `user/message` (default plugin source) with `content` rendered verbatim as a user-role message. `InjectOptions` deliberately has no attached contexts. `options.meta` persists opaque JSON state without rendering it. While a turn is open the injection joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). Injection bypasses the FIFOs and emits no `agent/inbox/*` event.
-- `agent.acceptInput(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata.
+- `agent.send(input)` — accept a fully specified route without helper defaults. `next-turn` targets the ordinary FIFO; `next-step` with wakeup targets steering and falls back to a waking ordinary turn while idle; `next-step` without wakeup is injection and requires `contexts: []`. Callers provide `meta: undefined` explicitly when they have no metadata.
 - `agent.cancel(cause?, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
 - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
 - `agent.session`, `agent.status`, `agent.options`, `agent.id`
@@ -79,7 +79,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
 
 #### What the model sees
 
-The four helpers and `acceptInput` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself.
+The four intent helpers and fully resolved `send` path feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself.
 
 #### Token effect
 

+ 7 - 7
packages/core/agent/src/types.ts

@@ -27,7 +27,7 @@ export interface AgentOptions {
 }
 
 /**
- * Options for {@link Agent.send}, {@link Agent.queue}, and {@link Agent.steer}.
+ * Options for {@link Agent.followup}, {@link Agent.queue}, and {@link Agent.steer}.
  * An omitted source attests direct human input as `{ kind: 'user' }` and may
  * authorize policy consumers, so non-human producers must label their content.
  */
@@ -68,7 +68,7 @@ export function AgentMessageId(id: string): AgentMessageId {
 
 /**
  * One accepted FIFO message, carried by the `agent/inbox/*` live events. `id`
- * is the value returned by the accepting helper or {@link Agent.acceptInput},
+ * is the value returned by the accepting helper or {@link Agent.send},
  * stable across this message's enqueue, dequeue, and discard events. Source
  * defaults, when applicable, are already applied, so these are the exact values
  * the item was accepted with.
@@ -78,7 +78,7 @@ export function AgentMessageId(id: string): AgentMessageId {
  * `steering/message`, not live-event routing data.
  */
 export interface AgentMessage {
-  /** The id returned by the accepting helper or {@link Agent.acceptInput}. */
+  /** The id returned by the accepting helper or {@link Agent.send}. */
   id: AgentMessageId
   content: ContentBlock[]
   source: MessageSource
@@ -122,7 +122,7 @@ export interface HookContext {
 }
 
 /**
- * Fully specified input for {@link Agent.acceptInput}. Unlike the intent-named
+ * Fully specified input for {@link Agent.send}. Unlike the intent-named
  * helpers, this form applies no defaults: callers provide content, source,
  * contexts, metadata (including explicit `undefined`), target, and wakeup.
  * The union excludes attached contexts from non-waking next-step injection.
@@ -201,7 +201,7 @@ export interface Agent {
    * @param options - source, attached contexts, and durable model-hidden meta.
    * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
    */
-  send(content: ContentBlock[], options?: SendOptions): AgentMessageId
+  followup(content: ContentBlock[], options?: SendOptions): AgentMessageId
 
   /**
    * Queue an ordinary message without waking an idle driver. The item retains
@@ -251,7 +251,7 @@ export interface Agent {
    * @param input - the resolved content, attribution, context, metadata, and routing facts.
    * @returns the accepted input's {@link AgentMessageId}, carried by FIFO lifecycle events when applicable.
    */
-  acceptInput(input: ResolvedAgentInput): AgentMessageId
+  send(input: ResolvedAgentInput): AgentMessageId
 
   /**
    * Clear queued and steering work — unless `keepInbox` — and abort the active
@@ -306,7 +306,7 @@ declare module 'cordis' {
      * FIFO). Source defaults are already applied, so `message` holds the exact
      * accepted values. This is the enqueue-time live signal; the durable record
      * is the eventual `user/message`/`steering/message`. Injection through
-     * `agent.inject()` or equivalent `acceptInput()` routing bypasses the FIFOs
+     * `agent.inject()` or equivalent `send()` routing bypasses the FIFOs
      * and does not emit this.
      * @param agent - the agent whose inbox received the item.
      * @param message - the accepted message (its returned `id`, content, source, contexts, steering, and wakeup facts).

+ 3 - 3
packages/core/agent/tests/agent.spec.ts

@@ -28,11 +28,11 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
     session: new Session(id),
     status: 'idle',
     ctx: new Context(),
-    send: () => AgentMessageId('stub'),
+    followup: () => AgentMessageId('stub'),
     queue: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),
     inject: () => AgentMessageId('stub'),
-    acceptInput: () => AgentMessageId('stub'),
+    send: () => AgentMessageId('stub'),
     cancel() {},
     whenIdle() { return Promise.resolve() },
     ...overrides,
@@ -50,7 +50,7 @@ describe('AgentRegistry', () => {
     expectTypeOf<'target' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>()
     expectTypeOf<'wakeup' extends keyof SendOptions ? true : false>().toEqualTypeOf<false>()
     expectTypeOf<'contexts' extends keyof InjectOptions ? true : false>().toEqualTypeOf<false>()
-    expectTypeOf<Parameters<Agent['acceptInput']>[0]>().toEqualTypeOf<ResolvedAgentInput>()
+    expectTypeOf<Parameters<Agent['send']>[0]>().toEqualTypeOf<ResolvedAgentInput>()
     expectTypeOf<OptionalInputKey>().toEqualTypeOf<never>()
     expectTypeOf<Extract<ResolvedAgentInput, { target: 'next-step'; wakeup: false }>['contexts']>()
       .toEqualTypeOf<[]>()

+ 4 - 4
packages/examples/agent-spine-demo/tests/agent-core.spec.ts

@@ -235,7 +235,7 @@ describe('dsh-agent-spine-demo bundle', () => {
       agentOptions: { provider: 'mock', model: 'mock' },
     })
 
-    handle.agent.send([{ type: 'text', text: 'recover' }])
+    handle.agent.followup([{ type: 'text', text: 'recover' }])
     await waitForIdle(ctx, handle.agent)
 
     expect(adapter.requests).toBe(2)
@@ -335,7 +335,7 @@ describe('dsh-agent-spine-demo bundle', () => {
       })
       const agent = handle.agent
 
-      agent.send([{ type: 'text', text: 'hi' }])
+      agent.followup([{ type: 'text', text: 'hi' }])
       await waitForIdle(ctx, agent)
 
       const sentText = adapter.requests[0]?.messages.map(messageText).join('\n')
@@ -364,7 +364,7 @@ describe('dsh-agent-spine-demo bundle', () => {
         agentOptions: { provider: 'mock', model: 'mock' },
       })
 
-      handle.agent.send([{ type: 'text', text: 'hi' }])
+      handle.agent.followup([{ type: 'text', text: 'hi' }])
       await waitForIdle(ctx, handle.agent)
 
       expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
@@ -454,7 +454,7 @@ describe('dsh-agent-spine-demo bundle', () => {
         agentOptions: { provider: 'mock', model: 'mock' },
       })
 
-      handle.agent.send([{ type: 'text', text: 'hi' }])
+      handle.agent.followup([{ type: 'text', text: 'hi' }])
       await waitForIdle(ctx, handle.agent)
 
       expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills')

+ 1 - 1
packages/examples/cli-demo/src/cli.ts

@@ -290,7 +290,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
   try {
     /* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */
     if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
-      agent.send([{ type: 'text', text: options.task }])
+      agent.followup([{ type: 'text', text: options.task }])
     }
     await turnEnded
   } finally {

+ 1 - 1
packages/examples/cli-demo/tests/cli.spec.ts

@@ -471,7 +471,7 @@ describe('runOneShot and executeCli', () => {
     startup.ctx.on('session/event', (session, event) => {
       if (session === startup.agent.session && event.type === 'assistant/chunk') started()
     })
-    startup.agent.send([{ type: 'text', text: 'first' }])
+    startup.agent.followup([{ type: 'text', text: 'first' }])
     await running
     const startupAbort = new AbortController()
     const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal })

+ 2 - 2
packages/fs/tool-fs/tests/fs-tools.e2e.ts

@@ -36,7 +36,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
     // (config.cwd = workdir) is the workspace.
     const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
-    agent.send([{ type: 'text', text:
+    agent.followup([{ type: 'text', text:
       'Create a file named note.txt containing exactly the line: status: draft. '
       + 'Then read it back, then edit it to replace the literal word draft with final. '
       + 'Tell me when done.' }])
@@ -68,7 +68,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
         meta: { cwd: sessionDir },
         agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
       })
-      handle.agent.send([{ type: 'text', text:
+      handle.agent.followup([{ type: 'text', text:
         'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }])
       await waitForIdle(ctx, handle.agent)
 

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

@@ -48,11 +48,11 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
     session,
     ctx: new Context(),
     get status() { return status },
-    send: () => AgentMessageId('stub'),
+    followup: () => AgentMessageId('stub'),
     queue: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),
     inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') },
-    acceptInput: () => AgentMessageId('stub'),
+    send: () => AgentMessageId('stub'),
     cancel() { status = 'idle' },
     whenIdle() { return Promise.resolve() },
   }

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

@@ -219,7 +219,7 @@ export function apply(ctx: Context): void {
     }
     state.attempt = reservation
     try {
-      agent.send(content, {
+      agent.followup(content, {
         source: { kind: 'goal', goalId: goal.id, revision: goal.revision, round },
       })
     } catch (error: unknown) {

+ 1 - 1
packages/goal/goal-session/src/prompt.ts

@@ -7,7 +7,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal'
  * Render the complete goal-round instruction retained in session history.
  * @param goal - exact active goal revision being admitted.
  * @param round - next positive round number.
- * @returns a fresh one-block prompt for `Agent.send()`.
+ * @returns a fresh one-block prompt for `Agent.followup()`.
  */
 export function renderGoalRoundPrompt(goal: GoalView, round: number): ContentBlock[] {
   return [{

+ 15 - 15
packages/goal/goal-session/tests/goal-session.spec.ts

@@ -276,7 +276,7 @@ describe('same-session goal driving', () => {
       ? Promise.resolve({ kind: 'block', reason: 'stop this round' })
       : next())
     test.ctx.on('goal/changed', (agent, change) => {
-      if (change.operation === 'block') agent.send([{ type: 'text', text: 'inspect the blocker' }])
+      if (change.operation === 'block') agent.followup([{ type: 'text', text: 'inspect the blocker' }])
     })
     test.ctx.goals.create(test.agent, { objective: 'stop and inspect' })
 
@@ -323,7 +323,7 @@ describe('same-session goal driving', () => {
   it('lets already-queued human work finish before reserving the next round', async () => {
     const test = await harness([textResponse('human answer'), textResponse('goal answer')])
     test.ctx.goals.create(test.agent, { objective: 'continue after the human', maxGoalRounds: 1 })
-    test.agent.send([{ type: 'text', text: 'human goes first' }])
+    test.agent.followup([{ type: 'text', text: 'human goes first' }])
 
     await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
 
@@ -364,7 +364,7 @@ describe('same-session goal driving', () => {
     test.ctx.on('agent/inbox/enqueue', (agent, info) => {
       if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
       inserted = true
-      agent.send([{ type: 'text', text: 'human joined the pending batch' }])
+      agent.followup([{ type: 'text', text: 'human joined the pending batch' }])
     })
     test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 })
 
@@ -479,16 +479,16 @@ describe('same-session goal driving', () => {
     expect(injectedTurn).toBeGreaterThan(goalTurn)
   })
 
-  it('blocks the goal when a custom agent rejects the otherwise valid send', async () => {
+  it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => {
     const test = await harness([])
-    // Reject only the goal-sourced round send, not the state-change injection
+    // Reject only the goal-sourced round follow-up, not the state-change injection
     // that precedes it.
-    const realSend = test.agent.send.bind(test.agent)
-    vi.spyOn(test.agent, 'send').mockImplementation((content, options) => {
+    const realFollowup = test.agent.followup.bind(test.agent)
+    vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => {
       if (options?.source?.kind === 'goal') {
         throw new Error('queue rejected')
       }
-      return realSend(content, options)
+      return realFollowup(content, options)
     })
     test.ctx.goals.create(test.agent, { objective: 'handle queue failure' })
 
@@ -502,15 +502,15 @@ describe('same-session goal driving', () => {
     expect(test.adapter.requests).toHaveLength(0)
   })
 
-  it('preserves a custom agent side effect when send disarms before throwing', async () => {
+  it('preserves a custom agent side effect when followup disarms before throwing', async () => {
     const test = await harness([])
-    const realSend = test.agent.send.bind(test.agent)
-    vi.spyOn(test.agent, 'send').mockImplementation((content, options) => {
+    const realFollowup = test.agent.followup.bind(test.agent)
+    vi.spyOn(test.agent, 'followup').mockImplementation((content, options) => {
       if (options?.source?.kind === 'goal') {
         test.ctx.goals.disarm(test.agent)
         throw new Error('queue rejected after disarm')
       }
-      return realSend(content, options)
+      return realFollowup(content, options)
     })
     test.ctx.goals.create(test.agent, { objective: 'preserve the newer activation state' })
 
@@ -609,7 +609,7 @@ describe('same-session goal driving', () => {
 
   it('blocks forged goal attribution without touching an absent reservation', async () => {
     const test = await harness([])
-    test.agent.send([{ type: 'text', text: 'forged automatic work' }], {
+    test.agent.followup([{ type: 'text', text: 'forged automatic work' }], {
       source: { kind: 'goal', goalId: GoalId('forged-goal'), revision: 1, round: 1 },
     })
     await test.agent.whenIdle()
@@ -621,7 +621,7 @@ describe('same-session goal driving', () => {
 
   it('does not invent goal state when ordinary queued work is cancelled', async () => {
     const test = await harness([])
-    test.agent.send([{ type: 'text', text: 'cancel ordinary work' }])
+    test.agent.followup([{ type: 'text', text: 'cancel ordinary work' }])
     test.agent.cancel({ kind: 'user' })
     await test.agent.whenIdle()
 
@@ -631,7 +631,7 @@ describe('same-session goal driving', () => {
 
   it('disarms without durably pausing when cancellation belongs to unrelated human work', async () => {
     const test = await harness(['hang'])
-    test.agent.send([{ type: 'text', text: 'inspect something first' }])
+    test.agent.followup([{ type: 'text', text: 'inspect something first' }])
     await waitForRequests(test.adapter, 1)
     const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' })
 

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

@@ -64,7 +64,7 @@ function stubAgentForSession(session: Session): StubAgent {
     session,
     ctx: new Context(),
     get status() { return status },
-    send: () => AgentMessageId('stub'),
+    followup: () => AgentMessageId('stub'),
     queue: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),
     inject(content, options) {
@@ -72,7 +72,7 @@ function stubAgentForSession(session: Session): StubAgent {
       else appendInjection(session, content, options)
       return AgentMessageId('stub')
     },
-    acceptInput: () => AgentMessageId('stub'),
+    send: () => AgentMessageId('stub'),
     cancel() {},
     whenIdle() { return Promise.resolve() },
   }

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

@@ -18,7 +18,7 @@ An autonomous goal round that successfully reports `complete` or `blocked` contr
 
 Execution requires the exact live `exec.agent`, its inherited `AgentRegistry` initiator, running status, and an open turn. Create, edit, pause, and resume additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. Durable fork lineage does not demote a resumed root; live subagent ownership does.
 
-`{ kind: 'user' }` is a host attestation. `Agent.send()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
+`{ kind: 'user' }` is a host attestation. `Agent.followup()` and `steer()` assign it when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
 
 Complete and blocked also accept the exact current goal round: a goal-sourced `user/message` whose id, revision, and round equal the folded current goal. A goal-round blocked call is mechanically rejected until `blockedAfterConsecutiveRounds`; the model judges whether the same condition actually persisted and must describe it in `blocked_reason`. Direct human authority may stop a goal immediately.
 

+ 1 - 1
packages/goal/tool-goal/src/authority.ts

@@ -64,7 +64,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE
 
 /**
  * Whether host-attested human input appears in the current root-agent turn.
- * An omitted `Agent.send()` / `steer()` source resolves to `user`, so non-human
+ * An omitted `Agent.followup()` / `steer()` source resolves to `user`, so non-human
  * producers must supply their own source rather than inheriting this authority.
  */
 function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean {

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

@@ -31,7 +31,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
     session,
     get status() { return status },
     ctx: new Context(),
-    send: () => AgentMessageId('stub'),
+    followup: () => AgentMessageId('stub'),
     queue: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),
     inject(content: ContentBlock[], options?: InjectOptions) {
@@ -43,7 +43,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
       }, { surfaceOp: 'append' })
       return AgentMessageId('stub')
     },
-    acceptInput: () => AgentMessageId('stub'),
+    send: () => AgentMessageId('stub'),
     cancel() {},
     whenIdle() { return Promise.resolve() },
   }

+ 18 - 18
packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts

@@ -56,7 +56,7 @@ describe('threshold escalation', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     const found = reminders(agent)
@@ -77,7 +77,7 @@ describe('threshold escalation', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     const found = reminders(agent)
@@ -99,7 +99,7 @@ describe('chain semantics', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     const found = reminders(agent)
@@ -123,7 +123,7 @@ describe('chain semantics', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(reminders(agent)).toHaveLength(1)
@@ -141,7 +141,7 @@ describe('chain semantics', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     const found = reminders(agent)
@@ -162,7 +162,7 @@ describe('chain semantics', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     const found = reminders(agent)
@@ -178,7 +178,7 @@ describe('chain semantics', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(reminders(agent)).toHaveLength(1) // probe was NOT excluded
@@ -194,7 +194,7 @@ describe('chain semantics', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(reminders(agent)).toHaveLength(1) // all three canonicalize identically
@@ -215,8 +215,8 @@ describe('chain semantics', () => {
     ]))
     const agentA = ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' })
     const agentB = ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' })
-    agentA.send([{ type: 'text', text: 'go' }])
-    agentB.send([{ type: 'text', text: 'go' }])
+    agentA.followup([{ type: 'text', text: 'go' }])
+    agentB.followup([{ type: 'text', text: 'go' }])
     await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)])
 
     expect(reminders(agentA)).toHaveLength(0) // 2 repeats < 3, despite B's 3 in the same registry
@@ -234,9 +234,9 @@ describe('chain semantics', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
-    agent.send([{ type: 'text', text: 'again' }])
+    agent.followup([{ type: 'text', text: 'again' }])
     await waitForIdle(ctx, agent)
 
     expect(reminders(agent)).toHaveLength(0)
@@ -256,13 +256,13 @@ describe('chain semantics', () => {
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
     }, { inject: ['agentLoop'] }))
-    first.send([{ type: 'text', text: 'go' }])
+    first.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, first)
     await fiber.dispose()
     await first.whenIdle()
 
     const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
-    second.send([{ type: 'text', text: 'go' }])
+    second.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, second)
 
     expect(reminders(second)).toHaveLength(0)
@@ -278,7 +278,7 @@ describe('chain semantics', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(reminders(agent)).toHaveLength(1)
@@ -294,7 +294,7 @@ describe('chain semantics', () => {
       textResponse('done'),
     ]))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(reminders(agent)).toHaveLength(0)
@@ -316,7 +316,7 @@ describe('fold onto the downstream decision', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     const found = reminders(agent)
@@ -347,7 +347,7 @@ describe('fold onto the downstream decision', () => {
     ])
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     const found = reminders(agent)

+ 10 - 10
packages/hooks/hooks-claude/tests/bridge.spec.ts

@@ -97,7 +97,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
     const adapter = new MockAdapter([textResponse('should not run')])
     const ctx = await harness(dir, adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'do something' }])
+    agent.followup([{ type: 'text', text: 'do something' }])
     await waitForIdle(ctx, agent)
 
     // The prompt was blocked: model never called, turn ended rejected.
@@ -120,7 +120,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(dir, adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     // The injected context reached the model and is recorded with the plugin source.
@@ -145,7 +145,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
     let ran = false
     ctx.tools.register(defineContentToolFixture({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'use danger' }])
+    agent.followup([{ type: 'text', text: 'use danger' }])
     await waitForIdle(ctx, agent)
 
     expect(ran).toBe(false)
@@ -168,7 +168,7 @@ describe('hooks-claude bridge — PreToolUse', () => {
     let ran = false
     ctx.tools.register(defineContentToolFixture({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'use safe' }])
+    agent.followup([{ type: 'text', text: 'use safe' }])
     await waitForIdle(ctx, agent)
 
     expect(ran).toBe(true)
@@ -190,7 +190,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
     const ctx = await harness(dir, adapter)
     ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     const result = events(agent).find(e => e.type === 'tool/result')
@@ -211,7 +211,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
     const ctx = await harness(dir, adapter)
     ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     const log = events(agent)
@@ -235,7 +235,7 @@ describe('hooks-claude bridge — PostToolUse', () => {
     let ran = false
     ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     // `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError.
@@ -264,7 +264,7 @@ describe('hooks-claude bridge — SessionStart', () => {
     // fixed sleep that flakes under load.
     await waitFor(() => events(agent).some(e => e.type === 'user/message'
       && e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs'))))
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs')
@@ -357,7 +357,7 @@ describe('hooks-claude bridge — load resilience', () => {
     await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
     // The turn ran normally — no hooks, no crash.
     expect(adapter.requests).toHaveLength(1)
@@ -379,7 +379,7 @@ describe('hooks-claude bridge — load resilience', () => {
     await fiber.dispose()
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
     expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
     expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran

+ 30 - 30
packages/hooks/hooks-claude/tests/coverage-cases.ts

@@ -74,7 +74,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
         const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
         ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
         const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
-        agent.send([{ type: 'text', text: 'go' }])
+        agent.followup([{ type: 'text', text: 'go' }])
         await waitForIdle(ctx, agent)
         return {
           payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string },
@@ -104,7 +104,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       ctx.logger.warn = warn as never
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       expect(existsSync(marker)).toBe(true) // substituted command ran
     })
@@ -120,7 +120,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       let sawArgs: unknown
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       // updatedInput is NOT honored — the tool ran with the ORIGINAL args.
       expect((sawArgs as { command?: string }).command).toBe('original')
@@ -136,7 +136,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const adapter = new MockAdapter([textResponse('ran')])
       const ctx = await harness(path, adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       // The prompt proceeded unchanged; no injected context.
       expect(adapter.requests).toHaveLength(1)
@@ -166,7 +166,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const res = events(agent).find(e => e.type === 'hook/result')
       expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
@@ -191,7 +191,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const res = events(agent).find(e => e.type === 'hook/result')
       expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
@@ -207,7 +207,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
       const ctx = await harness(path, adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       expect(adapter.requests).toHaveLength(2)
       expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please')
@@ -223,7 +223,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
       const ctx = await harness(path, adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       // A second model request ran → the empty-reason block forced continuation.
       expect(adapter.requests).toHaveLength(2)
@@ -271,7 +271,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const result = events(agent).find(e => e.type === 'tool/result')
       expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
@@ -285,7 +285,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const result = events(agent).find(e => e.type === 'tool/result')
       expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
@@ -314,7 +314,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const adapter = new MockAdapter([textResponse('no')])
       const ctx = await harness(path, adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
       expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook')
@@ -329,7 +329,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       let ran = false
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       // ask (no reason) → degrades to deny with the registry's generic message.
       expect(ran).toBe(false)
@@ -344,7 +344,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const res = events(agent).find(e => e.type === 'hook/result')
       expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
@@ -369,7 +369,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
       ctx.llm.registerAdapter(['mock'], adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       expect(existsSync(marker)).toBe(true)
     })
@@ -384,7 +384,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       let ran = false
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       expect(ran).toBe(true)
       const res = events(agent).find(e => e.type === 'hook/result')
@@ -399,7 +399,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const result = events(agent).find(e => e.type === 'tool/result')
       expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
@@ -418,7 +418,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       let ran = false
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const res = events(agent).find(e => e.type === 'hook/result')
       expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
@@ -435,7 +435,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const result = events(agent).find(e => e.type === 'tool/result')
       expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
@@ -455,7 +455,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       let ran = false
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
     })
@@ -473,7 +473,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       // The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
       const { SessionId } = await import('@deepseek-ai/dsh-session')
       const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } })
-      handle.agent.send([{ type: 'text', text: 'go' }])
+      handle.agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, handle.agent)
       expect(events(handle.agent).some(e => e.type === 'user/message'
       && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
@@ -491,7 +491,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       // A later listener that blocks every prompt (registered AFTER the bridge).
       ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       // the downstream block won: the model was never called, no user/message was
       // recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
@@ -519,7 +519,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
         }],
       }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const req = JSON.stringify(adapter.requests[0]!.messages)
       expect(req).toContain('from-bridge')
@@ -547,7 +547,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const result = events(agent).find(e => e.type === 'tool/result')
       expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
@@ -570,7 +570,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
         }],
       }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
 
       const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
@@ -593,7 +593,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const result = events(agent).find(e => e.type === 'tool/result')
       expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
@@ -617,7 +617,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       bash.run = (() => Promise.reject(new Error('executor down')))
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       const res = events(agent).find(e => e.type === 'hook/result')
       expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
@@ -640,7 +640,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       await waitFor(() => threw)
       expect(threw).toBe(true)
       agent.inject = original
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject
     })
@@ -667,7 +667,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
 
       const { SessionId } = await import('@deepseek-ai/dsh-session')
       const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
-      handle.agent.send([{ type: 'text', text: 'go' }])
+      handle.agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, handle.agent)
 
       expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
@@ -717,7 +717,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, adapter)
       const warn = vi.fn(); ctx.logger.warn = warn as never
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
       // Not surfaced: the systemMessage text never reaches the model request.
@@ -736,7 +736,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
       const ctx = await harness(path, adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
       // Send immediately — do NOT wait for the session-start inject.
-      agent.send([{ type: 'text', text: 'go' }])
+      agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, agent)
       expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
     })

+ 6 - 6
packages/hooks/hooks-codex/tests/bridge.spec.ts

@@ -77,7 +77,7 @@ describe('hooks-codex bridge', () => {
     let ran = false
     ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } }))
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'run ls' }])
+    agent.followup([{ type: 'text', text: 'run ls' }])
     await waitForIdle(ctx, agent)
 
     expect(ran).toBe(false)
@@ -98,7 +98,7 @@ describe('hooks-codex bridge', () => {
     const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
     const ctx = await harness(dir, adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
 
     expect(adapter.requests).toHaveLength(2)
@@ -115,7 +115,7 @@ describe('hooks-codex bridge', () => {
     const adapter = new MockAdapter([textResponse('must not run')])
     const ctx = await harness(dir, adapter)
     const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'cancel the hook' }])
+    agent.followup([{ type: 'text', text: 'cancel the hook' }])
     await waitFor(() => existsSync(marker))
     const pid = Number(readFileSync(pidFile, 'utf8').trim())
 
@@ -139,7 +139,7 @@ describe('hooks-codex bridge', () => {
     const adapter = new MockAdapter([textResponse('fine')])
     const ctx = await harness(dir, adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
     expect(adapter.requests).toHaveLength(1)
   })
@@ -149,7 +149,7 @@ describe('hooks-codex bridge', () => {
     const adapter = new MockAdapter([textResponse('ok')])
     const ctx = await harness(dir, adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
     expect(adapter.requests).toHaveLength(1)
   })
@@ -169,7 +169,7 @@ describe('hooks-codex bridge', () => {
     await fiber.dispose()
     ctx.llm.registerAdapter(['mock'], adapter)
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await waitForIdle(ctx, agent)
     expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
     expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran

+ 34 - 34
packages/hooks/hooks-codex/tests/coverage-cases.ts

@@ -65,7 +65,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
         const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} })
         ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
         const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' })
-        agent.send([{ type: 'text', text: 'go' }])
+        agent.followup([{ type: 'text', text: 'go' }])
         await waitForIdle(ctx, agent)
         return {
           payload: JSON.parse(readFileSync(cap, 'utf8')) as { transcript_path: string | null },
@@ -84,7 +84,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const adapter = new MockAdapter([textResponse('no')])
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(adapter.requests).toHaveLength(0)
       const te = events(agent).findLast(e => e.type === 'turn/end')
       expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected')
@@ -96,7 +96,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const adapter = new MockAdapter([textResponse('ok')])
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x')
     })
 
@@ -109,7 +109,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(adapter.requests).toHaveLength(0)
       expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
       const te = events(agent).findLast(e => e.type === 'turn/end')
@@ -131,7 +131,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
         }],
       }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const req = JSON.stringify(adapter.requests[0]!.messages)
       expect(req).toContain('from-bridge')
       expect(req).toContain('from-downstream')
@@ -154,7 +154,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: [{ type: 'text' as const, text: 'rewritten-result' }] }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const result = events(agent).find(e => e.type === 'tool/result')
       expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
       expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
@@ -175,7 +175,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
         }],
       }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
 
       const contexts = events(agent).filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
       expect(contexts.map(event => event.type === 'user/message' && event.data.source)).toEqual([
@@ -193,7 +193,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const result = events(agent).find(e => e.type === 'tool/result')
       expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
       expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
@@ -208,7 +208,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
       await waitFor(() => events(agent).some(e => e.type === 'user/message'
       && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx'))))
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx')
     })
 
@@ -219,7 +219,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const r = events(agent).find(e => e.type === 'tool/result')
       expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
       expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
@@ -232,7 +232,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true)
     })
   })
@@ -246,7 +246,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       let ran = false
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(ran).toBe(true) // clean-exit hook allows; commandOf returned ''
     })
 
@@ -257,7 +257,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const res = events(agent).find(e => e.type === 'hook/result')
       expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
       expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
@@ -270,7 +270,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const res = events(agent).find(e => e.type === 'hook/result')
       expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
       expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
@@ -293,7 +293,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 })
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const res = events(agent).find(e => e.type === 'hook/result')
       expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
     })
@@ -316,7 +316,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
       ctx.llm.registerAdapter(['mock'], adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(existsSync(marker)).toBe(true)
       expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook'))
     })
@@ -329,7 +329,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       let ran = false
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(ran).toBe(true)
     })
 
@@ -344,7 +344,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
       await waitFor(() => existsSync(marker)) // the clean no-output hook has finished
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
     })
 
@@ -370,7 +370,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       let ran = false
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(ran).toBe(true)
     })
 
@@ -383,7 +383,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       let ran = false
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded
       expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false)
     })
@@ -399,7 +399,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       let ran = false
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const res = events(agent).find(e => e.type === 'hook/result')
       expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
       expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
@@ -412,7 +412,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const r = events(agent).find(e => e.type === 'tool/result')
       expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
     })
@@ -424,7 +424,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const r = events(agent).find(e => e.type === 'tool/result')
       expect(r?.type === 'tool/result' && r.data.isError).toBe(true)
       expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
@@ -441,7 +441,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } }
       expect(payload.tool_input.command).toBe('')
     })
@@ -477,7 +477,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       ctx.bash.run = (() => Promise.reject(new Error('executor down')))
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const res = events(agent).find(e => e.type === 'hook/result')
       expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
     })
@@ -493,7 +493,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation
       expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
     })
@@ -506,7 +506,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const adapter = new MockAdapter([textResponse('ok')])
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook')
     })
 
@@ -534,7 +534,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const adapter = new MockAdapter([textResponse('ok')])
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran
       expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale')
     })
@@ -547,7 +547,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
       await waitFor(() => events(agent).some(e => e.type === 'user/message'
       && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble'))))
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
     })
 
@@ -559,7 +559,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const adapter = new MockAdapter([textResponse('ok')])
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated')
     })
 
@@ -574,7 +574,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } }
       expect(payload.tool_name).toBe('shell')
       expect(payload.tool_input.command).toBe('ls')
@@ -590,7 +590,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       let ran = false
       ctx.tools.register(defineContentToolFixture({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(ran).toBe(false) // the matcher fired → the hook denied the tool
       expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true)
     })
@@ -602,7 +602,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       const ctx = await harness(join(d, 'hooks.json'), adapter)
       const warn = vi.fn(); ctx.logger.warn = warn as never
       const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
-      agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
+      agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
       expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
       expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
     })
@@ -625,7 +625,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
       ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
       const { SessionId } = await import('@deepseek-ai/dsh-session')
       const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
-      handle.agent.send([{ type: 'text', text: 'go' }])
+      handle.agent.followup([{ type: 'text', text: 'go' }])
       await waitForIdle(ctx, handle.agent)
       expect(existsSync(marker)).toBe(true)
       expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)

+ 1 - 1
packages/host/runtime/src/api-proxy.ts

@@ -437,7 +437,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
         try {
           if (mode === 'steer') agent.steer(content, { source })
-          else agent.send(content, { source })
+          else agent.followup(content, { source })
         } catch (error: unknown) {
           // A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
           return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })

+ 5 - 5
packages/host/runtime/tests/host-runtime.spec.ts

@@ -372,7 +372,7 @@ describe('sessions.prompt / cancel', () => {
     const { api, ctx } = running
     const { sessionId } = expectOk(await api.sessions.create(request({})))
     const agent = ctx.agents.get(sessionId) as Agent
-    agent.send([{ type: 'text', text: 'run forever' }])
+    agent.followup([{ type: 'text', text: 'run forever' }])
     expectOk(await api.sessions.cancel(request({ sessionId })))
 
     const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId }))
@@ -391,7 +391,7 @@ describe('sessions.history', () => {
     const { sessionId } = expectOk(await first.api.sessions.create(request({})))
     const agent = first.ctx.agents.get(sessionId) as Agent
     const idle = waitForIdle(first.ctx, agent)
-    agent.send([{ type: 'text', text: 'save me' }])
+    agent.followup([{ type: 'text', text: 'save me' }])
     await idle
     const titleEvent = await appendTitle(first.ctx, agent, 'Persisted title')
     await first.dispose()
@@ -440,7 +440,7 @@ describe('sessions.history', () => {
     const agent = ctx.agents.get(sessionId) as Agent
     for (const text of ['q1', 'q2', 'q3']) {
       const idle = waitForIdle(ctx, agent)
-      agent.send([{ type: 'text', text }])
+      agent.followup([{ type: 'text', text }])
       await idle
     }
 
@@ -511,7 +511,7 @@ describe('events streams', () => {
 
     const agent = ctx.agents.get(sessionId) as Agent
     const idle = waitForIdle(ctx, agent)
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await idle
     const live = await stream.next()
     expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
@@ -574,7 +574,7 @@ describe('events streams', () => {
 
     const agent = ctx.agents.get(sessionId) as Agent
     const idle = waitForIdle(ctx, agent)
-    agent.send([{ type: 'text', text: 'run' }])
+    agent.followup([{ type: 'text', text: 'run' }])
     await idle
     const runningFrame = await stream.next()
     expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })

+ 1 - 1
packages/llm/llm-retry/tests/loader-composition.spec.ts

@@ -114,7 +114,7 @@ describe('real Loader composition', () => {
     loaded.llm.registerAdapter(['mock'], adapter)
     const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' })
     const idle = waitForIdle(loaded, agent)
-    agent.send([{ type: 'text', text: 'recover' }])
+    agent.followup([{ type: 'text', text: 'recover' }])
     await idle
 
     expect(adapter.requests).toBe(2)

+ 13 - 13
packages/llm/llm-retry/tests/retry.spec.ts

@@ -129,7 +129,7 @@ describe('bounded transient retry policy', () => {
       })
     })
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     const event = await scheduled
 
     expect(event.data).toEqual({
@@ -178,7 +178,7 @@ describe('bounded transient retry policy', () => {
     const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' })
     const scheduled = waitForRetry(context, agent, 1)
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await scheduled
     const idle = waitForIdle(context, agent)
     await vi.advanceTimersByTimeAsync(500)
@@ -213,7 +213,7 @@ describe('bounded transient retry policy', () => {
     const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' })
     const first = waitForRetry(context, agent, 1)
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     expect((await first).data.delayMs).toBe(450)
 
     const second = waitForRetry(context, agent, 2)
@@ -246,7 +246,7 @@ describe('bounded transient retry policy', () => {
     const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' })
     const scheduled = waitForRetry(context, agent, 1)
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     expect((await scheduled).data.delayMs).toBe(0)
 
     const idle = waitForIdle(context, agent)
@@ -264,7 +264,7 @@ describe('bounded transient retry policy', () => {
     ;({ ctx: context } = await harness(accepted, { jitterRatio: 1 }))
     const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' })
     const scheduled = waitForRetry(context, acceptedAgent, 1)
-    acceptedAgent.send([{ type: 'text', text: 'go' }])
+    acceptedAgent.followup([{ type: 'text', text: 'go' }])
     expect((await scheduled).data.delayMs).toBe(2_000)
     const acceptedIdle = waitForIdle(context, acceptedAgent)
     await vi.advanceTimersByTimeAsync(2_000)
@@ -278,7 +278,7 @@ describe('bounded transient retry policy', () => {
     ;({ ctx: context } = await harness(rejected))
     const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' })
     const rejectedIdle = waitForIdle(context, rejectedAgent)
-    rejectedAgent.send([{ type: 'text', text: 'go' }])
+    rejectedAgent.followup([{ type: 'text', text: 'go' }])
     await rejectedIdle
     expect(rejected.requests).toHaveLength(1)
     expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
@@ -290,7 +290,7 @@ describe('bounded transient retry policy', () => {
     ;({ ctx: context } = await harness(adapter))
     const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' })
     const idle = waitForIdle(context, agent)
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await idle
     expect(adapter.requests).toHaveLength(1)
     expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
@@ -307,7 +307,7 @@ describe('bounded transient retry policy', () => {
     context = mounted.ctx
     const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' })
     const scheduled = waitForRetry(context, agent, 1)
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await scheduled
     const idle = waitForIdle(context, agent)
 
@@ -335,7 +335,7 @@ describe('bounded transient retry policy', () => {
       model: 'mock',
     })
     const idle = waitForIdle(context, agent)
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await entered.promise
 
     const disposing = mounted.retryFiber.dispose()
@@ -376,7 +376,7 @@ describe('bounded transient retry policy', () => {
       model: 'mock',
     })
     const idle = waitForIdle(context, agent)
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await captured.promise
 
     await mounted.retryFiber.dispose()
@@ -397,7 +397,7 @@ describe('bounded transient retry policy', () => {
     ;({ ctx: context } = await harness(adapter))
     const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' })
     const scheduled = waitForRetry(context, agent, 1)
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await scheduled
     const idle = waitForIdle(context, agent)
     agent.cancel({ kind: 'user' })
@@ -426,7 +426,7 @@ describe('bounded transient retry policy', () => {
     const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' })
     const idle = waitForIdle(context, agent)
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await idle
 
     expect(adapter.requests).toHaveLength(1)
@@ -450,7 +450,7 @@ describe('bounded transient retry policy', () => {
     })
     const idle = waitForIdle(context, agent)
 
-    agent.send([{ type: 'text', text: 'go' }])
+    agent.followup([{ type: 'text', text: 'go' }])
     await idle
 
     expect(adapter.requests).toHaveLength(1)

+ 4 - 4
packages/plan/plan-mode/tests/integration.spec.ts

@@ -75,7 +75,7 @@ describe('plan mode through the agent loop', () => {
     // the first prompt-submit, BEFORE the first assembly.
     ctx.planMode.set(agent, true)
 
-    agent.send([{ type: 'text', text: 'explore the repo' }])
+    agent.followup([{ type: 'text', text: 'explore the repo' }])
     await waitForIdle(ctx, agent)
 
     const log = agent.session.events
@@ -103,14 +103,14 @@ describe('plan mode through the agent loop', () => {
     const ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(SessionId('it-plan-flip'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'hello' }])
+    agent.followup([{ type: 'text', text: 'hello' }])
     await waitForIdle(ctx, agent)
     expect(foldPlanMode(agent.session.events)).toBe(false)
     const first = findEvent(agent.session.events, 'request/header')
     expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
 
     ctx.planMode.set(agent, true)
-    agent.send([{ type: 'text', text: 'now plan' }])
+    agent.followup([{ type: 'text', text: 'now plan' }])
     await waitForIdle(ctx, agent)
 
     const log = agent.session.events
@@ -146,7 +146,7 @@ describe('plan mode through the agent loop', () => {
     })
 
     const idle = waitForIdle(ctx, agent)
-    agent.send([{ type: 'text', text: 'plan after the transient failure' }])
+    agent.followup([{ type: 'text', text: 'plan after the transient failure' }])
     await recoveryEntered.promise
     ctx.planMode.set(agent, true)
     releaseRecovery.resolve(true)

+ 3 - 3
packages/pty/pty-local/tests/index.spec.ts

@@ -42,7 +42,7 @@ function agent(ctx: Context): Agent {
   const id = SessionId('agent')
   return {
     id, options: {}, session: new Session(id), status: 'idle', ctx,
-    send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
+    followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
   }
 }
 
@@ -244,7 +244,7 @@ describe('pty-local plugin shape', () => {
     const ownerFiber = await ctx.plugin(() => {})
     const owner: Agent = {
       id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
-      send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
+      followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
     }
     ctx.agents.register(owner)
     const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
@@ -287,7 +287,7 @@ describe('pty-local plugin shape', () => {
     const ownerFiber = await ctx.plugin(() => {})
     const owner: Agent = {
       id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
-      send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
+      followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
     }
     ctx.agents.register(owner)
     const gate = Promise.withResolvers<undefined>()

+ 1 - 1
packages/pty/pty-local/tests/local.spec.ts

@@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
   const scope = ctx.plugin(() => {})
   return {
     id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
-    send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
+    followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
   }
 }
 

+ 2 - 2
packages/pty/pty/tests/service.spec.ts

@@ -27,11 +27,11 @@ function stubAgent(ctx: Context, rawId: string): Agent {
     session: new Session(id),
     status: 'idle',
     ctx: scopeFiber.ctx,
-    send: () => AgentMessageId('stub'),
+    followup: () => AgentMessageId('stub'),
     queue: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),
     inject: () => AgentMessageId('stub'),
-    acceptInput: () => AgentMessageId('stub'),
+    send: () => AgentMessageId('stub'),
     cancel() {},
     whenIdle: () => Promise.resolve(),
   }

+ 1 - 1
packages/pty/tool-pty/tests/loader-composition.spec.ts

@@ -40,7 +40,7 @@ function agent(ctx: Context): Agent {
   const id = SessionId('pty-loader-agent')
   const value: Agent = {
     id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
-    send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
+    followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
   }
   ctx.agents.register(value)
   return value

+ 1 - 1
packages/pty/tool-pty/tests/tools.spec.ts

@@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
   const id = SessionId(rawId)
   const agent: Agent = {
     id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
-    send: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), acceptInput: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
+    followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
   }
   ctx.agents.register(agent)
   return agent

+ 1 - 1
packages/session-persistence/session-checkpoint-policy/tests/fixtures/crash-child.ts

@@ -56,5 +56,5 @@ const handle = await ctx.agents.create({
   sessionId: SessionId('semantic-checkpoint-crash'),
   agentOptions: { provider: 'crash', model: 'crash' },
 })
-handle.agent.send([{ type: 'text', text: 'exercise the crash boundary' }])
+handle.agent.followup([{ type: 'text', text: 'exercise the crash boundary' }])
 await waitForCrash()

+ 2 - 2
packages/subagent/subagent-fork/tests/multi-subagent.spec.ts

@@ -64,7 +64,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
     ])
 
     // Parent does one real turn first, so the fork has a completed turn to seed.
-    parent.send([{ type: 'text', text: 'parent q1' }])
+    parent.followup([{ type: 'text', text: 'parent q1' }])
     await parent.whenIdle()
     const parentPrefixLen = parent.session.events.length
 
@@ -93,7 +93,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => {
     await forkRun.dispose()
 
     // The parent is unaffected and keeps working after both delegations.
-    parent.send([{ type: 'text', text: 'parent q2' }])
+    parent.followup([{ type: 'text', text: 'parent q2' }])
     await parent.whenIdle()
     const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message')
     expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two')

+ 7 - 7
packages/subagent/subagent-fork/tests/subagent-fork.spec.ts

@@ -89,9 +89,9 @@ describe('dsh-subagent-fork', () => {
 
   it('seeds every completed parent turn through the last turn/end', async () => {
     const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')])
-    parent.send([{ type: 'text', text: 'q1' }])
+    parent.followup([{ type: 'text', text: 'q1' }])
     await parent.whenIdle()
-    parent.send([{ type: 'text', text: 'q2' }])
+    parent.followup([{ type: 'text', text: 'q2' }])
     await parent.whenIdle()
     const parentPrefixLen = parent.session.events.length
 
@@ -108,7 +108,7 @@ describe('dsh-subagent-fork', () => {
     // Parent runs one turn, then we fork. The child's seeded log should contain
     // the parent's first turn, and the child should run its own new turn on top.
     const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
-    parent.send([{ type: 'text', text: 'parent question' }])
+    parent.followup([{ type: 'text', text: 'parent question' }])
     await parent.whenIdle()
     const parentPrefixLen = parent.session.events.length
 
@@ -137,10 +137,10 @@ describe('dsh-subagent-fork', () => {
     // open (a hanging model call), and fork while it's in flight. The seed must stop after the
     // balanced first turn; including the open turn would fail invariant replay during start.
     const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')])
-    parent.send([{ type: 'text', text: 'q1' }])
+    parent.followup([{ type: 'text', text: 'q1' }])
     await parent.whenIdle()
     // Start a second turn that hangs (open turn/start + open step, never ends).
-    parent.send([{ type: 'text', text: 'q2' }])
+    parent.followup([{ type: 'text', text: 'q2' }])
     await new Promise(r => setTimeout(r, 20)) // let the hanging turn open
 
     // Forking now must NOT throw (the open second turn is excluded from the seed).
@@ -164,7 +164,7 @@ describe('dsh-subagent-fork', () => {
       textResponse('parent turn'),
       toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
     ])
-    parent.send([{ type: 'text', text: 'warm up' }])
+    parent.followup([{ type: 'text', text: 'warm up' }])
     await parent.whenIdle()
     const run = await start(ctx, 'fork', {
       prompt: [{ type: 'text', text: 'report structured' }],
@@ -183,7 +183,7 @@ describe('dsh-subagent-fork', () => {
     // `readResult` must scan only child-owned events after the seed. The child emits no assistant
     // message, so scanning the whole log would incorrectly return the parent's distinctive text.
     const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop])
-    parent.send([{ type: 'text', text: 'parent question' }])
+    parent.followup([{ type: 'text', text: 'parent question' }])
     await parent.whenIdle()
 
     const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent })

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

@@ -11,7 +11,7 @@ The driver follows this sequence:
 1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header.
 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction.
 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime.
-4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.send(prompt)` followed by `child.whenIdle()`.
+4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
 5. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned zero-step turns.
 
 The child gets the parent's working-directory/session lineage and inherits the parent model unless `request.agentOptions` overrides it. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.

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

@@ -141,7 +141,7 @@ export async function startInProcessRun(
 
   const result: Promise<SubagentResult> = (async () => {
     try {
-      child.send(request.prompt)
+      child.followup(request.prompt)
       await child.whenIdle()
       return readResult(
         child,

+ 4 - 4
packages/subagent/subagent-inprocess/tests/structured.spec.ts

@@ -522,7 +522,7 @@ describe('in-process structured output', () => {
       textResponse('parent answer'),
       toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 1 }),
     ])
-    parent.send([{ type: 'text', text: 'hello' }])
+    parent.followup([{ type: 'text', text: 'hello' }])
     await parent.whenIdle()
     expect(adapter.requests[0]!.system ?? '').not.toContain(STRUCTURED_OUTPUT_INSTRUCTION)
     const run = await ctx.subagents.start('spawn', structuredRequest(parent))
@@ -538,7 +538,7 @@ describe('in-process structured output', () => {
   describe('scoped registration (each child owns its capture tool)', () => {
     it('a plain agent never sees the tool: nothing is registered globally at all', async () => {
       const { ctx, parent, adapter } = await setup([textResponse('parent answer')])
-      parent.send([{ type: 'text', text: 'hello' }])
+      parent.followup([{ type: 'text', text: 'hello' }])
       await parent.whenIdle()
       // Scoped registration: the global view has no capture tool, ever.
       expect(ctx.tools.get(STRUCTURED_OUTPUT_TOOL)).toBeUndefined()
@@ -552,7 +552,7 @@ describe('in-process structured output', () => {
         // Child turn: must see it, with the run's schema.
         toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 42 }),
       ])
-      parent.send([{ type: 'text', text: 'hello' }])
+      parent.followup([{ type: 'text', text: 'hello' }])
       await parent.whenIdle()
       expect(toolNames(adapter.requests[0]!)).not.toContain(STRUCTURED_OUTPUT_TOOL)
 
@@ -630,7 +630,7 @@ describe('in-process structured output', () => {
 
     it('a non-structured agent request keeps tools ABSENT when it had none (no tools: [] materialized)', async () => {
       const { parent, adapter } = await setup([textResponse('plain')])
-      parent.send([{ type: 'text', text: 'q' }])
+      parent.followup([{ type: 'text', text: 'q' }])
       await parent.whenIdle()
       const request = adapter.requests[0]!
       expect(request.tools).toBeUndefined()

+ 1 - 1
packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts

@@ -87,7 +87,7 @@ describe('startInProcessRun', () => {
 
   it('seeds a forked child but reads only the child-owned output', async () => {
     const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')])
-    parent.send([{ type: 'text', text: 'parent question' }])
+    parent.followup([{ type: 'text', text: 'parent question' }])
     await parent.whenIdle()
     const seed = parent.session.events.slice()
     const run = await startInProcessRun(request(parent), { seed })

+ 1 - 1
packages/subagent/subagent-spawn/tests/spawn.e2e.ts

@@ -31,7 +31,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', (
     ctx = await spawnHarness(workdir)
     const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
 
-    parent.send([{ type: 'text', text:
+    parent.followup([{ type: 'text', text:
       'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text '
       + 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." '
       + 'After the subagent finishes, tell me it is done.' }])

+ 2 - 2
packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts

@@ -95,7 +95,7 @@ describe('dsh-subagent-spawn', () => {
   it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => {
     // Drive the parent through one real turn so it has history, THEN spawn.
     const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')])
-    parent.send([{ type: 'text', text: 'parent prompt' }])
+    parent.followup([{ type: 'text', text: 'parent prompt' }])
     await parent.whenIdle()
     const parentEventCount = parent.session.events.length
     expect(parentEventCount).toBeGreaterThan(0)
@@ -372,7 +372,7 @@ describe('dsh-subagent-spawn', () => {
         textResponse('parent answer'),
         textResponse('child answer'),
       ])
-      parent.send([{ type: 'text', text: 'hi' }])
+      parent.followup([{ type: 'text', text: 'hi' }])
       await parent.whenIdle()
 
       const run = await start(ctx, 'spawn', {

+ 2 - 2
packages/tasks/tasks/tests/tasks.spec.ts

@@ -23,11 +23,11 @@ function stubAgent(ctx: Context, rawId: string): Agent {
     session: new Session(id),
     status: 'idle' as const,
     ctx: scopeFiber.ctx,
-    send: () => AgentMessageId('stub'),
+    followup: () => AgentMessageId('stub'),
     queue: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),
     inject: () => AgentMessageId('stub'),
-    acceptInput: () => AgentMessageId('stub'),
+    send: () => AgentMessageId('stub'),
     cancel() {},
     whenIdle() { return Promise.resolve() },
   }

+ 2 - 2
packages/todo/tool-todo/tests/integration.spec.ts

@@ -59,7 +59,7 @@ describe('todo_write tool through the agent loop', () => {
     const ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(SessionId('it-todo'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'plan a two-step task' }])
+    agent.followup([{ type: 'text', text: 'plan a two-step task' }])
     await waitForIdle(ctx, agent)
 
     const log = agent.session.events
@@ -87,7 +87,7 @@ describe('todo_write tool through the agent loop', () => {
     const ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { provider: 'mock', model: 'mock' })
 
-    agent.send([{ type: 'text', text: 'plan then update' }])
+    agent.followup([{ type: 'text', text: 'plan then update' }])
     await waitForIdle(ctx, agent)
 
     const todoEvents = agent.session.events.filter(e => e.type === 'todo/write')

+ 1 - 1
packages/ui/acp/README.md

@@ -29,7 +29,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
 | `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
 | `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands |
 | `session/list` | `ctx.sessionQuery` | returns live-preferred newest-first sessions with absolute cwd and optional folded title; supports exact normalized cwd filtering, returns no cursor, and rejects supplied cursors |
-| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
+| `session/prompt` | `ctx.commands.execute()` or `agent.followup()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
 | `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
 | `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions |
 | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません