@cabane/companion 0.6.53 → 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 +269 -69
  2. package/dist/runtime.js +269 -69
  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,23 +7098,29 @@ 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}`;
7112
+ var MAX_ASK_ITEMS = 20;
6981
7113
  var SUB_AGENT_TOOL = "sub_agent";
6982
7114
  var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
6983
7115
  var WAKE_ME_TOOL = "wake_me";
6984
7116
  var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
6985
7117
  var CANCEL_WAKE_TOOL = "cancel_wake";
6986
7118
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
6987
- function createSummonState() {
6988
- return { agentId: null };
7119
+ function createReplyState() {
7120
+ return { answersMessageId: null };
7121
+ }
7122
+ function createSendState() {
7123
+ return { agentId: null, message: null };
6989
7124
  }
6990
7125
  function createSkipState() {
6991
7126
  return { skipped: false, reason: null };
@@ -6996,6 +7131,19 @@ function createAskState() {
6996
7131
  function createWakeState() {
6997
7132
  return { afterSeconds: null, at: null, note: null, cancelled: false };
6998
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
+ }
6999
7147
  function wakeCommitField(state) {
7000
7148
  if (state.cancelled) return { wake: { cancel: true } };
7001
7149
  const { afterSeconds, at, note } = state;
@@ -7008,27 +7156,50 @@ function wakeCommitField(state) {
7008
7156
  }
7009
7157
  };
7010
7158
  }
7011
- function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
7159
+ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
7012
7160
  return createSdkMcpServer({
7013
7161
  name: COMPANION_LOCAL_MCP_SERVER,
7014
7162
  version: "0.0.0",
7015
7163
  tools: [
7016
7164
  tool(
7017
- SUMMON_AGENT_TOOL,
7018
- "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.",
7019
7167
  {
7020
7168
  agentId: z13.string().uuid().describe(
7021
- "The peer agent to summon \u2014 a workspace agent id, from your turn context's roster."
7022
- )
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.")
7023
7172
  },
7024
7173
  async (args) => {
7025
- summonState.agentId = args.agentId;
7174
+ sendState.agentId = args.agentId;
7175
+ sendState.message = args.message;
7026
7176
  return {
7027
- content: [{ type: "text", text: JSON.stringify({ summoned: args.agentId }) }]
7177
+ content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
7028
7178
  };
7029
7179
  },
7030
7180
  { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7031
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
+ ] : [],
7032
7203
  ...skipState ? [
7033
7204
  tool(
7034
7205
  SKIP_TURN_TOOL,
@@ -7049,7 +7220,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
7049
7220
  ...askState ? [
7050
7221
  tool(
7051
7222
  ASK_TOOL,
7052
- "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\u20135 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.`,
7053
7224
  {
7054
7225
  targetUserId: z13.string().uuid().describe(
7055
7226
  "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
@@ -7069,8 +7240,8 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
7069
7240
  body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
7070
7241
  options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
7071
7242
  })
7072
- ).min(1).max(5).optional().describe(
7073
- "MULTI-question form: 1\u20135 questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or `question`/`headline`/`options`, not both."
7243
+ ).min(1).max(MAX_ASK_ITEMS).optional().describe(
7244
+ `MULTI-question form: 1\u2013${MAX_ASK_ITEMS} questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or \`question\`/\`headline\`/\`options\`, not both.`
7074
7245
  )
7075
7246
  },
7076
7247
  async (args) => {
@@ -7122,10 +7293,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
7122
7293
  ...subAgentCreate ? [
7123
7294
  tool(
7124
7295
  SUB_AGENT_TOOL,
7125
- "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.",
7126
7297
  {
7127
7298
  prompt: z13.string().min(1).max(65536).describe(
7128
- "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."
7129
7300
  ),
7130
7301
  agentId: z13.string().uuid().optional().describe(
7131
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."
@@ -7146,7 +7317,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
7146
7317
  type: "text",
7147
7318
  text: JSON.stringify({
7148
7319
  conversationId: result.conversationId,
7149
- 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."
7150
7321
  })
7151
7322
  }
7152
7323
  ]
@@ -7285,10 +7456,9 @@ function buildCompanionTurnRequest(params) {
7285
7456
  // CT289: the auto-memory escape hatch, when the operator set it.
7286
7457
  ...params.claudeCode ? { claudeCode: params.claudeCode } : {}
7287
7458
  },
7288
- // Host-injected: the companion-local summon server (for the subprocess adapters,
7289
- // under its own namespace).
7459
+ // Host-injected turn-control tools, under their own namespace.
7290
7460
  extra: {
7291
- mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.summonServer }
7461
+ mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.turnControlServer }
7292
7462
  }
7293
7463
  };
7294
7464
  }
@@ -7563,7 +7733,7 @@ var TurnCommitter = class {
7563
7733
  turnId: deps.turnId,
7564
7734
  seq,
7565
7735
  parentMessageId: deps.parentMessageId,
7566
- ...kind === "final" ? this.turnControlFields() : {}
7736
+ ...kind === "final" ? this.turnControlFields("final") : {}
7567
7737
  },
7568
7738
  deps.signal
7569
7739
  );
@@ -7658,27 +7828,45 @@ var TurnCommitter = class {
7658
7828
  return this.pump.finalEmitted;
7659
7829
  }
7660
7830
  // A closing textual reply and a wordless terminal marker carry the same
7661
- // per-turn control state. Keep this one projection so ask/wake/summon cannot
7662
- // silently diverge when the agent ends without words.
7663
- 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) {
7664
7837
  return {
7665
- ...this.summonField(),
7838
+ ...this.answersField(kind),
7839
+ ...this.sendField(),
7666
7840
  ...this.askField(),
7667
7841
  ...this.wakeField()
7668
7842
  };
7669
7843
  }
7670
- // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
7671
- // commit. A self-target is stripped here (mirror of the in-app self-strip); the
7672
- // server strips it again and resolves / ignores an unknown id.
7673
- summonField() {
7674
- const target = this.deps.summonState.agentId;
7675
- if (!target || target === this.deps.agentId) return {};
7676
- 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 };
7677
7865
  }
7678
7866
  // CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
7679
7867
  // The server validates the target (must be a workspace member/owner) and
7680
7868
  // creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
7681
- // independent of `summonField` — a turn could conceivably summon AND ask.
7869
+ // independent of `sendField` — a turn can send and ask independently.
7682
7870
  askField() {
7683
7871
  const { targetUserId, question, headline, options, questions } = this.deps.askState;
7684
7872
  if (!targetUserId) return {};
@@ -7708,7 +7896,7 @@ var TurnCommitter = class {
7708
7896
  // CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
7709
7897
  // The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
7710
7898
  // null = no wake this turn → attach nothing). The server computes `fire_at` and
7711
- // 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
7712
7900
  // conceivably ask AND arm a wake.
7713
7901
  //
7714
7902
  // CT990: the field now carries three states, and the third can't be spelled by
@@ -7945,15 +8133,9 @@ function describeErrorBody(body) {
7945
8133
  function describeSubAgentError(status2, body) {
7946
8134
  const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
7947
8135
  switch (code) {
7948
- case "callout_cap_exceeded":
7949
- 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.";
7950
- case "callout_depth_exceeded":
7951
- return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
7952
8136
  case "dispatch_agent_not_found":
7953
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.";
7954
- case "dispatch_return_requires_turn":
7955
- case "dispatch_return_requires_agent":
7956
- case "dispatch_return_requires_dispatch":
8138
+ case "dispatch_ask_requires_dispatch":
7957
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.`;
7958
8140
  default:
7959
8141
  return `sub_agent: the spawn failed (${code ?? `HTTP ${status2}`}).`;
@@ -8119,7 +8301,12 @@ var Dispatcher = class {
8119
8301
  }
8120
8302
  let hookEnv;
8121
8303
  const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
8122
- 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
+ } : {};
8123
8310
  if (prepareHook) {
8124
8311
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
8125
8312
  if (cached2 && !checkoutState(cached2.cwd).ok) {
@@ -8273,10 +8460,12 @@ ${reason}`,
8273
8460
  "dispatcher: active-run flag set failed terminally; proceeding"
8274
8461
  );
8275
8462
  }
8276
- const summonState = createSummonState();
8463
+ const sendState = createSendState();
8277
8464
  const skipState = createSkipState();
8278
8465
  const askState = createAskState();
8279
8466
  const wakeState = createWakeState();
8467
+ const replyState = createReplyState();
8468
+ let spawnedSubAgent = false;
8280
8469
  const subAgentCreate = async (args) => {
8281
8470
  const dispatchTarget = args.agentId ?? payload.agentId;
8282
8471
  const projectId = turnContext.conversation.projectId;
@@ -8289,7 +8478,7 @@ ${reason}`,
8289
8478
  ...args.title ? { title: args.title } : {},
8290
8479
  body: args.prompt,
8291
8480
  dispatch: dispatchTarget,
8292
- dispatchReturn: true
8481
+ dispatchAsk: true
8293
8482
  }
8294
8483
  );
8295
8484
  if (status2 >= 400) return { ok: false, error: describeSubAgentError(status2, body) };
@@ -8297,14 +8486,16 @@ ${reason}`,
8297
8486
  if (!conversationId) {
8298
8487
  return { ok: false, error: "sub_agent: the spawn returned no conversation id." };
8299
8488
  }
8489
+ spawnedSubAgent = true;
8300
8490
  return { ok: true, conversationId };
8301
8491
  };
8302
- const summonServer = createSummonMcpServer(
8303
- summonState,
8492
+ const turnControlServer = createTurnControlMcpServer(
8493
+ sendState,
8304
8494
  skipState,
8305
8495
  askState,
8306
8496
  subAgentCreate,
8307
- wakeState
8497
+ wakeState,
8498
+ replyState
8308
8499
  );
8309
8500
  const request = buildCompanionTurnRequest({
8310
8501
  turnContext,
@@ -8318,7 +8509,7 @@ ${reason}`,
8318
8509
  // CT1103: the prepare-hook env plus the authoritative host-access token.
8319
8510
  env: turnEnv,
8320
8511
  mcpServers: resolvedMcpServers,
8321
- summonServer,
8512
+ turnControlServer,
8322
8513
  // CT238: this turn's conversation, forwarded as the active-conversation
8323
8514
  // header so a cross-thread post/spawn stamps its origin.
8324
8515
  activeConversationId: payload.conversationId,
@@ -8567,15 +8758,20 @@ ${reason}`,
8567
8758
  signal: abortController.signal,
8568
8759
  log: turnLog,
8569
8760
  nextSeq,
8570
- // CT183: the committer reads this at commit to attach the in-thread summon
8571
- // onto the turn's `final` row.
8572
- summonState,
8761
+ // The committer reads this at commit to attach the addressed send onto the
8762
+ // turn's `final` row.
8763
+ sendState,
8573
8764
  // CT326: likewise the ask payload — attached to the `final` row so the
8574
8765
  // server creates the `asks` row atomically with the message it rides on.
8575
8766
  askState,
8576
8767
  // CT442: likewise the wake payload — attached to the `final` row so the
8577
8768
  // server arms the wake schedule atomically with the reply it rode on.
8578
- 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
8579
8775
  });
8580
8776
  const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
8581
8777
  let turnControlIntentFetched = false;
@@ -8616,7 +8812,11 @@ ${reason}`,
8616
8812
  wakeState.note = intent.wake.note;
8617
8813
  }
8618
8814
  }
8619
- 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;
8620
8820
  if (intent.skipped) {
8621
8821
  skipState.skipped = true;
8622
8822
  skipState.reason = intent.skipReason;
@@ -8754,10 +8954,10 @@ ${reason}`,
8754
8954
  turnId,
8755
8955
  seq: nextSeq(),
8756
8956
  parentMessageId: payload.messageId,
8757
- // ask, wake and summon must survive a wordless turn exactly as
8957
+ // ask, wake and send must survive a wordless turn exactly as
8758
8958
  // they survive a textual final; dropping one can strand a person
8759
8959
  // or the next actor with no visible failure.
8760
- ...committer.turnControlFields()
8960
+ ...committer.turnControlFields("silent")
8761
8961
  });
8762
8962
  silentMarkerEmitted = true;
8763
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,23 +6597,29 @@ 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}`;
6611
+ var MAX_ASK_ITEMS = 20;
6480
6612
  var SUB_AGENT_TOOL = "sub_agent";
6481
6613
  var SUB_AGENT_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${SUB_AGENT_TOOL}`;
6482
6614
  var WAKE_ME_TOOL = "wake_me";
6483
6615
  var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
6484
6616
  var CANCEL_WAKE_TOOL = "cancel_wake";
6485
6617
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
6486
- function createSummonState() {
6487
- return { agentId: null };
6618
+ function createReplyState() {
6619
+ return { answersMessageId: null };
6620
+ }
6621
+ function createSendState() {
6622
+ return { agentId: null, message: null };
6488
6623
  }
6489
6624
  function createSkipState() {
6490
6625
  return { skipped: false, reason: null };
@@ -6495,6 +6630,19 @@ function createAskState() {
6495
6630
  function createWakeState() {
6496
6631
  return { afterSeconds: null, at: null, note: null, cancelled: false };
6497
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
+ }
6498
6646
  function wakeCommitField(state) {
6499
6647
  if (state.cancelled) return { wake: { cancel: true } };
6500
6648
  const { afterSeconds, at, note } = state;
@@ -6507,27 +6655,50 @@ function wakeCommitField(state) {
6507
6655
  }
6508
6656
  };
6509
6657
  }
6510
- function createSummonMcpServer(summonState, skipState, askState, subAgentCreate, wakeState) {
6658
+ function createTurnControlMcpServer(sendState, skipState, askState, subAgentCreate, wakeState, replyState) {
6511
6659
  return createSdkMcpServer({
6512
6660
  name: COMPANION_LOCAL_MCP_SERVER,
6513
6661
  version: "0.0.0",
6514
6662
  tools: [
6515
6663
  tool(
6516
- SUMMON_AGENT_TOOL,
6517
- "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.",
6518
6666
  {
6519
6667
  agentId: z13.string().uuid().describe(
6520
- "The peer agent to summon \u2014 a workspace agent id, from your turn context's roster."
6521
- )
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.")
6522
6671
  },
6523
6672
  async (args) => {
6524
- summonState.agentId = args.agentId;
6673
+ sendState.agentId = args.agentId;
6674
+ sendState.message = args.message;
6525
6675
  return {
6526
- content: [{ type: "text", text: JSON.stringify({ summoned: args.agentId }) }]
6676
+ content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
6527
6677
  };
6528
6678
  },
6529
6679
  { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
6530
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
+ ] : [],
6531
6702
  ...skipState ? [
6532
6703
  tool(
6533
6704
  SKIP_TURN_TOOL,
@@ -6548,7 +6719,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6548
6719
  ...askState ? [
6549
6720
  tool(
6550
6721
  ASK_TOOL,
6551
- "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\u20135 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.`,
6552
6723
  {
6553
6724
  targetUserId: z13.string().uuid().describe(
6554
6725
  "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
@@ -6568,8 +6739,8 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6568
6739
  body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
6569
6740
  options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
6570
6741
  })
6571
- ).min(1).max(5).optional().describe(
6572
- "MULTI-question form: 1\u20135 questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or `question`/`headline`/`options`, not both."
6742
+ ).min(1).max(MAX_ASK_ITEMS).optional().describe(
6743
+ `MULTI-question form: 1\u2013${MAX_ASK_ITEMS} questions to ask at once, when a plan ends with several bounded decisions. Provide EITHER this or \`question\`/\`headline\`/\`options\`, not both.`
6573
6744
  )
6574
6745
  },
6575
6746
  async (args) => {
@@ -6621,10 +6792,10 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6621
6792
  ...subAgentCreate ? [
6622
6793
  tool(
6623
6794
  SUB_AGENT_TOOL,
6624
- "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.",
6625
6796
  {
6626
6797
  prompt: z13.string().min(1).max(65536).describe(
6627
- "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."
6628
6799
  ),
6629
6800
  agentId: z13.string().uuid().optional().describe(
6630
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."
@@ -6645,7 +6816,7 @@ function createSummonMcpServer(summonState, skipState, askState, subAgentCreate,
6645
6816
  type: "text",
6646
6817
  text: JSON.stringify({
6647
6818
  conversationId: result.conversationId,
6648
- 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."
6649
6820
  })
6650
6821
  }
6651
6822
  ]
@@ -6784,10 +6955,9 @@ function buildCompanionTurnRequest(params) {
6784
6955
  // CT289: the auto-memory escape hatch, when the operator set it.
6785
6956
  ...params.claudeCode ? { claudeCode: params.claudeCode } : {}
6786
6957
  },
6787
- // Host-injected: the companion-local summon server (for the subprocess adapters,
6788
- // under its own namespace).
6958
+ // Host-injected turn-control tools, under their own namespace.
6789
6959
  extra: {
6790
- mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.summonServer }
6960
+ mcpServers: { [COMPANION_LOCAL_MCP_SERVER]: params.turnControlServer }
6791
6961
  }
6792
6962
  };
6793
6963
  }
@@ -7062,7 +7232,7 @@ var TurnCommitter = class {
7062
7232
  turnId: deps.turnId,
7063
7233
  seq,
7064
7234
  parentMessageId: deps.parentMessageId,
7065
- ...kind === "final" ? this.turnControlFields() : {}
7235
+ ...kind === "final" ? this.turnControlFields("final") : {}
7066
7236
  },
7067
7237
  deps.signal
7068
7238
  );
@@ -7157,27 +7327,45 @@ var TurnCommitter = class {
7157
7327
  return this.pump.finalEmitted;
7158
7328
  }
7159
7329
  // A closing textual reply and a wordless terminal marker carry the same
7160
- // per-turn control state. Keep this one projection so ask/wake/summon cannot
7161
- // silently diverge when the agent ends without words.
7162
- 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) {
7163
7336
  return {
7164
- ...this.summonField(),
7337
+ ...this.answersField(kind),
7338
+ ...this.sendField(),
7165
7339
  ...this.askField(),
7166
7340
  ...this.wakeField()
7167
7341
  };
7168
7342
  }
7169
- // CT183: resolve the in-thread summon into the `dispatch` field for a `final`
7170
- // commit. A self-target is stripped here (mirror of the in-app self-strip); the
7171
- // server strips it again and resolves / ignores an unknown id.
7172
- summonField() {
7173
- const target = this.deps.summonState.agentId;
7174
- if (!target || target === this.deps.agentId) return {};
7175
- 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 };
7176
7364
  }
7177
7365
  // CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
7178
7366
  // The server validates the target (must be a workspace member/owner) and
7179
7367
  // creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
7180
- // independent of `summonField` — a turn could conceivably summon AND ask.
7368
+ // independent of `sendField` — a turn can send and ask independently.
7181
7369
  askField() {
7182
7370
  const { targetUserId, question, headline, options, questions } = this.deps.askState;
7183
7371
  if (!targetUserId) return {};
@@ -7207,7 +7395,7 @@ var TurnCommitter = class {
7207
7395
  // CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
7208
7396
  // The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
7209
7397
  // null = no wake this turn → attach nothing). The server computes `fire_at` and
7210
- // 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
7211
7399
  // conceivably ask AND arm a wake.
7212
7400
  //
7213
7401
  // CT990: the field now carries three states, and the third can't be spelled by
@@ -7444,15 +7632,9 @@ function describeErrorBody(body) {
7444
7632
  function describeSubAgentError(status, body) {
7445
7633
  const code = body && typeof body === "object" && "error" in body ? String(body.error) : void 0;
7446
7634
  switch (code) {
7447
- case "callout_cap_exceeded":
7448
- 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.";
7449
- case "callout_depth_exceeded":
7450
- return "sub_agent: this would nest sub-agents too deep (max 3 levels). Have the current worker report back rather than spawning another layer.";
7451
7635
  case "dispatch_agent_not_found":
7452
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.";
7453
- case "dispatch_return_requires_turn":
7454
- case "dispatch_return_requires_agent":
7455
- case "dispatch_return_requires_dispatch":
7637
+ case "dispatch_ask_requires_dispatch":
7456
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.`;
7457
7639
  default:
7458
7640
  return `sub_agent: the spawn failed (${code ?? `HTTP ${status}`}).`;
@@ -7618,7 +7800,12 @@ var Dispatcher = class {
7618
7800
  }
7619
7801
  let hookEnv;
7620
7802
  const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
7621
- 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
+ } : {};
7622
7809
  if (prepareHook) {
7623
7810
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
7624
7811
  if (cached2 && !checkoutState(cached2.cwd).ok) {
@@ -7772,10 +7959,12 @@ ${reason}`,
7772
7959
  "dispatcher: active-run flag set failed terminally; proceeding"
7773
7960
  );
7774
7961
  }
7775
- const summonState = createSummonState();
7962
+ const sendState = createSendState();
7776
7963
  const skipState = createSkipState();
7777
7964
  const askState = createAskState();
7778
7965
  const wakeState = createWakeState();
7966
+ const replyState = createReplyState();
7967
+ let spawnedSubAgent = false;
7779
7968
  const subAgentCreate = async (args) => {
7780
7969
  const dispatchTarget = args.agentId ?? payload.agentId;
7781
7970
  const projectId = turnContext.conversation.projectId;
@@ -7788,7 +7977,7 @@ ${reason}`,
7788
7977
  ...args.title ? { title: args.title } : {},
7789
7978
  body: args.prompt,
7790
7979
  dispatch: dispatchTarget,
7791
- dispatchReturn: true
7980
+ dispatchAsk: true
7792
7981
  }
7793
7982
  );
7794
7983
  if (status >= 400) return { ok: false, error: describeSubAgentError(status, body) };
@@ -7796,14 +7985,16 @@ ${reason}`,
7796
7985
  if (!conversationId) {
7797
7986
  return { ok: false, error: "sub_agent: the spawn returned no conversation id." };
7798
7987
  }
7988
+ spawnedSubAgent = true;
7799
7989
  return { ok: true, conversationId };
7800
7990
  };
7801
- const summonServer = createSummonMcpServer(
7802
- summonState,
7991
+ const turnControlServer = createTurnControlMcpServer(
7992
+ sendState,
7803
7993
  skipState,
7804
7994
  askState,
7805
7995
  subAgentCreate,
7806
- wakeState
7996
+ wakeState,
7997
+ replyState
7807
7998
  );
7808
7999
  const request = buildCompanionTurnRequest({
7809
8000
  turnContext,
@@ -7817,7 +8008,7 @@ ${reason}`,
7817
8008
  // CT1103: the prepare-hook env plus the authoritative host-access token.
7818
8009
  env: turnEnv,
7819
8010
  mcpServers: resolvedMcpServers,
7820
- summonServer,
8011
+ turnControlServer,
7821
8012
  // CT238: this turn's conversation, forwarded as the active-conversation
7822
8013
  // header so a cross-thread post/spawn stamps its origin.
7823
8014
  activeConversationId: payload.conversationId,
@@ -8066,15 +8257,20 @@ ${reason}`,
8066
8257
  signal: abortController.signal,
8067
8258
  log: turnLog,
8068
8259
  nextSeq,
8069
- // CT183: the committer reads this at commit to attach the in-thread summon
8070
- // onto the turn's `final` row.
8071
- summonState,
8260
+ // The committer reads this at commit to attach the addressed send onto the
8261
+ // turn's `final` row.
8262
+ sendState,
8072
8263
  // CT326: likewise the ask payload — attached to the `final` row so the
8073
8264
  // server creates the `asks` row atomically with the message it rides on.
8074
8265
  askState,
8075
8266
  // CT442: likewise the wake payload — attached to the `final` row so the
8076
8267
  // server arms the wake schedule atomically with the reply it rode on.
8077
- 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
8078
8274
  });
8079
8275
  const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
8080
8276
  let turnControlIntentFetched = false;
@@ -8115,7 +8311,11 @@ ${reason}`,
8115
8311
  wakeState.note = intent.wake.note;
8116
8312
  }
8117
8313
  }
8118
- 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;
8119
8319
  if (intent.skipped) {
8120
8320
  skipState.skipped = true;
8121
8321
  skipState.reason = intent.skipReason;
@@ -8253,10 +8453,10 @@ ${reason}`,
8253
8453
  turnId,
8254
8454
  seq: nextSeq(),
8255
8455
  parentMessageId: payload.messageId,
8256
- // ask, wake and summon must survive a wordless turn exactly as
8456
+ // ask, wake and send must survive a wordless turn exactly as
8257
8457
  // they survive a textual final; dropping one can strand a person
8258
8458
  // or the next actor with no visible failure.
8259
- ...committer.turnControlFields()
8459
+ ...committer.turnControlFields("silent")
8260
8460
  });
8261
8461
  silentMarkerEmitted = true;
8262
8462
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.53",
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",