@cabane/companion 0.6.54 → 0.6.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +266 -67
  2. package/dist/runtime.js +266 -67
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2423,7 +2423,7 @@ var CabaneApi = class {
2423
2423
  return this.request("GET", `/api/workspaces/${workspaceId}/conversations/${conversationId}`);
2424
2424
  }
2425
2425
  // CT339: spawn a sub-agent conversation — the public create-conversation
2426
- // endpoint (`POST /conversations/messages`) with `dispatch` + `dispatchReturn`,
2426
+ // endpoint (`POST /conversations/messages`) with an addressed agent ask,
2427
2427
  // the machinery the `sub_agent` turn-control tool is sugar over. Two things make
2428
2428
  // it distinct from an ordinary `request` call, so it does its own `fetch`:
2429
2429
  // - a PER-CALL bearer — the turn's OBO token when the API minted one, else the
@@ -2431,10 +2431,10 @@ var CabaneApi = class {
2431
2431
  // cabane calls this turn (mirrors build-options' `turnToken ?? agentPat`);
2432
2432
  // - the `x-cabane-active-conversation` header naming the caller's turn, which
2433
2433
  // the server verifies against the live run to resolve the caller pair for the
2434
- // return callout (the same origin that stamps `origin_conversation_id`).
2434
+ // immutable birth tether (the same origin that stamps provenance).
2435
2435
  // Single-shot (no outbox/retry): a `sub_agent` spawn is a live, user-visible
2436
2436
  // action, and its result is returned to the agent immediately. Returns
2437
- // `{ status, body }` un-thrown so the caller maps the callout cap / gating errors
2437
+ // `{ status, body }` un-thrown so the caller maps validation errors
2438
2438
  // to a legible tool result rather than an exception.
2439
2439
  async createSubAgentConversation(workspaceId, activeConversationId, bearer, body) {
2440
2440
  const res = await fetch(`${this.base}/api/workspaces/${workspaceId}/conversations/messages`, {
@@ -2494,7 +2494,7 @@ var CabaneApi = class {
2494
2494
  const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
2495
2495
  return this.request("GET", `/api/agent/turn-context?${q}`);
2496
2496
  }
2497
- // CT714: read a turn's recorded turn-control intent (ask/wake/summon/skip). An
2497
+ // CT714: read a turn's recorded turn-control intent (ask/wake/send/skip). An
2498
2498
  // EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
2499
2499
  // `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
2500
2500
  // in-memory closures, so the dispatcher fetches this once at settle — by
@@ -3326,7 +3326,7 @@ var turnRequestSchema = z8.object({
3326
3326
  // EXTERNAL adapters (Codex / opencode) mount it by URL under the key
3327
3327
  // `cabane_companion` — using the same `bearer` (the turn token) and the same
3328
3328
  // active-conversation header they send to the `cabane` server — so their
3329
- // agents get `ask`/`wake_me`/`summon_agent`/`sub_agent`/`skip_turn`, the
3329
+ // agents get `ask`/`wake_me`/`send`/`sub_agent`/`skip_turn`, the
3330
3330
  // verbs they can't get from the companion's in-process SDK server. Optional:
3331
3331
  // claude-code ignores it (it mounts the in-process instance instead), and
3332
3332
  // every existing `cabane`-block fixture keeps parsing unchanged; the
@@ -3361,7 +3361,7 @@ var turnRequestSchema = z8.object({
3361
3361
  // default in place (see `buildClaudeCodeOptions`).
3362
3362
  claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
3363
3363
  }),
3364
- // Host-owned injected servers (host-filled) — e.g. the summon server.
3364
+ // Host-owned injected servers (host-filled) — e.g. the send server.
3365
3365
  extra: z8.object({
3366
3366
  mcpServers: hostInjectedServersSchema
3367
3367
  })
@@ -3692,8 +3692,9 @@ var CLAUDE_CODE_ADDENDUM = [
3692
3692
  "`mcp__cabane__sdk`; the ancillary tools are `mcp__cabane__list_workspaces`,",
3693
3693
  "`mcp__cabane__read_binary`, `mcp__cabane__upload`, `mcp__cabane__begin_upload`,",
3694
3694
  "`mcp__cabane__finalize_upload`, and `mcp__cabane__mint_render_token`; the turn tools are",
3695
- "`mcp__cabane_companion__ask`, `mcp__cabane_companion__wake_me`,",
3696
- "`mcp__cabane_companion__summon_agent`, `mcp__cabane_companion__sub_agent`, and",
3695
+ "`mcp__cabane_companion__ask`, `mcp__cabane_companion__reply_to`,",
3696
+ "`mcp__cabane_companion__wake_me`,",
3697
+ "`mcp__cabane_companion__send`, `mcp__cabane_companion__sub_agent`, and",
3697
3698
  "`mcp__cabane_companion__skip_turn`. Where this prompt names a tool by its bare verb, call",
3698
3699
  "the prefixed form from your tool list. Your harness's own tools (shell, local file",
3699
3700
  "read/edit) keep their ordinary names and act on the local machine only."
@@ -3806,8 +3807,19 @@ function decideResume(stored, currentCwd) {
3806
3807
  // packages/agent-runtime/src/claude-code/options.ts
3807
3808
  var CABANE_MCP_SERVER = "cabane";
3808
3809
  var ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
3809
- async function allowEverythingHook(input) {
3810
+ async function preToolUseHook(input) {
3810
3811
  const toolInput = "tool_input" in input && input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
3812
+ const toolName = "tool_name" in input ? input.tool_name : void 0;
3813
+ if (typeof toolName === "string" && SUBAGENT_TOOLS.includes(toolName) && (toolInput.run_in_background !== false || toolInput.isolation === "remote")) {
3814
+ return {
3815
+ continue: true,
3816
+ hookSpecificOutput: {
3817
+ hookEventName: "PreToolUse",
3818
+ permissionDecision: "deny",
3819
+ permissionDecisionReason: "Background subagents don't survive a Cabane turn: they're children of this turn's process and are killed when it ends, so nothing will notify you. Re-run this with run_in_background: false to get the result inside this turn, or arm a wake and check back."
3820
+ }
3821
+ };
3822
+ }
3811
3823
  return {
3812
3824
  continue: true,
3813
3825
  hookSpecificOutput: {
@@ -3892,7 +3904,7 @@ function buildClaudeCodeOptions(req, augment) {
3892
3904
  settingSources: ["project"],
3893
3905
  allowedTools,
3894
3906
  disallowedTools,
3895
- hooks: { PreToolUse: [{ hooks: [allowEverythingHook] }] }
3907
+ hooks: { PreToolUse: [{ hooks: [preToolUseHook] }] }
3896
3908
  };
3897
3909
  } else {
3898
3910
  options = {
@@ -3968,6 +3980,8 @@ async function* decodeSdkStream(iter, ctx) {
3968
3980
  const err = msg.error;
3969
3981
  if (typeof err === "string" && err.length > 0) authError = err;
3970
3982
  } else if (msg.type === "result") {
3983
+ const origin = msg.origin;
3984
+ if (origin?.kind === "task-notification") continue;
3971
3985
  sawResult = true;
3972
3986
  usage = readSdkUsage(msg);
3973
3987
  if (usage) {
@@ -4160,6 +4174,10 @@ var resultSuccess = (sessionId) => ({
4160
4174
  subtype: "success",
4161
4175
  session_id: sessionId
4162
4176
  });
4177
+ var resultSuccessFull = (sessionId, extra) => ({
4178
+ ...resultSuccess(sessionId),
4179
+ ...extra
4180
+ });
4163
4181
  var resultError = (subtype) => ({
4164
4182
  type: "result",
4165
4183
  subtype,
@@ -4313,6 +4331,116 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
4313
4331
  nativeStream: [init("s1"), resultSuccess("s1")],
4314
4332
  expected: [sessionEvent("s1"), { type: "result", ok: true }]
4315
4333
  },
4334
+ {
4335
+ // CT1220: on resume, a completed background task can emit a notification and
4336
+ // its own zero-token result before the prompt's frames. That result is not the
4337
+ // turn boundary; decoding continues through the prompt's reply and real usage.
4338
+ name: "task-notification result does not end a resumed turn",
4339
+ request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
4340
+ nativeStream: [
4341
+ init("s1"),
4342
+ {
4343
+ type: "system",
4344
+ subtype: "task_notification",
4345
+ task_id: "task-1",
4346
+ status: "completed",
4347
+ output_file: "/tmp/task-1.output",
4348
+ summary: "Background work completed.",
4349
+ session_id: "s1"
4350
+ },
4351
+ resultSuccessFull("s1", {
4352
+ is_error: false,
4353
+ origin: { kind: "task-notification" },
4354
+ usage: {
4355
+ input_tokens: 0,
4356
+ output_tokens: 0,
4357
+ cache_read_input_tokens: 0,
4358
+ cache_creation_input_tokens: 0
4359
+ }
4360
+ }),
4361
+ assistantTextTool("Checking the returned work.", {
4362
+ id: "tu1",
4363
+ name: "mcp__cabane__cabane_read",
4364
+ input: { path: "result.md" }
4365
+ }),
4366
+ toolResult("tu1", [{ type: "text", text: "ready" }]),
4367
+ assistantText("The prompt completed normally."),
4368
+ resultSuccessFull("s1", {
4369
+ is_error: false,
4370
+ usage: {
4371
+ input_tokens: 120,
4372
+ output_tokens: 30,
4373
+ cache_read_input_tokens: 80,
4374
+ cache_creation_input_tokens: 10
4375
+ }
4376
+ })
4377
+ ],
4378
+ expected: [
4379
+ { type: "text", body: "Checking the returned work.", terminal: false },
4380
+ {
4381
+ type: "tool",
4382
+ id: "tu1",
4383
+ name: "cabane_read",
4384
+ phase: "start",
4385
+ summary: "result.md",
4386
+ input: { path: "result.md" },
4387
+ mcpServer: "cabane"
4388
+ },
4389
+ {
4390
+ type: "tool",
4391
+ id: "tu1",
4392
+ name: "cabane_read",
4393
+ phase: "done",
4394
+ summary: "result.md",
4395
+ input: { path: "result.md" },
4396
+ result: [{ type: "text", text: "ready" }],
4397
+ mcpServer: "cabane"
4398
+ },
4399
+ { type: "text", body: "The prompt completed normally.", terminal: true },
4400
+ {
4401
+ type: "result",
4402
+ ok: true,
4403
+ usage: {
4404
+ inputTokens: 210,
4405
+ outputTokens: 30,
4406
+ cacheReadTokens: 80,
4407
+ cacheCreationTokens: 10
4408
+ }
4409
+ }
4410
+ ]
4411
+ },
4412
+ {
4413
+ // CT1220: the filter is deliberately narrow. An origin-less zero-token result
4414
+ // keeps today's break-on-first-result behavior, even if more frames follow.
4415
+ name: "origin-less result still ends a resumed turn",
4416
+ request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
4417
+ nativeStream: [
4418
+ init("s1"),
4419
+ resultSuccessFull("s1", {
4420
+ is_error: false,
4421
+ usage: {
4422
+ input_tokens: 0,
4423
+ output_tokens: 0,
4424
+ cache_read_input_tokens: 0,
4425
+ cache_creation_input_tokens: 0
4426
+ }
4427
+ }),
4428
+ assistantText("This must not be emitted."),
4429
+ resultSuccess("s1")
4430
+ ],
4431
+ expected: [
4432
+ {
4433
+ type: "result",
4434
+ ok: true,
4435
+ usage: {
4436
+ inputTokens: 0,
4437
+ outputTokens: 0,
4438
+ cacheReadTokens: 0,
4439
+ cacheCreationTokens: 0
4440
+ }
4441
+ }
4442
+ ]
4443
+ },
4316
4444
  {
4317
4445
  // Empty-final: a clean turn that ended on a tool call with no closing text.
4318
4446
  // The adapter emits NO final text — empty-final promotion is host/pump
@@ -4494,7 +4622,7 @@ function selectAdapter(registry, runtime) {
4494
4622
  // packages/agent-runtime/src/opencode/addendum.ts
4495
4623
  var OPENCODE_ADDENDUM = [
4496
4624
  "Every tool here goes by its plain name: the workspace tool is `sdk`; the turn tools are",
4497
- "`ask`, `wake_me`, `summon_agent`, `sub_agent`, `skip_turn`; the ancillary tools are",
4625
+ "`ask`, `reply_to`, `wake_me`, `send`, `sub_agent`, `skip_turn`; the ancillary tools are",
4498
4626
  "`list_workspaces`, `read_binary`, `upload`, `begin_upload`, `finalize_upload`,",
4499
4627
  "`mint_render_token`; and the host tools are plain verbs too (`bash`, `read`, `edit`). Mind",
4500
4628
  "the collision: a bare `read` or `edit` is the HOST tool, acting on the local machine \u2014 a",
@@ -5470,8 +5598,9 @@ var CODEX_ADDENDUM = [
5470
5598
  "workspace tool is `mcp__cabane__sdk` \u2014 if it isn't in your visible tool list, locate it in",
5471
5599
  "the deferred-tool inventory and invoke that exact qualified name; never conclude the SDK is",
5472
5600
  "absent without attempting discovery and invocation, and if a call fails, report the recorded",
5473
- "error. The turn tools are `mcp__cabane_companion__ask` / `wake_me` / `summon_agent` /",
5474
- "`sub_agent` / `skip_turn`, and the ancillary tools `mcp__cabane__list_workspaces` /",
5601
+ "error. The turn tools are `mcp__cabane_companion__ask` / `reply_to` / `wake_me` /",
5602
+ "`send` / `sub_agent` / `skip_turn`, and the ancillary tools",
5603
+ "`mcp__cabane__list_workspaces` /",
5475
5604
  "`read_binary` / `upload` / `begin_upload` / `finalize_upload` / `mint_render_token` \u2014 all",
5476
5605
  "possibly deferred too. There is no Cabane `read`/`write`/`search` tool \u2014 those are",
5477
5606
  "`cabane.*` calls inside your program. One more harness fact: you can interleave narration",
@@ -6969,15 +7098,17 @@ import { createHash as createHash2, randomUUID } from "crypto";
6969
7098
  import { appendFileSync as appendFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync10, readdirSync as readdirSync2, statSync } from "fs";
6970
7099
  import { join as join14 } from "path";
6971
7100
 
6972
- // src/summon.ts
7101
+ // src/turn-control-tools.ts
6973
7102
  import { z as z13 } from "zod";
6974
7103
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
6975
- var SUMMON_AGENT_TOOL = "summon_agent";
6976
- var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
7104
+ var SEND_TOOL = "send";
7105
+ var SEND_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SEND_TOOL}`;
6977
7106
  var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
6978
7107
  var SKIP_TURN_TOOL = "skip_turn";
6979
7108
  var ASK_TOOL = "ask";
6980
7109
  var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
7110
+ var REPLY_TO_TOOL = "reply_to";
7111
+ var REPLY_TO_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${REPLY_TO_TOOL}`;
6981
7112
  var MAX_ASK_ITEMS = 20;
6982
7113
  var SUB_AGENT_TOOL = "sub_agent";
6983
7114
  var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
@@ -6985,8 +7116,11 @@ var WAKE_ME_TOOL = "wake_me";
6985
7116
  var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
6986
7117
  var CANCEL_WAKE_TOOL = "cancel_wake";
6987
7118
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
6988
- function createSummonState() {
6989
- return { agentId: null };
7119
+ function createReplyState() {
7120
+ return { answersMessageId: null };
7121
+ }
7122
+ function createSendState() {
7123
+ return { agentId: null, message: null };
6990
7124
  }
6991
7125
  function createSkipState() {
6992
7126
  return { skipped: false, reason: null };
@@ -6997,6 +7131,19 @@ function createAskState() {
6997
7131
  function createWakeState() {
6998
7132
  return { afterSeconds: null, at: null, note: null, cancelled: false };
6999
7133
  }
7134
+ function resolveDeclaredReplyField(input) {
7135
+ const explicit = input.replyState.answersMessageId;
7136
+ if (explicit) return { answersMessageId: explicit };
7137
+ if (input.kind !== "final") return {};
7138
+ if (!input.owedReplyMessageId) return {};
7139
+ const outwardSend = Boolean(
7140
+ input.sendState.agentId && input.sendState.message && input.sendState.agentId !== input.agentId
7141
+ );
7142
+ const askRaised = Boolean(input.askState.targetUserId);
7143
+ const wakeArmed = input.wakeState.afterSeconds !== null || input.wakeState.at !== null;
7144
+ if (outwardSend || askRaised || wakeArmed || input.spawnedSubAgent) return {};
7145
+ return { answersMessageId: input.owedReplyMessageId };
7146
+ }
7000
7147
  function wakeCommitField(state) {
7001
7148
  if (state.cancelled) return { wake: { cancel: true } };
7002
7149
  const { afterSeconds, at, note } = state;
@@ -7009,27 +7156,50 @@ function wakeCommitField(state) {
7009
7156
  }
7010
7157
  };
7011
7158
  }
7012
- function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
7159
+ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
7013
7160
  return createSdkMcpServer({
7014
7161
  name: COMPANION_LOCAL_MCP_SERVER,
7015
7162
  version: "0.0.0",
7016
7163
  tools: [
7017
7164
  tool(
7018
- SUMMON_AGENT_TOOL,
7019
- "Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Use it to hand part of the work to a teammate, or pull in an expert, without leaving the conversation. Pass the peer's `agentId` \u2014 every agent's handle and id is on the roster in your turn context. The peer is dispatched on your turn's final reply, so write the context/ask into that reply first \u2014 it receives your message + this conversation to work from. Writing `@handle` in your prose does NOT summon anyone (agent prose never dispatches); this tool is the only in-thread lever. Single target \u2014 the last call wins. Summoning yourself is a no-op. Reach for it when the human wants the peer's answer right HERE, in front of them \u2014 the reply lands in this thread, so there's no return to wire (a return is for work YOU consume, never a courtesy notification). A handoff to a DIFFERENT conversation is `cabane.conversations.create` / `cabane.conversations.post` with their `dispatch` field instead.",
7165
+ SEND_TOOL,
7166
+ "Send one addressed message to another agent in THIS conversation. Pass the peer's `agentId` and the complete `message` they should act on; the server writes it separately from your terminal reply. Writing `@handle` in prose dispatches nobody. Single target \u2014 the last call wins. Sending to yourself is a no-op. A handoff to a DIFFERENT conversation is `cabane.conversations.create` / `cabane.conversations.post` with their `dispatch` field instead. Never use send to hand an answer back to the requester \u2014 declare that with reply_to.",
7020
7167
  {
7021
7168
  agentId: z13.string().uuid().describe(
7022
- "The peer agent to summon \u2014 a workspace agent id, from your turn context's roster."
7023
- )
7169
+ "The peer agent to address \u2014 a workspace agent id, from your turn context's roster."
7170
+ ),
7171
+ message: z13.string().min(1).max(65536).describe("The complete new request the peer should receive and act on.")
7024
7172
  },
7025
7173
  async (args) => {
7026
- summonState.agentId = args.agentId;
7174
+ sendState.agentId = args.agentId;
7175
+ sendState.message = args.message;
7027
7176
  return {
7028
- content: [{ type: "text", text: JSON.stringify({ summoned: args.agentId }) }]
7177
+ content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
7029
7178
  };
7030
7179
  },
7031
7180
  { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7032
7181
  ),
7182
+ ...replyState ? [
7183
+ tool(
7184
+ REPLY_TO_TOOL,
7185
+ "Declare which addressed ask your final response answers. Pass the `messageId` shown as the owed reply id in your turn context, then finish your response normally. This records lineage only: it does not send another message. The server verifies that this agent owes that ask in this conversation; one reply per turn, last call wins.",
7186
+ {
7187
+ messageId: z13.string().uuid().describe("The owed ask message id from this turn context.")
7188
+ },
7189
+ async (args) => {
7190
+ replyState.answersMessageId = args.messageId;
7191
+ return {
7192
+ content: [
7193
+ {
7194
+ type: "text",
7195
+ text: JSON.stringify({ answersMessageId: args.messageId })
7196
+ }
7197
+ ]
7198
+ };
7199
+ },
7200
+ { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7201
+ )
7202
+ ] : [],
7033
7203
  ...skipState ? [
7034
7204
  tool(
7035
7205
  SKIP_TURN_TOOL,
@@ -7050,7 +7220,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
7050
7220
  ...askState ? [
7051
7221
  tool(
7052
7222
  ASK_TOOL,
7053
- `Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact). Pass \`targetUserId\` (a workspace member's user id \u2014 every person's id is on the roster in your turn context). Two forms: a SINGLE question \u2014 a \`headline\` (the actual question as one clear, capitalized sentence ending in \`?\`, "Do we go to prod?") plus a short \`question\` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once, a \`questions\` array of 1\u2013${MAX_ASK_ITEMS} items, each \`{ headline, body?, options? }\`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence \`headline\`, a short \`body\` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted lists or headings), and 2\u20134 \`options\` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. An option can be a short button label or a whole sentence. Provide EITHER \`question\` (single) or \`questions\` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use summon/dispatch instead.`,
7223
+ `Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact). Pass \`targetUserId\` (a workspace member's user id \u2014 every person's id is on the roster in your turn context). Two forms: a SINGLE question \u2014 a \`headline\` (the actual question as one clear, capitalized sentence ending in \`?\`, "Do we go to prod?") plus a short \`question\` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once, a \`questions\` array of 1\u2013${MAX_ASK_ITEMS} items, each \`{ headline, body?, options? }\`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence \`headline\`, a short \`body\` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted lists or headings), and 2\u20134 \`options\` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. An option can be a short button label or a whole sentence. Provide EITHER \`question\` (single) or \`questions\` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use send/dispatch instead.`,
7054
7224
  {
7055
7225
  targetUserId: z13.string().uuid().describe(
7056
7226
  "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
@@ -7123,10 +7293,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
7123
7293
  ...subAgentCreate ? [
7124
7294
  tool(
7125
7295
  SUB_AGENT_TOOL,
7126
- "Spawn a sub-agent \u2014 hand a piece of work to a private worker with a fresh context window, whose result comes back to you automatically. Modeled on the Task tool, with ONE deliberate difference: it does NOT return the result inline. A callee's turn can run for minutes and no turn may hold an unbounded wait, so the shape is spawn-now, results-on-wake \u2014 this returns immediately with the child's `conversationId`, and the outcome lands LATER as a message in THIS conversation; you're woken once every sub-agent you have out in this conversation has returned. So DON'T wait for it: after spawning, finish whatever else this turn can do and end your turn (never poll the child with reads in a loop \u2014 the wake is automatic). Parallel fan-out = call this N times in one turn (they run concurrently; ONE wake when all are in); series = one call per turn. `prompt` is the child's opening instruction \u2014 make it self-contained (the sub-agent starts fresh, with only this prompt + the thread it lands in). `agentId` (optional) dispatches a PEER instead of yourself \u2014 same mechanics, a different mind (use for capability/context you lack); default (self) is the pure sub-worker with a clean context window. `title` (optional) names the child thread (results link it, so a legible title helps). A single sub-agent has no wall-clock advantage (you idle either way) \u2014 it pays when the callee has capability/context you lack, or to isolate a big read from your own session; the real win is fan-out. Don't spawn one for a lookup you can do in-turn with your own tools. The result returns to YOU to act on \u2014 reach for it when you're the consumer of the output, not as a way to notify a human: if a person just wants to read the result, dispatch a plain (no-return) conversation and link it instead of spawning a sub-agent.",
7296
+ "Ask into a child conversation \u2014 a bounded question or piece of work whose ANSWER returns to you automatically. This creates the child and dispatches its agent now, returning the child's `conversationId` immediately; the answer lands LATER as a message in THIS conversation, and you're woken once every child you asked this turn has answered. So DON'T wait: after asking, finish whatever else this turn can do and end your turn (never poll the child with reads in a loop \u2014 the wake is automatic and it carries the answer). Parallel fan-out = call this N times in one turn (they run concurrently; ONE wake carrying all the answers); series = one call per turn. `prompt` is the ask itself \u2014 write it as a request that expects an answer back, not a role you are assigning or a job description: self-contained (the child starts fresh, with only this prompt), and say what shape the answer should take. `agentId` (optional) asks a PEER instead of yourself \u2014 same mechanics, a different mind (use for capability/context you lack); default (self) is a fresh context window of you. `title` (optional) names the child thread (answers link it, so a legible title helps). A single child buys no wall-clock time (you idle either way) \u2014 it pays when the callee has capability/context you lack, or to isolate a big read from your own session; the real win is fan-out. Don't ask a child for a lookup you can do in-turn with your own tools. The answer returns to YOU to act on \u2014 reach for it when you're the consumer of the output, not as a way to notify a human: if a person just wants to read the result, dispatch a plain (no-return) conversation and link it instead.",
7127
7297
  {
7128
7298
  prompt: z13.string().min(1).max(65536).describe(
7129
- "The sub-agent's opening instruction \u2014 self-contained (it starts with a fresh context window; only this prompt + the thread it lands in)."
7299
+ "The ask \u2014 a self-contained request that expects an answer back (the child starts fresh: only this prompt + the thread it lands in). Say what shape the answer should take."
7130
7300
  ),
7131
7301
  agentId: z13.string().uuid().optional().describe(
7132
7302
  "Optional peer to run the sub-agent as (a workspace agent id, from your turn context's roster); omit to spawn yourself with a fresh context window."
@@ -7147,7 +7317,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
7147
7317
  type: "text",
7148
7318
  text: JSON.stringify({
7149
7319
  conversationId: result.conversationId,
7150
- note: "Spawned. Don't wait for it \u2014 finish what else this turn can do, then end your turn; the result posts back here and you're woken once every sub-agent you have out in this conversation has returned."
7320
+ note: "Asked. Don't wait for it \u2014 finish what else this turn can do, then end your turn; the answer posts back here and you're woken once every child you asked this turn has answered."
7151
7321
  })
7152
7322
  }
7153
7323
  ]
@@ -7286,10 +7456,9 @@ function buildCompanionTurnRequest(params) {
7286
7456
  // CT289: the auto-memory escape hatch, when the operator set it.
7287
7457
  ...params.claudeCode ? { claudeCode: params.claudeCode } : {}
7288
7458
  },
7289
- // Host-injected: the companion-local summon server (for the subprocess adapters,
7290
- // under its own namespace).
7459
+ // Host-injected turn-control tools, under their own namespace.
7291
7460
  extra: {
7292
- mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.summonServer }
7461
+ mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.turnControlServer }
7293
7462
  }
7294
7463
  };
7295
7464
  }
@@ -7564,7 +7733,7 @@ var TurnCommitter = class {
7564
7733
  turnId: deps.turnId,
7565
7734
  seq,
7566
7735
  parentMessageId: deps.parentMessageId,
7567
- ...kind === "final" ? this.turnControlFields() : {}
7736
+ ...kind === "final" ? this.turnControlFields("final") : {}
7568
7737
  },
7569
7738
  deps.signal
7570
7739
  );
@@ -7659,27 +7828,45 @@ var TurnCommitter = class {
7659
7828
  return this.pump.finalEmitted;
7660
7829
  }
7661
7830
  // A closing textual reply and a wordless terminal marker carry the same
7662
- // per-turn control state. Keep this one projection so ask/wake/summon cannot
7663
- // silently diverge when the agent ends without words.
7664
- turnControlFields() {
7831
+ // per-turn control state. Keep this one projection so ask/wake/send cannot
7832
+ // silently diverge when the agent ends without words. The KIND matters to
7833
+ // exactly one field: the auto-declared reply binds only a textual `final` —
7834
+ // a wordless turn has no answer to bind, and the server's mute-settle
7835
+ // notice speaks for it.
7836
+ turnControlFields(kind) {
7665
7837
  return {
7666
- ...this.summonField(),
7838
+ ...this.answersField(kind),
7839
+ ...this.sendField(),
7667
7840
  ...this.askField(),
7668
7841
  ...this.wakeField()
7669
7842
  };
7670
7843
  }
7671
- // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
7672
- // commit. A self-target is stripped here (mirror of the in-app self-strip); the
7673
- // server strips it again and resolves / ignores an unknown id.
7674
- summonField() {
7675
- const target = this.deps.summonState.agentId;
7676
- if (!target || target === this.deps.agentId) return {};
7677
- return { dispatch: target };
7844
+ // The declared reply: explicit `reply_to` first, else the runtime's own
7845
+ // declaration for the turn that just answers see
7846
+ // `resolveDeclaredReplyField` for the whole rule and its reasons.
7847
+ answersField(kind) {
7848
+ return resolveDeclaredReplyField({
7849
+ kind,
7850
+ owedReplyMessageId: this.deps.owedReplyMessageId,
7851
+ agentId: this.deps.agentId,
7852
+ replyState: this.deps.replyState,
7853
+ sendState: this.deps.sendState,
7854
+ askState: this.deps.askState,
7855
+ wakeState: this.deps.wakeState,
7856
+ spawnedSubAgent: this.deps.spawnedSubAgent()
7857
+ });
7858
+ }
7859
+ // Carry both halves of the addressed send. The server writes `dispatchBody`
7860
+ // on a distinct message row; the turn row itself remains unaddressed speech.
7861
+ sendField() {
7862
+ const { agentId: target, message } = this.deps.sendState;
7863
+ if (!target || !message || target === this.deps.agentId) return {};
7864
+ return { dispatch: target, dispatchBody: message };
7678
7865
  }
7679
7866
  // CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
7680
7867
  // The server validates the target (must be a workspace member/owner) and
7681
7868
  // creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
7682
- // independent of `summonField` — a turn could conceivably summon AND ask.
7869
+ // independent of `sendField` — a turn can send and ask independently.
7683
7870
  askField() {
7684
7871
  const { targetUserId, question, headline, options, questions } = this.deps.askState;
7685
7872
  if (!targetUserId) return {};
@@ -7709,7 +7896,7 @@ var TurnCommitter = class {
7709
7896
  // CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
7710
7897
  // The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
7711
7898
  // null = no wake this turn → attach nothing). The server computes `fire_at` and
7712
- // arms the CT441 schedule. Kept independent of summon/ask — a turn could
7899
+ // arms the CT441 schedule. Kept independent of send/ask — a turn could
7713
7900
  // conceivably ask AND arm a wake.
7714
7901
  //
7715
7902
  // CT990: the field now carries three states, and the third can't be spelled by
@@ -7946,15 +8133,9 @@ function describeErrorBody(body) {
7946
8133
  function describeSubAgentError(status2, body) {
7947
8134
  const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
7948
8135
  switch (code) {
7949
- case "callout_cap_exceeded":
7950
- return "sub_agent: you already have the maximum open sub-agents for this thread. Wait for some to return \u2014 you're woken once they're all back \u2014 before spawning more.";
7951
- case "callout_depth_exceeded":
7952
- return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
7953
8136
  case "dispatch_agent_not_found":
7954
8137
  return "sub_agent: no live agent in this workspace matches that `agentId`. Check the roster in your turn context, or omit `agentId` to spawn yourself.";
7955
- case "dispatch_return_requires_turn":
7956
- case "dispatch_return_requires_agent":
7957
- case "dispatch_return_requires_dispatch":
8138
+ case "dispatch_ask_requires_dispatch":
7958
8139
  return `sub_agent: the spawn was rejected (${code}). This is a turn-context problem, not something to retry blindly \u2014 report it rather than looping.`;
7959
8140
  default:
7960
8141
  return `sub_agent: the spawn failed (${code ?? `HTTP ${status2}`}).`;
@@ -8120,7 +8301,12 @@ var Dispatcher = class {
8120
8301
  }
8121
8302
  let hookEnv;
8122
8303
  const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
8123
- const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? { dispatch: turnContext.dispatchedByAgentId } : {};
8304
+ const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
8305
+ dispatch: turnContext.dispatchedByAgentId,
8306
+ dispatchBody: `${PREPARE_FAILED_PREFIX}
8307
+
8308
+ The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`
8309
+ } : {};
8124
8310
  if (prepareHook) {
8125
8311
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
8126
8312
  if (cached2 && !checkoutState(cached2.cwd).ok) {
@@ -8274,10 +8460,12 @@ ${reason}`,
8274
8460
  "dispatcher: active-run flag set failed terminally; proceeding"
8275
8461
  );
8276
8462
  }
8277
- const summonState = createSummonState();
8463
+ const sendState = createSendState();
8278
8464
  const skipState = createSkipState();
8279
8465
  const askState = createAskState();
8280
8466
  const wakeState = createWakeState();
8467
+ const replyState = createReplyState();
8468
+ let spawnedSubAgent = false;
8281
8469
  const subAgentCreate = async (args) => {
8282
8470
  const dispatchTarget = args.agentId ?? payload.agentId;
8283
8471
  const projectId = turnContext.conversation.projectId;
@@ -8290,7 +8478,7 @@ ${reason}`,
8290
8478
  ...args.title ? { title: args.title } : {},
8291
8479
  body: args.prompt,
8292
8480
  dispatch: dispatchTarget,
8293
- dispatchReturn: true
8481
+ dispatchAsk: true
8294
8482
  }
8295
8483
  );
8296
8484
  if (status2 >= 400) return { ok: false, error: describeSubAgentError(status2, body) };
@@ -8298,14 +8486,16 @@ ${reason}`,
8298
8486
  if (!conversationId) {
8299
8487
  return { ok: false, error: "sub_agent: the spawn returned no conversation id." };
8300
8488
  }
8489
+ spawnedSubAgent = true;
8301
8490
  return { ok: true, conversationId };
8302
8491
  };
8303
- const summonServer = createSummonMcpServer(
8304
- summonState,
8492
+ const turnControlServer = createTurnControlMcpServer(
8493
+ sendState,
8305
8494
  skipState,
8306
8495
  askState,
8307
8496
  subAgentCreate,
8308
- wakeState
8497
+ wakeState,
8498
+ replyState
8309
8499
  );
8310
8500
  const request = buildCompanionTurnRequest({
8311
8501
  turnContext,
@@ -8319,7 +8509,7 @@ ${reason}`,
8319
8509
  // CT1103: the prepare-hook env plus the authoritative host-access token.
8320
8510
  env: turnEnv,
8321
8511
  mcpServers: resolvedMcpServers,
8322
- summonServer,
8512
+ turnControlServer,
8323
8513
  // CT238: this turn's conversation, forwarded as the active-conversation
8324
8514
  // header so a cross-thread post/spawn stamps its origin.
8325
8515
  activeConversationId: payload.conversationId,
@@ -8568,15 +8758,20 @@ ${reason}`,
8568
8758
  signal: abortController.signal,
8569
8759
  log: turnLog,
8570
8760
  nextSeq,
8571
- // CT183: the committer reads this at commit to attach the in-thread summon
8572
- // onto the turn's `final` row.
8573
- summonState,
8761
+ // The committer reads this at commit to attach the addressed send onto the
8762
+ // turn's `final` row.
8763
+ sendState,
8574
8764
  // CT326: likewise the ask payload — attached to the `final` row so the
8575
8765
  // server creates the `asks` row atomically with the message it rides on.
8576
8766
  askState,
8577
8767
  // CT442: likewise the wake payload — attached to the `final` row so the
8578
8768
  // server arms the wake schedule atomically with the reply it rode on.
8579
- wakeState
8769
+ wakeState,
8770
+ replyState,
8771
+ // The ledger-derived owed reply, for the runtime's own declaration when
8772
+ // the agent doesn't call `reply_to` (resolveDeclaredReplyField).
8773
+ owedReplyMessageId: turnContext.owedReplyMessageId ?? null,
8774
+ spawnedSubAgent: () => spawnedSubAgent
8580
8775
  });
8581
8776
  const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
8582
8777
  let turnControlIntentFetched = false;
@@ -8617,7 +8812,11 @@ ${reason}`,
8617
8812
  wakeState.note = intent.wake.note;
8618
8813
  }
8619
8814
  }
8620
- if (intent.summonAgentId) summonState.agentId = intent.summonAgentId;
8815
+ if (intent.sendAgentId && intent.sendBody) {
8816
+ sendState.agentId = intent.sendAgentId;
8817
+ sendState.message = intent.sendBody;
8818
+ }
8819
+ if (intent.answersMessageId) replyState.answersMessageId = intent.answersMessageId;
8621
8820
  if (intent.skipped) {
8622
8821
  skipState.skipped = true;
8623
8822
  skipState.reason = intent.skipReason;
@@ -8755,10 +8954,10 @@ ${reason}`,
8755
8954
  turnId,
8756
8955
  seq: nextSeq(),
8757
8956
  parentMessageId: payload.messageId,
8758
- // ask, wake and summon must survive a wordless turn exactly as
8957
+ // ask, wake and send must survive a wordless turn exactly as
8759
8958
  // they survive a textual final; dropping one can strand a person
8760
8959
  // or the next actor with no visible failure.
8761
- ...committer.turnControlFields()
8960
+ ...committer.turnControlFields("silent")
8762
8961
  });
8763
8962
  silentMarkerEmitted = true;
8764
8963
  } catch (err) {
package/dist/runtime.js CHANGED
@@ -1843,7 +1843,7 @@ var CabaneApi = class {
1843
1843
  return this.request("GET", `/api/workspaces/${workspaceId}/conversations/${conversationId}`);
1844
1844
  }
1845
1845
  // CT339: spawn a sub-agent conversation — the public create-conversation
1846
- // endpoint (`POST /conversations/messages`) with `dispatch` + `dispatchReturn`,
1846
+ // endpoint (`POST /conversations/messages`) with an addressed agent ask,
1847
1847
  // the machinery the `sub_agent` turn-control tool is sugar over. Two things make
1848
1848
  // it distinct from an ordinary `request` call, so it does its own `fetch`:
1849
1849
  // - a PER-CALL bearer — the turn's OBO token when the API minted one, else the
@@ -1851,10 +1851,10 @@ var CabaneApi = class {
1851
1851
  // cabane calls this turn (mirrors build-options' `turnToken ?? agentPat`);
1852
1852
  // - the `x-cabane-active-conversation` header naming the caller's turn, which
1853
1853
  // the server verifies against the live run to resolve the caller pair for the
1854
- // return callout (the same origin that stamps `origin_conversation_id`).
1854
+ // immutable birth tether (the same origin that stamps provenance).
1855
1855
  // Single-shot (no outbox/retry): a `sub_agent` spawn is a live, user-visible
1856
1856
  // action, and its result is returned to the agent immediately. Returns
1857
- // `{ status, body }` un-thrown so the caller maps the callout cap / gating errors
1857
+ // `{ status, body }` un-thrown so the caller maps validation errors
1858
1858
  // to a legible tool result rather than an exception.
1859
1859
  async createSubAgentConversation(workspaceId, activeConversationId, bearer, body) {
1860
1860
  const res = await fetch(`${this.base}/api/workspaces/${workspaceId}/conversations/messages`, {
@@ -1914,7 +1914,7 @@ var CabaneApi = class {
1914
1914
  const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
1915
1915
  return this.request("GET", `/api/agent/turn-context?${q}`);
1916
1916
  }
1917
- // CT714: read a turn's recorded turn-control intent (ask/wake/summon/skip). An
1917
+ // CT714: read a turn's recorded turn-control intent (ask/wake/send/skip). An
1918
1918
  // EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
1919
1919
  // `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
1920
1920
  // in-memory closures, so the dispatcher fetches this once at settle — by
@@ -2825,7 +2825,7 @@ var turnRequestSchema = z8.object({
2825
2825
  // EXTERNAL adapters (Codex / opencode) mount it by URL under the key
2826
2826
  // `cabane_companion` — using the same `bearer` (the turn token) and the same
2827
2827
  // active-conversation header they send to the `cabane` server — so their
2828
- // agents get `ask`/`wake_me`/`summon_agent`/`sub_agent`/`skip_turn`, the
2828
+ // agents get `ask`/`wake_me`/`send`/`sub_agent`/`skip_turn`, the
2829
2829
  // verbs they can't get from the companion's in-process SDK server. Optional:
2830
2830
  // claude-code ignores it (it mounts the in-process instance instead), and
2831
2831
  // every existing `cabane`-block fixture keeps parsing unchanged; the
@@ -2860,7 +2860,7 @@ var turnRequestSchema = z8.object({
2860
2860
  // default in place (see `buildClaudeCodeOptions`).
2861
2861
  claudeCode: z8.object({ autoMemory: z8.boolean().optional() }).optional()
2862
2862
  }),
2863
- // Host-owned injected servers (host-filled) — e.g. the summon server.
2863
+ // Host-owned injected servers (host-filled) — e.g. the send server.
2864
2864
  extra: z8.object({
2865
2865
  mcpServers: hostInjectedServersSchema
2866
2866
  })
@@ -3191,8 +3191,9 @@ var CLAUDE_CODE_ADDENDUM = [
3191
3191
  "`mcp__cabane__sdk`; the ancillary tools are `mcp__cabane__list_workspaces`,",
3192
3192
  "`mcp__cabane__read_binary`, `mcp__cabane__upload`, `mcp__cabane__begin_upload`,",
3193
3193
  "`mcp__cabane__finalize_upload`, and `mcp__cabane__mint_render_token`; the turn tools are",
3194
- "`mcp__cabane_companion__ask`, `mcp__cabane_companion__wake_me`,",
3195
- "`mcp__cabane_companion__summon_agent`, `mcp__cabane_companion__sub_agent`, and",
3194
+ "`mcp__cabane_companion__ask`, `mcp__cabane_companion__reply_to`,",
3195
+ "`mcp__cabane_companion__wake_me`,",
3196
+ "`mcp__cabane_companion__send`, `mcp__cabane_companion__sub_agent`, and",
3196
3197
  "`mcp__cabane_companion__skip_turn`. Where this prompt names a tool by its bare verb, call",
3197
3198
  "the prefixed form from your tool list. Your harness's own tools (shell, local file",
3198
3199
  "read/edit) keep their ordinary names and act on the local machine only."
@@ -3305,8 +3306,19 @@ function decideResume(stored, currentCwd) {
3305
3306
  // packages/agent-runtime/src/claude-code/options.ts
3306
3307
  var CABANE_MCP_SERVER = "cabane";
3307
3308
  var ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
3308
- async function allowEverythingHook(input) {
3309
+ async function preToolUseHook(input) {
3309
3310
  const toolInput = "tool_input" in input && input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
3311
+ const toolName = "tool_name" in input ? input.tool_name : void 0;
3312
+ if (typeof toolName === "string" && SUBAGENT_TOOLS.includes(toolName) && (toolInput.run_in_background !== false || toolInput.isolation === "remote")) {
3313
+ return {
3314
+ continue: true,
3315
+ hookSpecificOutput: {
3316
+ hookEventName: "PreToolUse",
3317
+ permissionDecision: "deny",
3318
+ permissionDecisionReason: "Background subagents don't survive a Cabane turn: they're children of this turn's process and are killed when it ends, so nothing will notify you. Re-run this with run_in_background: false to get the result inside this turn, or arm a wake and check back."
3319
+ }
3320
+ };
3321
+ }
3310
3322
  return {
3311
3323
  continue: true,
3312
3324
  hookSpecificOutput: {
@@ -3391,7 +3403,7 @@ function buildClaudeCodeOptions(req, augment) {
3391
3403
  settingSources: ["project"],
3392
3404
  allowedTools,
3393
3405
  disallowedTools,
3394
- hooks: { PreToolUse: [{ hooks: [allowEverythingHook] }] }
3406
+ hooks: { PreToolUse: [{ hooks: [preToolUseHook] }] }
3395
3407
  };
3396
3408
  } else {
3397
3409
  options = {
@@ -3467,6 +3479,8 @@ async function* decodeSdkStream(iter, ctx) {
3467
3479
  const err = msg.error;
3468
3480
  if (typeof err === "string" && err.length > 0) authError = err;
3469
3481
  } else if (msg.type === "result") {
3482
+ const origin = msg.origin;
3483
+ if (origin?.kind === "task-notification") continue;
3470
3484
  sawResult = true;
3471
3485
  usage = readSdkUsage(msg);
3472
3486
  if (usage) {
@@ -3659,6 +3673,10 @@ var resultSuccess = (sessionId) => ({
3659
3673
  subtype: "success",
3660
3674
  session_id: sessionId
3661
3675
  });
3676
+ var resultSuccessFull = (sessionId, extra) => ({
3677
+ ...resultSuccess(sessionId),
3678
+ ...extra
3679
+ });
3662
3680
  var resultError = (subtype) => ({
3663
3681
  type: "result",
3664
3682
  subtype,
@@ -3812,6 +3830,116 @@ var CLAUDE_CODE_CONFORMANCE_FIXTURES = [
3812
3830
  nativeStream: [init("s1"), resultSuccess("s1")],
3813
3831
  expected: [sessionEvent("s1"), { type: "result", ok: true }]
3814
3832
  },
3833
+ {
3834
+ // CT1220: on resume, a completed background task can emit a notification and
3835
+ // its own zero-token result before the prompt's frames. That result is not the
3836
+ // turn boundary; decoding continues through the prompt's reply and real usage.
3837
+ name: "task-notification result does not end a resumed turn",
3838
+ request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
3839
+ nativeStream: [
3840
+ init("s1"),
3841
+ {
3842
+ type: "system",
3843
+ subtype: "task_notification",
3844
+ task_id: "task-1",
3845
+ status: "completed",
3846
+ output_file: "/tmp/task-1.output",
3847
+ summary: "Background work completed.",
3848
+ session_id: "s1"
3849
+ },
3850
+ resultSuccessFull("s1", {
3851
+ is_error: false,
3852
+ origin: { kind: "task-notification" },
3853
+ usage: {
3854
+ input_tokens: 0,
3855
+ output_tokens: 0,
3856
+ cache_read_input_tokens: 0,
3857
+ cache_creation_input_tokens: 0
3858
+ }
3859
+ }),
3860
+ assistantTextTool("Checking the returned work.", {
3861
+ id: "tu1",
3862
+ name: "mcp__cabane__cabane_read",
3863
+ input: { path: "result.md" }
3864
+ }),
3865
+ toolResult("tu1", [{ type: "text", text: "ready" }]),
3866
+ assistantText("The prompt completed normally."),
3867
+ resultSuccessFull("s1", {
3868
+ is_error: false,
3869
+ usage: {
3870
+ input_tokens: 120,
3871
+ output_tokens: 30,
3872
+ cache_read_input_tokens: 80,
3873
+ cache_creation_input_tokens: 10
3874
+ }
3875
+ })
3876
+ ],
3877
+ expected: [
3878
+ { type: "text", body: "Checking the returned work.", terminal: false },
3879
+ {
3880
+ type: "tool",
3881
+ id: "tu1",
3882
+ name: "cabane_read",
3883
+ phase: "start",
3884
+ summary: "result.md",
3885
+ input: { path: "result.md" },
3886
+ mcpServer: "cabane"
3887
+ },
3888
+ {
3889
+ type: "tool",
3890
+ id: "tu1",
3891
+ name: "cabane_read",
3892
+ phase: "done",
3893
+ summary: "result.md",
3894
+ input: { path: "result.md" },
3895
+ result: [{ type: "text", text: "ready" }],
3896
+ mcpServer: "cabane"
3897
+ },
3898
+ { type: "text", body: "The prompt completed normally.", terminal: true },
3899
+ {
3900
+ type: "result",
3901
+ ok: true,
3902
+ usage: {
3903
+ inputTokens: 210,
3904
+ outputTokens: 30,
3905
+ cacheReadTokens: 80,
3906
+ cacheCreationTokens: 10
3907
+ }
3908
+ }
3909
+ ]
3910
+ },
3911
+ {
3912
+ // CT1220: the filter is deliberately narrow. An origin-less zero-token result
3913
+ // keeps today's break-on-first-result behavior, even if more frames follow.
3914
+ name: "origin-less result still ends a resumed turn",
3915
+ request: makeRequest({ session: encodeSession({ sdkSessionId: "s1", cwd: CWD }) }),
3916
+ nativeStream: [
3917
+ init("s1"),
3918
+ resultSuccessFull("s1", {
3919
+ is_error: false,
3920
+ usage: {
3921
+ input_tokens: 0,
3922
+ output_tokens: 0,
3923
+ cache_read_input_tokens: 0,
3924
+ cache_creation_input_tokens: 0
3925
+ }
3926
+ }),
3927
+ assistantText("This must not be emitted."),
3928
+ resultSuccess("s1")
3929
+ ],
3930
+ expected: [
3931
+ {
3932
+ type: "result",
3933
+ ok: true,
3934
+ usage: {
3935
+ inputTokens: 0,
3936
+ outputTokens: 0,
3937
+ cacheReadTokens: 0,
3938
+ cacheCreationTokens: 0
3939
+ }
3940
+ }
3941
+ ]
3942
+ },
3815
3943
  {
3816
3944
  // Empty-final: a clean turn that ended on a tool call with no closing text.
3817
3945
  // The adapter emits NO final text — empty-final promotion is host/pump
@@ -3993,7 +4121,7 @@ function selectAdapter(registry, runtime) {
3993
4121
  // packages/agent-runtime/src/opencode/addendum.ts
3994
4122
  var OPENCODE_ADDENDUM = [
3995
4123
  "Every tool here goes by its plain name: the workspace tool is `sdk`; the turn tools are",
3996
- "`ask`, `wake_me`, `summon_agent`, `sub_agent`, `skip_turn`; the ancillary tools are",
4124
+ "`ask`, `reply_to`, `wake_me`, `send`, `sub_agent`, `skip_turn`; the ancillary tools are",
3997
4125
  "`list_workspaces`, `read_binary`, `upload`, `begin_upload`, `finalize_upload`,",
3998
4126
  "`mint_render_token`; and the host tools are plain verbs too (`bash`, `read`, `edit`). Mind",
3999
4127
  "the collision: a bare `read` or `edit` is the HOST tool, acting on the local machine \u2014 a",
@@ -4969,8 +5097,9 @@ var CODEX_ADDENDUM = [
4969
5097
  "workspace tool is `mcp__cabane__sdk` \u2014 if it isn't in your visible tool list, locate it in",
4970
5098
  "the deferred-tool inventory and invoke that exact qualified name; never conclude the SDK is",
4971
5099
  "absent without attempting discovery and invocation, and if a call fails, report the recorded",
4972
- "error. The turn tools are `mcp__cabane_companion__ask` / `wake_me` / `summon_agent` /",
4973
- "`sub_agent` / `skip_turn`, and the ancillary tools `mcp__cabane__list_workspaces` /",
5100
+ "error. The turn tools are `mcp__cabane_companion__ask` / `reply_to` / `wake_me` /",
5101
+ "`send` / `sub_agent` / `skip_turn`, and the ancillary tools",
5102
+ "`mcp__cabane__list_workspaces` /",
4974
5103
  "`read_binary` / `upload` / `begin_upload` / `finalize_upload` / `mint_render_token` \u2014 all",
4975
5104
  "possibly deferred too. There is no Cabane `read`/`write`/`search` tool \u2014 those are",
4976
5105
  "`cabane.*` calls inside your program. One more harness fact: you can interleave narration",
@@ -6468,15 +6597,17 @@ import { createHash as createHash2, randomUUID } from "crypto";
6468
6597
  import { appendFileSync as appendFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync10, readdirSync as readdirSync2, statSync } from "fs";
6469
6598
  import { join as join14 } from "path";
6470
6599
 
6471
- // src/summon.ts
6600
+ // src/turn-control-tools.ts
6472
6601
  import { z as z13 } from "zod";
6473
6602
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
6474
- var SUMMON_AGENT_TOOL = "summon_agent";
6475
- var SUMMON_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUMMON_AGENT_TOOL}`;
6603
+ var SEND_TOOL = "send";
6604
+ var SEND_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SEND_TOOL}`;
6476
6605
  var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
6477
6606
  var SKIP_TURN_TOOL = "skip_turn";
6478
6607
  var ASK_TOOL = "ask";
6479
6608
  var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
6609
+ var REPLY_TO_TOOL = "reply_to";
6610
+ var REPLY_TO_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${REPLY_TO_TOOL}`;
6480
6611
  var MAX_ASK_ITEMS = 20;
6481
6612
  var SUB_AGENT_TOOL = "sub_agent";
6482
6613
  var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
@@ -6484,8 +6615,11 @@ var WAKE_ME_TOOL = "wake_me";
6484
6615
  var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
6485
6616
  var CANCEL_WAKE_TOOL = "cancel_wake";
6486
6617
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
6487
- function createSummonState() {
6488
- return { agentId: null };
6618
+ function createReplyState() {
6619
+ return { answersMessageId: null };
6620
+ }
6621
+ function createSendState() {
6622
+ return { agentId: null, message: null };
6489
6623
  }
6490
6624
  function createSkipState() {
6491
6625
  return { skipped: false, reason: null };
@@ -6496,6 +6630,19 @@ function createAskState() {
6496
6630
  function createWakeState() {
6497
6631
  return { afterSeconds: null, at: null, note: null, cancelled: false };
6498
6632
  }
6633
+ function resolveDeclaredReplyField(input) {
6634
+ const explicit = input.replyState.answersMessageId;
6635
+ if (explicit) return { answersMessageId: explicit };
6636
+ if (input.kind !== "final") return {};
6637
+ if (!input.owedReplyMessageId) return {};
6638
+ const outwardSend = Boolean(
6639
+ input.sendState.agentId && input.sendState.message && input.sendState.agentId !== input.agentId
6640
+ );
6641
+ const askRaised = Boolean(input.askState.targetUserId);
6642
+ const wakeArmed = input.wakeState.afterSeconds !== null || input.wakeState.at !== null;
6643
+ if (outwardSend || askRaised || wakeArmed || input.spawnedSubAgent) return {};
6644
+ return { answersMessageId: input.owedReplyMessageId };
6645
+ }
6499
6646
  function wakeCommitField(state) {
6500
6647
  if (state.cancelled) return { wake: { cancel: true } };
6501
6648
  const { afterSeconds, at, note } = state;
@@ -6508,27 +6655,50 @@ function wakeCommitField(state) {
6508
6655
  }
6509
6656
  };
6510
6657
  }
6511
- function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
6658
+ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
6512
6659
  return createSdkMcpServer({
6513
6660
  name: COMPANION_LOCAL_MCP_SERVER,
6514
6661
  version: "0.0.0",
6515
6662
  tools: [
6516
6663
  tool(
6517
- SUMMON_AGENT_TOOL,
6518
- "Summon another agent into THIS conversation \u2014 dispatch a peer to reply here on your turn. Use it to hand part of the work to a teammate, or pull in an expert, without leaving the conversation. Pass the peer's `agentId` \u2014 every agent's handle and id is on the roster in your turn context. The peer is dispatched on your turn's final reply, so write the context/ask into that reply first \u2014 it receives your message + this conversation to work from. Writing `@handle` in your prose does NOT summon anyone (agent prose never dispatches); this tool is the only in-thread lever. Single target \u2014 the last call wins. Summoning yourself is a no-op. Reach for it when the human wants the peer's answer right HERE, in front of them \u2014 the reply lands in this thread, so there's no return to wire (a return is for work YOU consume, never a courtesy notification). A handoff to a DIFFERENT conversation is `cabane.conversations.create` / `cabane.conversations.post` with their `dispatch` field instead.",
6664
+ SEND_TOOL,
6665
+ "Send one addressed message to another agent in THIS conversation. Pass the peer's `agentId` and the complete `message` they should act on; the server writes it separately from your terminal reply. Writing `@handle` in prose dispatches nobody. Single target \u2014 the last call wins. Sending to yourself is a no-op. A handoff to a DIFFERENT conversation is `cabane.conversations.create` / `cabane.conversations.post` with their `dispatch` field instead. Never use send to hand an answer back to the requester \u2014 declare that with reply_to.",
6519
6666
  {
6520
6667
  agentId: z13.string().uuid().describe(
6521
- "The peer agent to summon \u2014 a workspace agent id, from your turn context's roster."
6522
- )
6668
+ "The peer agent to address \u2014 a workspace agent id, from your turn context's roster."
6669
+ ),
6670
+ message: z13.string().min(1).max(65536).describe("The complete new request the peer should receive and act on.")
6523
6671
  },
6524
6672
  async (args) => {
6525
- summonState.agentId = args.agentId;
6673
+ sendState.agentId = args.agentId;
6674
+ sendState.message = args.message;
6526
6675
  return {
6527
- content: [{ type: "text", text: JSON.stringify({ summoned: args.agentId }) }]
6676
+ content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
6528
6677
  };
6529
6678
  },
6530
6679
  { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
6531
6680
  ),
6681
+ ...replyState ? [
6682
+ tool(
6683
+ REPLY_TO_TOOL,
6684
+ "Declare which addressed ask your final response answers. Pass the `messageId` shown as the owed reply id in your turn context, then finish your response normally. This records lineage only: it does not send another message. The server verifies that this agent owes that ask in this conversation; one reply per turn, last call wins.",
6685
+ {
6686
+ messageId: z13.string().uuid().describe("The owed ask message id from this turn context.")
6687
+ },
6688
+ async (args) => {
6689
+ replyState.answersMessageId = args.messageId;
6690
+ return {
6691
+ content: [
6692
+ {
6693
+ type: "text",
6694
+ text: JSON.stringify({ answersMessageId: args.messageId })
6695
+ }
6696
+ ]
6697
+ };
6698
+ },
6699
+ { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
6700
+ )
6701
+ ] : [],
6532
6702
  ...skipState ? [
6533
6703
  tool(
6534
6704
  SKIP_TURN_TOOL,
@@ -6549,7 +6719,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6549
6719
  ...askState ? [
6550
6720
  tool(
6551
6721
  ASK_TOOL,
6552
- `Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact). Pass \`targetUserId\` (a workspace member's user id \u2014 every person's id is on the roster in your turn context). Two forms: a SINGLE question \u2014 a \`headline\` (the actual question as one clear, capitalized sentence ending in \`?\`, "Do we go to prod?") plus a short \`question\` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once, a \`questions\` array of 1\u2013${MAX_ASK_ITEMS} items, each \`{ headline, body?, options? }\`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence \`headline\`, a short \`body\` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted lists or headings), and 2\u20134 \`options\` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. An option can be a short button label or a whole sentence. Provide EITHER \`question\` (single) or \`questions\` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use summon/dispatch instead.`,
6722
+ `Ask a HUMAN a structured question (or a short LIST of them) you need answered to continue, then END your turn \u2014 don't wait for the reply. Use it when you genuinely can't proceed without a person's input (a decision only they can make, a missing fact). Pass \`targetUserId\` (a workspace member's user id \u2014 every person's id is on the roster in your turn context). Two forms: a SINGLE question \u2014 a \`headline\` (the actual question as one clear, capitalized sentence ending in \`?\`, "Do we go to prod?") plus a short \`question\` body for the framing the headline can't hold \u2014 OR, when a plan ends with SEVERAL bounded decisions at once, a \`questions\` array of 1\u2013${MAX_ASK_ITEMS} items, each \`{ headline, body?, options? }\`. **Prefer the list over cramming the extra decisions into prose or dropping them** \u2014 end the turn with one ask carrying every question, never pick one and bury the rest. Each question keeps the same form rules: a one-sentence \`headline\`, a short \`body\` frame (NOT a report \u2014 your status, links, and detail go in your REPLY, and the body renders inline markdown only: links/emphasis/inline code, no bulleted lists or headings), and 2\u20134 \`options\` when the answer is a bounded choice \u2014 for a yes/no go-ahead always pass them, so it's one click, not a typed reply. An option can be a short button label or a whole sentence. Provide EITHER \`question\` (single) or \`questions\` (array), never both. The ask is a first-class attention item aimed at that person; your final reply carries the surrounding CONTEXT (what you found, why you're stuck), the ask carries the QUESTION(S). An open ask marks you as blocked until EVERY question is answered, so raise one only when you truly can't proceed \u2014 never ceremonially. One ask per turn (last call wins). After asking, stop \u2014 when the person replies addressed to you, the ask resolves and you resume; other people's or agents' messages may wake you but leave it open. Targets a human only; to hand work to another AGENT use send/dispatch instead.`,
6553
6723
  {
6554
6724
  targetUserId: z13.string().uuid().describe(
6555
6725
  "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
@@ -6622,10 +6792,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6622
6792
  ...subAgentCreate ? [
6623
6793
  tool(
6624
6794
  SUB_AGENT_TOOL,
6625
- "Spawn a sub-agent \u2014 hand a piece of work to a private worker with a fresh context window, whose result comes back to you automatically. Modeled on the Task tool, with ONE deliberate difference: it does NOT return the result inline. A callee's turn can run for minutes and no turn may hold an unbounded wait, so the shape is spawn-now, results-on-wake \u2014 this returns immediately with the child's `conversationId`, and the outcome lands LATER as a message in THIS conversation; you're woken once every sub-agent you have out in this conversation has returned. So DON'T wait for it: after spawning, finish whatever else this turn can do and end your turn (never poll the child with reads in a loop \u2014 the wake is automatic). Parallel fan-out = call this N times in one turn (they run concurrently; ONE wake when all are in); series = one call per turn. `prompt` is the child's opening instruction \u2014 make it self-contained (the sub-agent starts fresh, with only this prompt + the thread it lands in). `agentId` (optional) dispatches a PEER instead of yourself \u2014 same mechanics, a different mind (use for capability/context you lack); default (self) is the pure sub-worker with a clean context window. `title` (optional) names the child thread (results link it, so a legible title helps). A single sub-agent has no wall-clock advantage (you idle either way) \u2014 it pays when the callee has capability/context you lack, or to isolate a big read from your own session; the real win is fan-out. Don't spawn one for a lookup you can do in-turn with your own tools. The result returns to YOU to act on \u2014 reach for it when you're the consumer of the output, not as a way to notify a human: if a person just wants to read the result, dispatch a plain (no-return) conversation and link it instead of spawning a sub-agent.",
6795
+ "Ask into a child conversation \u2014 a bounded question or piece of work whose ANSWER returns to you automatically. This creates the child and dispatches its agent now, returning the child's `conversationId` immediately; the answer lands LATER as a message in THIS conversation, and you're woken once every child you asked this turn has answered. So DON'T wait: after asking, finish whatever else this turn can do and end your turn (never poll the child with reads in a loop \u2014 the wake is automatic and it carries the answer). Parallel fan-out = call this N times in one turn (they run concurrently; ONE wake carrying all the answers); series = one call per turn. `prompt` is the ask itself \u2014 write it as a request that expects an answer back, not a role you are assigning or a job description: self-contained (the child starts fresh, with only this prompt), and say what shape the answer should take. `agentId` (optional) asks a PEER instead of yourself \u2014 same mechanics, a different mind (use for capability/context you lack); default (self) is a fresh context window of you. `title` (optional) names the child thread (answers link it, so a legible title helps). A single child buys no wall-clock time (you idle either way) \u2014 it pays when the callee has capability/context you lack, or to isolate a big read from your own session; the real win is fan-out. Don't ask a child for a lookup you can do in-turn with your own tools. The answer returns to YOU to act on \u2014 reach for it when you're the consumer of the output, not as a way to notify a human: if a person just wants to read the result, dispatch a plain (no-return) conversation and link it instead.",
6626
6796
  {
6627
6797
  prompt: z13.string().min(1).max(65536).describe(
6628
- "The sub-agent's opening instruction \u2014 self-contained (it starts with a fresh context window; only this prompt + the thread it lands in)."
6798
+ "The ask \u2014 a self-contained request that expects an answer back (the child starts fresh: only this prompt + the thread it lands in). Say what shape the answer should take."
6629
6799
  ),
6630
6800
  agentId: z13.string().uuid().optional().describe(
6631
6801
  "Optional peer to run the sub-agent as (a workspace agent id, from your turn context's roster); omit to spawn yourself with a fresh context window."
@@ -6646,7 +6816,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6646
6816
  type: "text",
6647
6817
  text: JSON.stringify({
6648
6818
  conversationId: result.conversationId,
6649
- note: "Spawned. Don't wait for it \u2014 finish what else this turn can do, then end your turn; the result posts back here and you're woken once every sub-agent you have out in this conversation has returned."
6819
+ note: "Asked. Don't wait for it \u2014 finish what else this turn can do, then end your turn; the answer posts back here and you're woken once every child you asked this turn has answered."
6650
6820
  })
6651
6821
  }
6652
6822
  ]
@@ -6785,10 +6955,9 @@ function buildCompanionTurnRequest(params) {
6785
6955
  // CT289: the auto-memory escape hatch, when the operator set it.
6786
6956
  ...params.claudeCode ? { claudeCode: params.claudeCode } : {}
6787
6957
  },
6788
- // Host-injected: the companion-local summon server (for the subprocess adapters,
6789
- // under its own namespace).
6958
+ // Host-injected turn-control tools, under their own namespace.
6790
6959
  extra: {
6791
- mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.summonServer }
6960
+ mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.turnControlServer }
6792
6961
  }
6793
6962
  };
6794
6963
  }
@@ -7063,7 +7232,7 @@ var TurnCommitter = class {
7063
7232
  turnId: deps.turnId,
7064
7233
  seq,
7065
7234
  parentMessageId: deps.parentMessageId,
7066
- ...kind === "final" ? this.turnControlFields() : {}
7235
+ ...kind === "final" ? this.turnControlFields("final") : {}
7067
7236
  },
7068
7237
  deps.signal
7069
7238
  );
@@ -7158,27 +7327,45 @@ var TurnCommitter = class {
7158
7327
  return this.pump.finalEmitted;
7159
7328
  }
7160
7329
  // A closing textual reply and a wordless terminal marker carry the same
7161
- // per-turn control state. Keep this one projection so ask/wake/summon cannot
7162
- // silently diverge when the agent ends without words.
7163
- turnControlFields() {
7330
+ // per-turn control state. Keep this one projection so ask/wake/send cannot
7331
+ // silently diverge when the agent ends without words. The KIND matters to
7332
+ // exactly one field: the auto-declared reply binds only a textual `final` —
7333
+ // a wordless turn has no answer to bind, and the server's mute-settle
7334
+ // notice speaks for it.
7335
+ turnControlFields(kind) {
7164
7336
  return {
7165
- ...this.summonField(),
7337
+ ...this.answersField(kind),
7338
+ ...this.sendField(),
7166
7339
  ...this.askField(),
7167
7340
  ...this.wakeField()
7168
7341
  };
7169
7342
  }
7170
- // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
7171
- // commit. A self-target is stripped here (mirror of the in-app self-strip); the
7172
- // server strips it again and resolves / ignores an unknown id.
7173
- summonField() {
7174
- const target = this.deps.summonState.agentId;
7175
- if (!target || target === this.deps.agentId) return {};
7176
- return { dispatch: target };
7343
+ // The declared reply: explicit `reply_to` first, else the runtime's own
7344
+ // declaration for the turn that just answers see
7345
+ // `resolveDeclaredReplyField` for the whole rule and its reasons.
7346
+ answersField(kind) {
7347
+ return resolveDeclaredReplyField({
7348
+ kind,
7349
+ owedReplyMessageId: this.deps.owedReplyMessageId,
7350
+ agentId: this.deps.agentId,
7351
+ replyState: this.deps.replyState,
7352
+ sendState: this.deps.sendState,
7353
+ askState: this.deps.askState,
7354
+ wakeState: this.deps.wakeState,
7355
+ spawnedSubAgent: this.deps.spawnedSubAgent()
7356
+ });
7357
+ }
7358
+ // Carry both halves of the addressed send. The server writes `dispatchBody`
7359
+ // on a distinct message row; the turn row itself remains unaddressed speech.
7360
+ sendField() {
7361
+ const { agentId: target, message } = this.deps.sendState;
7362
+ if (!target || !message || target === this.deps.agentId) return {};
7363
+ return { dispatch: target, dispatchBody: message };
7177
7364
  }
7178
7365
  // CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
7179
7366
  // The server validates the target (must be a workspace member/owner) and
7180
7367
  // creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
7181
- // independent of `summonField` — a turn could conceivably summon AND ask.
7368
+ // independent of `sendField` — a turn can send and ask independently.
7182
7369
  askField() {
7183
7370
  const { targetUserId, question, headline, options, questions } = this.deps.askState;
7184
7371
  if (!targetUserId) return {};
@@ -7208,7 +7395,7 @@ var TurnCommitter = class {
7208
7395
  // CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
7209
7396
  // The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
7210
7397
  // null = no wake this turn → attach nothing). The server computes `fire_at` and
7211
- // arms the CT441 schedule. Kept independent of summon/ask — a turn could
7398
+ // arms the CT441 schedule. Kept independent of send/ask — a turn could
7212
7399
  // conceivably ask AND arm a wake.
7213
7400
  //
7214
7401
  // CT990: the field now carries three states, and the third can't be spelled by
@@ -7445,15 +7632,9 @@ function describeErrorBody(body) {
7445
7632
  function describeSubAgentError(status, body) {
7446
7633
  const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
7447
7634
  switch (code) {
7448
- case "callout_cap_exceeded":
7449
- return "sub_agent: you already have the maximum open sub-agents for this thread. Wait for some to return \u2014 you're woken once they're all back \u2014 before spawning more.";
7450
- case "callout_depth_exceeded":
7451
- return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
7452
7635
  case "dispatch_agent_not_found":
7453
7636
  return "sub_agent: no live agent in this workspace matches that `agentId`. Check the roster in your turn context, or omit `agentId` to spawn yourself.";
7454
- case "dispatch_return_requires_turn":
7455
- case "dispatch_return_requires_agent":
7456
- case "dispatch_return_requires_dispatch":
7637
+ case "dispatch_ask_requires_dispatch":
7457
7638
  return `sub_agent: the spawn was rejected (${code}). This is a turn-context problem, not something to retry blindly \u2014 report it rather than looping.`;
7458
7639
  default:
7459
7640
  return `sub_agent: the spawn failed (${code ?? `HTTP ${status}`}).`;
@@ -7619,7 +7800,12 @@ var Dispatcher = class {
7619
7800
  }
7620
7801
  let hookEnv;
7621
7802
  const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
7622
- const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? { dispatch: turnContext.dispatchedByAgentId } : {};
7803
+ const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
7804
+ dispatch: turnContext.dispatchedByAgentId,
7805
+ dispatchBody: `${PREPARE_FAILED_PREFIX}
7806
+
7807
+ The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`
7808
+ } : {};
7623
7809
  if (prepareHook) {
7624
7810
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
7625
7811
  if (cached2 && !checkoutState(cached2.cwd).ok) {
@@ -7773,10 +7959,12 @@ ${reason}`,
7773
7959
  "dispatcher: active-run flag set failed terminally; proceeding"
7774
7960
  );
7775
7961
  }
7776
- const summonState = createSummonState();
7962
+ const sendState = createSendState();
7777
7963
  const skipState = createSkipState();
7778
7964
  const askState = createAskState();
7779
7965
  const wakeState = createWakeState();
7966
+ const replyState = createReplyState();
7967
+ let spawnedSubAgent = false;
7780
7968
  const subAgentCreate = async (args) => {
7781
7969
  const dispatchTarget = args.agentId ?? payload.agentId;
7782
7970
  const projectId = turnContext.conversation.projectId;
@@ -7789,7 +7977,7 @@ ${reason}`,
7789
7977
  ...args.title ? { title: args.title } : {},
7790
7978
  body: args.prompt,
7791
7979
  dispatch: dispatchTarget,
7792
- dispatchReturn: true
7980
+ dispatchAsk: true
7793
7981
  }
7794
7982
  );
7795
7983
  if (status >= 400) return { ok: false, error: describeSubAgentError(status, body) };
@@ -7797,14 +7985,16 @@ ${reason}`,
7797
7985
  if (!conversationId) {
7798
7986
  return { ok: false, error: "sub_agent: the spawn returned no conversation id." };
7799
7987
  }
7988
+ spawnedSubAgent = true;
7800
7989
  return { ok: true, conversationId };
7801
7990
  };
7802
- const summonServer = createSummonMcpServer(
7803
- summonState,
7991
+ const turnControlServer = createTurnControlMcpServer(
7992
+ sendState,
7804
7993
  skipState,
7805
7994
  askState,
7806
7995
  subAgentCreate,
7807
- wakeState
7996
+ wakeState,
7997
+ replyState
7808
7998
  );
7809
7999
  const request = buildCompanionTurnRequest({
7810
8000
  turnContext,
@@ -7818,7 +8008,7 @@ ${reason}`,
7818
8008
  // CT1103: the prepare-hook env plus the authoritative host-access token.
7819
8009
  env: turnEnv,
7820
8010
  mcpServers: resolvedMcpServers,
7821
- summonServer,
8011
+ turnControlServer,
7822
8012
  // CT238: this turn's conversation, forwarded as the active-conversation
7823
8013
  // header so a cross-thread post/spawn stamps its origin.
7824
8014
  activeConversationId: payload.conversationId,
@@ -8067,15 +8257,20 @@ ${reason}`,
8067
8257
  signal: abortController.signal,
8068
8258
  log: turnLog,
8069
8259
  nextSeq,
8070
- // CT183: the committer reads this at commit to attach the in-thread summon
8071
- // onto the turn's `final` row.
8072
- summonState,
8260
+ // The committer reads this at commit to attach the addressed send onto the
8261
+ // turn's `final` row.
8262
+ sendState,
8073
8263
  // CT326: likewise the ask payload — attached to the `final` row so the
8074
8264
  // server creates the `asks` row atomically with the message it rides on.
8075
8265
  askState,
8076
8266
  // CT442: likewise the wake payload — attached to the `final` row so the
8077
8267
  // server arms the wake schedule atomically with the reply it rode on.
8078
- wakeState
8268
+ wakeState,
8269
+ replyState,
8270
+ // The ledger-derived owed reply, for the runtime's own declaration when
8271
+ // the agent doesn't call `reply_to` (resolveDeclaredReplyField).
8272
+ owedReplyMessageId: turnContext.owedReplyMessageId ?? null,
8273
+ spawnedSubAgent: () => spawnedSubAgent
8079
8274
  });
8080
8275
  const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
8081
8276
  let turnControlIntentFetched = false;
@@ -8116,7 +8311,11 @@ ${reason}`,
8116
8311
  wakeState.note = intent.wake.note;
8117
8312
  }
8118
8313
  }
8119
- if (intent.summonAgentId) summonState.agentId = intent.summonAgentId;
8314
+ if (intent.sendAgentId && intent.sendBody) {
8315
+ sendState.agentId = intent.sendAgentId;
8316
+ sendState.message = intent.sendBody;
8317
+ }
8318
+ if (intent.answersMessageId) replyState.answersMessageId = intent.answersMessageId;
8120
8319
  if (intent.skipped) {
8121
8320
  skipState.skipped = true;
8122
8321
  skipState.reason = intent.skipReason;
@@ -8254,10 +8453,10 @@ ${reason}`,
8254
8453
  turnId,
8255
8454
  seq: nextSeq(),
8256
8455
  parentMessageId: payload.messageId,
8257
- // ask, wake and summon must survive a wordless turn exactly as
8456
+ // ask, wake and send must survive a wordless turn exactly as
8258
8457
  // they survive a textual final; dropping one can strand a person
8259
8458
  // or the next actor with no visible failure.
8260
- ...committer.turnControlFields()
8459
+ ...committer.turnControlFields("silent")
8261
8460
  });
8262
8461
  silentMarkerEmitted = true;
8263
8462
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.54",
3
+ "version": "0.6.59",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect a coding agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",