@cabane/companion 0.6.80 → 0.6.83

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 +233 -380
  2. package/dist/runtime.js +233 -380
  3. package/package.json +1 -1
package/dist/runtime.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/runtime.ts
2
- import { randomUUID as randomUUID3 } from "crypto";
2
+ import { randomUUID as randomUUID4 } from "crypto";
3
3
 
4
4
  // src/config.ts
5
5
  import {
@@ -1699,7 +1699,7 @@ async function verifyRuntime(state, requestImpl = controlRequest) {
1699
1699
  }
1700
1700
 
1701
1701
  // src/supervisor.ts
1702
- import { randomUUID as randomUUID2 } from "crypto";
1702
+ import { randomUUID as randomUUID3 } from "crypto";
1703
1703
 
1704
1704
  // src/api.ts
1705
1705
  var RETRY_BACKOFF_MS = [250, 750];
@@ -1879,14 +1879,14 @@ var CabaneApi = class {
1879
1879
  const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
1880
1880
  return this.request("GET", `/api/agent/turn-context?${q}`);
1881
1881
  }
1882
- // CT714: read a turn's recorded turn-control intent (ask/wake/send/skip). An
1883
- // EXTERNAL adapter (Codex / opencode) records its turn-control verbs into
1884
- // `turn_intents` server-side (the URL MCP surface) rather than the dispatcher's
1885
- // in-memory closures, so the dispatcher fetches this once at settle — by
1886
- // `turnId` — and populates those closures, letting the unchanged settle path
1887
- // materialize the effects identically to claude-code. Agent-PAT authed +
1888
- // self-scoped (`:agentId` must match the PAT's agent). A turn that recorded no
1889
- // control verb returns all-empty fields.
1882
+ // CT714: read a turn's recorded turn-control intent. An EXTERNAL adapter
1883
+ // (Codex / opencode) records `reply_to` / `skip_turn` into `turn_intents`
1884
+ // server-side (the URL MCP surface) rather than the dispatcher's in-memory
1885
+ // closures, so the dispatcher fetches this once at settle — by `turnId` —
1886
+ // and populates those closures, letting the unchanged settle path close the
1887
+ // turn identically to claude-code. CT1354: those two verbs are all it
1888
+ // carries now; the outgoing acts commit at the call (`turnAct`). Agent-PAT
1889
+ // authed + self-scoped (`:agentId` must match the PAT's agent).
1890
1890
  getTurnIntent(workspaceId, conversationId, agentId, turnId) {
1891
1891
  const q = `turnId=${encodeURIComponent(turnId)}`;
1892
1892
  return this.request(
@@ -1894,6 +1894,27 @@ var CabaneApi = class {
1894
1894
  `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-intent?${q}`
1895
1895
  );
1896
1896
  }
1897
+ // CT1354: COMMIT ONE OUTGOING ACT of a running turn — a `send`, an `ask`, a
1898
+ // `wake_me`, a `cancel_wake` — the moment the tool is called. The server
1899
+ // runs `performTurnAct` inside the turn's write fence: the row is in the
1900
+ // ledger when this resolves, and a 200 `ok: false` is the server's REFUSAL
1901
+ // (an unknown target, a non-member, a wake outside its guardrails) for the
1902
+ // tool to hand back as its error. 409 means the turn already settled.
1903
+ //
1904
+ // Bounded retry, on purpose: the act is a durable write whose loss strands a
1905
+ // person or a peer, and `act.callId` — minted once per tool invocation by the
1906
+ // caller — is what makes the retry safe: the server keys on `(turnId,
1907
+ // callId)`, so an attempt that landed before the response was lost is
1908
+ // replayed as the same row, never a second message. The caller's `signal`
1909
+ // is the per-call deadline (the tool must answer the model, not hang it).
1910
+ turnAct(workspaceId, turnId, act, signal) {
1911
+ return this.request(
1912
+ "POST",
1913
+ `/api/workspaces/${workspaceId}/turns/${turnId}/acts`,
1914
+ act,
1915
+ signal ? { retry: true, signal } : { retry: true }
1916
+ );
1917
+ }
1897
1918
  // CT1292: RENEW THE TURN'S LEASE — ask the server whether this turn is still
1898
1919
  // running. The dispatcher calls this on a cadence for the life of the SDK loop,
1899
1920
  // and out of cadence the moment a commit is refused, so a loop whose turn was
@@ -7041,10 +7062,11 @@ import { existsSync as existsSync11, readdirSync as readdirSync2, statSync } fro
7041
7062
  import { join as join14 } from "path";
7042
7063
 
7043
7064
  // src/turn-execution.ts
7044
- import { createHash as createHash2, randomUUID } from "crypto";
7065
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
7045
7066
  import { existsSync as existsSync10 } from "fs";
7046
7067
 
7047
7068
  // src/turn-control-tools.ts
7069
+ import { randomUUID } from "crypto";
7048
7070
  import { z as z13 } from "zod";
7049
7071
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
7050
7072
  var SEND_TOOL = "send";
@@ -7053,6 +7075,9 @@ var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
7053
7075
  var SKIP_TURN_TOOL = "skip_turn";
7054
7076
  var ASK_TOOL = "ask";
7055
7077
  var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
7078
+ var WITHDRAW_ASK_TOOL = "withdraw_ask";
7079
+ var WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
7080
+ var MAX_WITHDRAW_REASON = 400;
7056
7081
  var REPLY_TO_TOOL = "reply_to";
7057
7082
  var REPLY_TO_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${REPLY_TO_TOOL}`;
7058
7083
  var MAX_ASK_ITEMS = 20;
@@ -7060,96 +7085,83 @@ var WAKE_ME_TOOL = "wake_me";
7060
7085
  var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
7061
7086
  var CANCEL_WAKE_TOOL = "cancel_wake";
7062
7087
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
7088
+ var TURN_ACT_TIMEOUT_MS = 45e3;
7063
7089
  function createReplyState() {
7064
- return { answersMessageId: null, order: null };
7065
- }
7066
- function createSendState() {
7067
- return { agentId: null, message: null, order: null };
7068
- }
7069
- function createTurnControlOrder() {
7070
- let calls = 0;
7071
- return {
7072
- next: () => {
7073
- calls += 1;
7074
- return calls;
7075
- }
7076
- };
7090
+ return { answersMessageId: null };
7077
7091
  }
7078
7092
  function createSkipState() {
7079
7093
  return { skipped: false, reason: null };
7080
7094
  }
7081
- function createAskState() {
7082
- return {
7083
- targetUserId: null,
7084
- question: null,
7085
- headline: null,
7086
- options: null,
7087
- questions: null,
7088
- order: null
7089
- };
7090
- }
7091
- function createWakeState() {
7092
- return { afterSeconds: null, at: null, note: null, cancelled: false };
7093
- }
7094
7095
  function resolveDeclaredReplyField(input) {
7095
7096
  const explicit = input.replyState.answersMessageId;
7096
7097
  if (explicit) return { answersMessageId: explicit };
7097
7098
  if (input.kind !== "final") return {};
7098
7099
  if (!input.owedReplyMessageId) return {};
7099
- const outwardSend = Boolean(
7100
- input.sendState.agentId && input.sendState.message && input.sendState.agentId !== input.agentId
7101
- );
7102
- const askRaised = Boolean(input.askState.targetUserId);
7103
- const wakeArmed = input.wakeState.afterSeconds !== null || input.wakeState.at !== null;
7104
- if (outwardSend || askRaised || wakeArmed) return {};
7105
7100
  return { answersMessageId: input.owedReplyMessageId, answersAutoDeclared: true };
7106
7101
  }
7107
- function wakeCommitField(state) {
7108
- if (state.cancelled) return { wake: { cancel: true } };
7109
- const { afterSeconds, at, note } = state;
7110
- if (!note || afterSeconds === null && at === null) return {};
7111
- return {
7112
- wake: {
7113
- ...afterSeconds !== null ? { afterSeconds } : {},
7114
- ...at !== null ? { at } : {},
7115
- note
7102
+ function toolError(text) {
7103
+ return { isError: true, content: [{ type: "text", text }] };
7104
+ }
7105
+ async function performAct(acts, act) {
7106
+ let result;
7107
+ try {
7108
+ result = await acts.api.turnAct(
7109
+ acts.workspaceId,
7110
+ acts.turnId,
7111
+ act,
7112
+ AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
7113
+ );
7114
+ } catch (err) {
7115
+ if (err instanceof ApiError) {
7116
+ if (err.status === 409) {
7117
+ return toolError(
7118
+ "No active turn: this turn has already settled, so the act was not performed."
7119
+ );
7120
+ }
7121
+ return toolError(`The act was refused (${err.status}): ${err.message}`);
7116
7122
  }
7117
- };
7123
+ const message = err instanceof Error ? err.message : String(err);
7124
+ return toolError(
7125
+ `Cabane could not be reached (${message}), so it is UNKNOWN whether this act was recorded \u2014 it may have committed with only the response lost. Do NOT simply repeat the call: a repeat is a second, separate act, not a retry. Carry on, and say in your reply that you could not confirm it.`
7126
+ );
7127
+ }
7128
+ if (!result.ok) return toolError(JSON.stringify(result));
7129
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
7118
7130
  }
7119
- function createTurnControlMcpServer(sendState, skipState, askState, wakeState, replyState, controlOrder) {
7131
+ function createTurnControlMcpServer(acts, skipState, replyState) {
7132
+ const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
7133
+ const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
7134
+ const RECORDER_TOOL = { readOnlyHint: true, openWorldHint: false };
7120
7135
  return createSdkMcpServer({
7121
7136
  name: COMPANION_LOCAL_MCP_SERVER,
7122
7137
  version: "0.0.0",
7123
7138
  tools: [
7124
7139
  tool(
7125
7140
  SEND_TOOL,
7126
- "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 `conversations.create` / `conversations.post` with their `dispatch` field instead. Never use send to hand an answer back to the requester \u2014 declare that with reply_to.",
7141
+ "Send one addressed message to another agent in THIS conversation. Pass the peer's `agentId` and the complete `message` they should act on. The message is WRITTEN the moment you call this \u2014 durable, in the conversation's queue, in the order you called \u2014 but it is DELIVERED only after your own turn ends: the peer is not running yet and cannot answer you inside this turn. Each call is one message; two calls are two messages. Writing `@handle` in prose dispatches nobody. An unknown, deactivated, or self target is refused here, in the result. A handoff to a DIFFERENT conversation is `conversations.create` / `conversations.post` with their `dispatch` field instead. Never use send to hand an answer back to the requester \u2014 declare that with reply_to.",
7127
7142
  {
7128
7143
  agentId: z13.string().uuid().describe(
7129
7144
  "The peer agent to address \u2014 a workspace agent id, from your turn context's roster."
7130
7145
  ),
7131
7146
  message: z13.string().min(1).max(65536).describe("The complete new request the peer should receive and act on.")
7132
7147
  },
7133
- async (args) => {
7134
- sendState.agentId = args.agentId;
7135
- sendState.message = args.message;
7136
- sendState.order = controlOrder?.next() ?? null;
7137
- return {
7138
- content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
7139
- };
7140
- },
7141
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7148
+ async (args) => performAct(acts, {
7149
+ kind: "send",
7150
+ callId: randomUUID(),
7151
+ agentId: args.agentId,
7152
+ message: args.message
7153
+ }),
7154
+ { annotations: ACT_TOOL, alwaysLoad: true }
7142
7155
  ),
7143
7156
  ...replyState ? [
7144
7157
  tool(
7145
7158
  REPLY_TO_TOOL,
7146
- "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.",
7159
+ "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 \u2014 a later call replaces the earlier declaration.",
7147
7160
  {
7148
7161
  messageId: z13.string().uuid().describe("The owed ask message id from this turn context.")
7149
7162
  },
7150
7163
  async (args) => {
7151
7164
  replyState.answersMessageId = args.messageId;
7152
- replyState.order = controlOrder?.next() ?? null;
7153
7165
  return {
7154
7166
  content: [
7155
7167
  {
@@ -7159,7 +7171,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, wakeState, r
7159
7171
  ]
7160
7172
  };
7161
7173
  },
7162
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7174
+ { annotations: RECORDER_TOOL, alwaysLoad: true }
7163
7175
  )
7164
7176
  ] : [],
7165
7177
  ...skipState ? [
@@ -7176,162 +7188,146 @@ function createTurnControlMcpServer(sendState, skipState, askState, wakeState, r
7176
7188
  content: [{ type: "text", text: JSON.stringify({ skipped: true }) }]
7177
7189
  };
7178
7190
  },
7179
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7191
+ { annotations: RECORDER_TOOL, alwaysLoad: true }
7180
7192
  )
7181
7193
  ] : [],
7182
- ...askState ? [
7183
- tool(
7184
- ASK_TOOL,
7185
- `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). The human sends ONE response: supplied answers are recorded, omissions become terminally Unanswered; later answers require a fresh ask. Ask only when blocked \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.`,
7186
- {
7187
- targetUserId: z13.string().uuid().describe(
7188
- "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
7189
- ),
7190
- question: z13.string().min(1).max(400).optional().describe(
7191
- "SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (capped, inline markdown only). Provide EITHER this or `questions`, not both. Put the crisp one-sentence question in `headline`."
7192
- ),
7193
- headline: z13.string().min(1).max(120).optional().describe(
7194
- 'SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in `?` ("Do we go to prod?"). What the human reads first in the inbox and the chip \u2014 one scannable question, no elaboration (that goes in `question`). Strongly encouraged.'
7195
- ),
7196
- options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
7197
- questions: z13.array(
7198
- z13.object({
7199
- headline: z13.string().min(1).max(120).describe(
7200
- 'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
7201
- ),
7202
- body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
7203
- options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
7204
- })
7205
- ).min(1).max(MAX_ASK_ITEMS).optional().describe(
7206
- `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.`
7207
- )
7208
- },
7209
- async (args) => {
7210
- const hasSingle = args.question !== void 0;
7211
- const hasArray = args.questions !== void 0 && args.questions.length > 0;
7212
- if (hasSingle && hasArray) {
7213
- return {
7214
- isError: true,
7215
- content: [
7216
- {
7217
- type: "text",
7218
- text: "Provide either `question` (single) or `questions` (array), not both."
7219
- }
7220
- ]
7221
- };
7222
- }
7223
- if (!hasSingle && !hasArray) {
7224
- return {
7225
- isError: true,
7226
- content: [
7227
- {
7228
- type: "text",
7229
- text: "Provide `question` (single) or `questions` (array)."
7230
- }
7231
- ]
7232
- };
7233
- }
7234
- askState.targetUserId = args.targetUserId;
7235
- askState.order = controlOrder?.next() ?? null;
7236
- if (hasArray) {
7237
- askState.questions = args.questions;
7238
- askState.question = null;
7239
- askState.headline = null;
7240
- askState.options = null;
7241
- } else {
7242
- askState.question = args.question;
7243
- askState.headline = args.headline ?? null;
7244
- askState.options = args.options ?? null;
7245
- askState.questions = null;
7246
- }
7247
- return {
7248
- content: [
7249
- { type: "text", text: JSON.stringify({ asked: args.targetUserId }) }
7250
- ]
7251
- };
7252
- },
7253
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7254
- )
7255
- ] : [],
7256
- ...wakeState ? [
7257
- tool(
7258
- WAKE_ME_TOOL,
7259
- "Wake yourself later \u2014 end this turn now and be re-dispatched at a time you pick, with a note you write to yourself. Use it for \"wait until X\": when the thing you need hasn't happened yet (a PR isn't merged, a human hasn't answered), arm a wake, end your turn, and you're woken later to CHECK \u2014 read the workspace, and either act or re-arm. Ground the delay before you arm it. Almost every wake is short \u2014 seconds to a couple of hours \u2014 waiting on a condition you can name: a session limit resetting, a PR merging. Reach past a few hours only when (a) a human asked for that timing, or (b) the wait is pinned to a real external event you can name \u2014 a report that only runs Mondays, a known reset time. A speculative far-future check-in you invented yourself is the one thing not to arm: if no one asked and you can't name both what clears the wait and why it takes that long, don't arm it \u2014 finish now, or raise an `ask`. Pass EXACTLY ONE of `afterSeconds` (a relative delay \u2014 `300` for five minutes) or `at` (an absolute ISO-8601 timestamp WITH a zone, e.g. `2026-07-16T09:00:00-07:00` \u2014 YOU compute it from a phrase like \"tomorrow morning\"; the system never parses natural-language time). `note` is a message to your future self \u2014 it becomes the body of the wake message that re-dispatches you, so write the condition to re-check (\"check whether CT441 merged yet\"). The wake is armed when your turn SETTLES, not now, so the delay counts from the turn ending; one wake per turn (last call wins). This is the sanctioned way to schedule your own continuation \u2014 the ONLY one; never reach for a host cron/scheduler. Guardrails: at least 60s out, at most 14 days; widen the interval as a loop ages (5m \u2192 15m \u2192 1h\u2026) rather than hammering; after many consecutive re-arms with no other activity you'll be steered to raise an `ask` to the human instead. If a wake can't be armed you're re-dispatched with a note explaining why \u2014 never a silent drop.",
7260
- {
7261
- afterSeconds: z13.number().int().positive().optional().describe(
7262
- "Relative delay in seconds from when this turn ends (e.g. 300 = five minutes). Provide EITHER this or `at`, not both. Floor 60s, horizon 14 days \u2014 enforced server-side."
7263
- ),
7264
- at: z13.string().datetime({ offset: true }).optional().describe(
7265
- "Absolute ISO-8601 timestamp WITH a zone (`Z` or `\xB1HH:MM`), e.g. `2026-07-16T09:00:00-07:00`. YOU compute it from a natural-language phrase using the current datetime in your turn context. Provide EITHER this or `afterSeconds`, not both."
7266
- ),
7267
- note: z13.string().min(1).max(2e3).describe(
7268
- 'A note to your future self \u2014 becomes the body of the wake message that re-dispatches you. Write the condition to re-check ("check whether the PR merged").'
7194
+ tool(
7195
+ ASK_TOOL,
7196
+ `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 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; inline markdown only, no 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. 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 CONTEXT, the ask carries the QUESTION(S). It is written and delivered THE MOMENT you call this \u2014 the person sees it in their inbox while your turn is still running \u2014 so call it once you know the question; a second call is a second question, not an edit. The human sends ONE response: supplied answers are recorded, omissions become terminally Unanswered. Ask only when blocked \u2014 never ceremonially. \`replaces\` withdraws a stale ask of yours (or anyone's) in this conversation in the same write, with the reason shown to the person. Targets a human only; to hand work to another AGENT use send/dispatch instead.`,
7197
+ {
7198
+ targetUserId: z13.string().uuid().describe(
7199
+ "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
7200
+ ),
7201
+ question: z13.string().min(1).max(400).optional().describe(
7202
+ "SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (capped, inline markdown only). Provide EITHER this or `questions`, not both. Put the crisp one-sentence question in `headline`."
7203
+ ),
7204
+ headline: z13.string().min(1).max(120).optional().describe(
7205
+ 'SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in `?` ("Do we go to prod?"). What the human reads first in the inbox and the chip \u2014 one scannable question, no elaboration (that goes in `question`). Strongly encouraged.'
7206
+ ),
7207
+ options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
7208
+ questions: z13.array(
7209
+ z13.object({
7210
+ headline: z13.string().min(1).max(120).describe(
7211
+ 'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
7212
+ ),
7213
+ body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
7214
+ options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
7215
+ })
7216
+ ).min(1).max(MAX_ASK_ITEMS).optional().describe(
7217
+ `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.`
7218
+ ),
7219
+ replaces: z13.object({
7220
+ askId: z13.string().uuid().describe("The id of the open ask to withdraw."),
7221
+ reason: z13.string().min(1).max(MAX_WITHDRAW_REASON).describe(
7222
+ "Why the old question is no longer the question \u2014 shown to the person on their Done row."
7269
7223
  )
7270
- },
7271
- async (args) => {
7272
- const hasAfter = args.afterSeconds !== void 0;
7273
- const hasAt = args.at !== void 0;
7274
- if (hasAfter && hasAt) {
7275
- return {
7276
- isError: true,
7277
- content: [
7278
- {
7279
- type: "text",
7280
- text: "Provide either `afterSeconds` (relative) or `at` (absolute), not both."
7281
- }
7282
- ]
7283
- };
7224
+ }).optional().describe(
7225
+ "Withdraw a stale ask in this conversation as part of raising this one. Both land in a single write, so the person goes from the old question to the new one and never sees neither or both."
7226
+ )
7227
+ },
7228
+ async (args) => {
7229
+ const hasSingle = args.question !== void 0;
7230
+ const hasArray = args.questions !== void 0 && args.questions.length > 0;
7231
+ if (hasSingle && hasArray) {
7232
+ return toolError(
7233
+ "Provide either `question` (single) or `questions` (array), not both."
7234
+ );
7235
+ }
7236
+ if (!hasSingle && !hasArray) {
7237
+ return toolError("Provide `question` (single) or `questions` (array).");
7238
+ }
7239
+ return performAct(acts, {
7240
+ kind: "ask",
7241
+ callId: randomUUID(),
7242
+ // CT1350: the swap rides the ask, so the server commits both halves
7243
+ // in one transaction — the person never sees neither or both.
7244
+ ...args.replaces ? { replaces: args.replaces } : {},
7245
+ ask: hasArray ? {
7246
+ targetUserId: args.targetUserId,
7247
+ questions: args.questions.map((q) => ({
7248
+ headline: q.headline,
7249
+ ...q.body ? { body: q.body } : {},
7250
+ ...q.options && q.options.length > 0 ? { options: q.options } : {}
7251
+ }))
7252
+ } : {
7253
+ targetUserId: args.targetUserId,
7254
+ question: args.question,
7255
+ ...args.headline ? { headline: args.headline } : {},
7256
+ ...args.options && args.options.length > 0 ? { options: args.options } : {}
7284
7257
  }
7285
- if (!hasAfter && !hasAt) {
7286
- return {
7287
- isError: true,
7288
- content: [{ type: "text", text: "Provide `afterSeconds` or `at`." }]
7289
- };
7258
+ });
7259
+ },
7260
+ { annotations: ACT_TOOL, alwaysLoad: true }
7261
+ ),
7262
+ tool(
7263
+ WAKE_ME_TOOL,
7264
+ "Wake yourself later \u2014 end this turn now and be re-dispatched at a time you pick, with a note you write to yourself. Use it for \"wait until X\": when the thing you need hasn't happened yet (a PR isn't merged, a human hasn't answered), arm a wake, end your turn, and you're woken later to CHECK \u2014 read the workspace, and either act or re-arm. Ground the delay before you arm it. Almost every wake is short \u2014 seconds to a couple of hours \u2014 waiting on a condition you can name: a session limit resetting, a PR merging. Reach past a few hours only when (a) a human asked for that timing, or (b) the wait is pinned to a real external event you can name \u2014 a report that only runs Mondays, a known reset time. A speculative far-future check-in you invented yourself is the one thing not to arm: if no one asked and you can't name both what clears the wait and why it takes that long, don't arm it \u2014 finish now, or raise an `ask`. Pass EXACTLY ONE of `afterSeconds` (a relative delay \u2014 `300` for five minutes) or `at` (an absolute ISO-8601 timestamp WITH a zone, e.g. `2026-07-16T09:00:00-07:00` \u2014 YOU compute it; the system never parses natural-language time). `note` is a message to your future self \u2014 it becomes the body of the wake message that re-dispatches you, so write the condition to re-check (\"check whether CT441 merged yet\"). The wake arms NOW, when you call this, and the delay counts from this call; a later `wake_me` in the same turn replaces it, and `cancel_wake` stands it down. A wake that comes due while your turn is still running waits and is delivered after your reply. This is the ONLY sanctioned way to schedule your own continuation; never reach for a host cron/scheduler. Guardrails: at least 60s out, at most 14 days \u2014 a wake that can't be armed returns an error here, in this call, so you can recompute; widen the interval as a loop ages (5m \u2192 15m \u2192 1h\u2026) rather than hammering; after many consecutive re-arms with no other activity you'll be steered to raise an `ask` to the human instead.",
7265
+ {
7266
+ afterSeconds: z13.number().int().positive().optional().describe(
7267
+ "Relative delay in seconds from NOW \u2014 this call (e.g. 300 = five minutes). Provide EITHER this or `at`, not both. Floor 60s, horizon 14 days \u2014 enforced server-side."
7268
+ ),
7269
+ at: z13.string().datetime({ offset: true }).optional().describe(
7270
+ "Absolute ISO-8601 timestamp WITH a zone (`Z` or `\xB1HH:MM`), e.g. `2026-07-16T09:00:00-07:00`. YOU compute it from a natural-language phrase using the current datetime in your turn context. Provide EITHER this or `afterSeconds`, not both."
7271
+ ),
7272
+ note: z13.string().min(1).max(2e3).describe(
7273
+ 'A note to your future self \u2014 becomes the body of the wake message that re-dispatches you. Write the condition to re-check ("check whether the PR merged").'
7274
+ )
7275
+ },
7276
+ async (args) => {
7277
+ const hasAfter = args.afterSeconds !== void 0;
7278
+ const hasAt = args.at !== void 0;
7279
+ if (hasAfter && hasAt) {
7280
+ return toolError(
7281
+ "Provide either `afterSeconds` (relative) or `at` (absolute), not both."
7282
+ );
7283
+ }
7284
+ if (!hasAfter && !hasAt) {
7285
+ return toolError("Provide `afterSeconds` or `at`.");
7286
+ }
7287
+ return performAct(acts, {
7288
+ kind: "wake",
7289
+ callId: randomUUID(),
7290
+ wake: {
7291
+ ...hasAfter ? { afterSeconds: args.afterSeconds } : {},
7292
+ ...hasAt ? { at: args.at } : {},
7293
+ note: args.note
7290
7294
  }
7291
- wakeState.afterSeconds = args.afterSeconds ?? null;
7292
- wakeState.at = args.at ?? null;
7293
- wakeState.note = args.note;
7294
- wakeState.cancelled = false;
7295
- return {
7296
- content: [
7297
- {
7298
- type: "text",
7299
- text: JSON.stringify({
7300
- armed: hasAt ? { at: args.at } : { afterSeconds: args.afterSeconds },
7301
- note: "Recorded. It arms when this turn ends \u2014 end your turn now; you'll be woken with this note."
7302
- })
7303
- }
7304
- ]
7305
- };
7306
- },
7307
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7308
- ),
7309
- // CT990: the inverse. No arguments — it can only ever stand down the
7310
- // caller's own wake in the conversation it's holding a turn in.
7311
- tool(
7312
- CANCEL_WAKE_TOOL,
7313
- 'Stand down your own wake \u2014 the inverse of `wake_me`. No arguments: it means "when this turn settles, leave no wake armed for me in this conversation." Reach for it when the thing you armed a wake to check has already happened, or the work it was watching is over \u2014 an armed wake you no longer need fires into a turn with nothing to do, and a supervision loop with no off switch is one you can only end by leaving it running. Whether you have one armed is printed in your turn context ("Wake armed: \u2026"); calling this with nothing armed is a clean no-op, no error. It writes the same per-turn slot as `wake_me`, so the two are last-call-wins against each other: `cancel_wake` then `wake_me` leaves the NEW wake armed, `wake_me` then `cancel_wake` leaves nothing armed. Like `wake_me` it takes effect when the turn SETTLES, not now. It reaches only your own wake in this conversation \u2014 never a peer\'s, and never a recurring schedule (those are `schedules.*`).',
7314
- {},
7315
- async () => {
7316
- wakeState.cancelled = true;
7317
- wakeState.afterSeconds = null;
7318
- wakeState.at = null;
7319
- wakeState.note = null;
7320
- return {
7321
- content: [
7322
- {
7323
- type: "text",
7324
- text: JSON.stringify({
7325
- cancelled: true,
7326
- note: "Recorded. Any wake you have armed in this conversation is stood down when this turn ends. If you had none, nothing happens."
7327
- })
7328
- }
7329
- ]
7330
- };
7331
- },
7332
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7333
- )
7334
- ] : []
7295
+ });
7296
+ },
7297
+ { annotations: ACT_TOOL, alwaysLoad: true }
7298
+ ),
7299
+ // CT1350: the standalone withdrawal. Not annotated destructive — an ask
7300
+ // is a question, not data, and a harness that gated this on a live
7301
+ // operator would hang a turn nobody is watching (the `cancel_wake`
7302
+ // reasoning). Not idempotent either: a second call with the same id is
7303
+ // refused with `ask_not_open`, which is information, not a no-op.
7304
+ tool(
7305
+ WITHDRAW_ASK_TOOL,
7306
+ "Withdraw an open ask \u2014 yours or anyone's in this conversation \u2014 because the question is moot, was wrong, or has been overtaken. `reason` is required and is shown to the person and, if it wasn't your ask, to the agent who asked it. If there is a current question to put in its place, don't call this \u2014 call `ask` with `replaces` so the two land as one and the person never sees neither or both. Never withdraw to hurry an answer; to act on a deadline, arm a `wake_me` and decide then.",
7307
+ {
7308
+ askId: z13.string().uuid().describe(
7309
+ "The open ask to withdraw. Ask ids ride the conversation's message rows (`ask: { id, status, items }`), and your turn context lists the ones you have open."
7310
+ ),
7311
+ reason: z13.string().min(1).max(MAX_WITHDRAW_REASON).describe(
7312
+ 'Why this question is no longer the question ("merged via GitHub #1551"). Required, and shown to the person on their Done row \u2014 a bare "withdrawn" tells them nothing.'
7313
+ )
7314
+ },
7315
+ async (args) => performAct(acts, {
7316
+ kind: "withdraw_ask",
7317
+ callId: randomUUID(),
7318
+ withdraw: { askId: args.askId, reason: args.reason }
7319
+ }),
7320
+ { annotations: ACT_TOOL, alwaysLoad: true }
7321
+ ),
7322
+ // CT990: the inverse. No arguments — it can only ever stand down the
7323
+ // caller's own wake in the conversation it's holding a turn in.
7324
+ tool(
7325
+ CANCEL_WAKE_TOOL,
7326
+ 'Stand down your own wake \u2014 the inverse of `wake_me`. No arguments: it cancels the wake you have armed in this conversation, NOW, when you call it. Reach for it when the thing you armed a wake to check has already happened, or the work it was watching is over \u2014 an armed wake you no longer need fires into a turn with nothing to do, and a supervision loop with no off switch is one you can only end by leaving it running. Whether you have one armed is printed in your turn context ("Wake armed: \u2026"); calling this with nothing armed is a clean no-op, no error. Each call acts as it arrives: `cancel_wake` then `wake_me` leaves the NEW wake armed; `wake_me` then `cancel_wake` leaves nothing armed. It reaches only your own wake in this conversation \u2014 never a peer\'s, and never a recurring schedule (those are `schedules.*`).',
7327
+ {},
7328
+ async () => performAct(acts, { kind: "cancel_wake", callId: randomUUID() }),
7329
+ { annotations: CANCEL_TOOL, alwaysLoad: true }
7330
+ )
7335
7331
  ]
7336
7332
  });
7337
7333
  }
@@ -7758,112 +7754,25 @@ var TurnCommitter = class {
7758
7754
  return this.pump.finalEmitted;
7759
7755
  }
7760
7756
  // A closing textual reply and a wordless terminal marker carry the same
7761
- // per-turn control state. Keep this one projection so ask/wake/send cannot
7762
- // silently diverge when the agent ends without words. The KIND matters to
7763
- // exactly one field: the auto-declared reply binds only a textual `final` —
7764
- // a wordless turn has no answer to bind, and the server's mute-settle
7765
- // notice speaks for it.
7757
+ // declared-reply projection. The KIND matters: the auto-declared reply binds
7758
+ // only a textual `final` a wordless turn has no answer to bind, and the
7759
+ // server's mute-settle notice speaks for it.
7766
7760
  turnControlFields(kind) {
7767
- const fields = {
7768
- ...this.answersField(kind),
7769
- ...this.sendField(),
7770
- ...this.askField(),
7771
- ...this.wakeField()
7772
- };
7773
- return { ...fields, ...this.orderField(fields) };
7774
- }
7775
- // CT1281: the order the control tools were CALLED, for the addressed rows the
7776
- // server writes from this one commit. It reports a position only for an intent
7777
- // that actually made it into the commit — a self-send the committer stripped
7778
- // contributes no row and so has no place in the line. An intent with no
7779
- // recorded call (the runtime's auto-declared reply) is deliberately absent, and
7780
- // the server sorts it after every recorded one — it binds the turn's closing
7781
- // words.
7782
- //
7783
- // CT1347: the ask has a position too. Its carrier used to queue last however
7784
- // early the tool fired, because a landed open ask held the conversation and an
7785
- // ask ahead of its own turn's send would have stranded that send behind a
7786
- // person's response time. Nothing holds now, so the ask is an addressed intent
7787
- // like the other two and queues where it was called.
7788
- orderField(fields) {
7789
- const order = {};
7790
- const replyOrder = this.deps.replyState.order;
7791
- if (fields.answersMessageId !== void 0 && replyOrder !== null) order.reply = replyOrder;
7792
- if (fields.dispatch !== void 0 && this.deps.sendState.order !== null) {
7793
- order.send = this.deps.sendState.order;
7794
- }
7795
- if (fields.ask !== void 0 && this.deps.askState.order !== null) {
7796
- order.ask = this.deps.askState.order;
7797
- }
7798
- return Object.keys(order).length > 0 ? { turnControlOrder: order } : {};
7761
+ return this.answersField(kind);
7799
7762
  }
7800
7763
  // The declared reply: explicit `reply_to` first, else the runtime's own
7801
7764
  // declaration for the turn that just answers — see
7802
7765
  // `resolveDeclaredReplyField` for the whole rule and its reasons. CT1224: an
7803
- // auto-declaration is flagged as such on the wire, because the server drops it
7804
- // when the turn also opened a conversation (a fan-out is mid-arc). An explicit
7805
- // `reply_to` carries no flag and always stands.
7766
+ // auto-declaration is flagged as such on the wire, because the server drops
7767
+ // it when the turn performed an outward act (CT1354: any of them, read off
7768
+ // the ledger). An explicit `reply_to` carries no flag and always stands.
7806
7769
  answersField(kind) {
7807
7770
  return resolveDeclaredReplyField({
7808
7771
  kind,
7809
7772
  owedReplyMessageId: this.deps.owedReplyMessageId,
7810
- agentId: this.deps.agentId,
7811
- replyState: this.deps.replyState,
7812
- sendState: this.deps.sendState,
7813
- askState: this.deps.askState,
7814
- wakeState: this.deps.wakeState
7773
+ replyState: this.deps.replyState
7815
7774
  });
7816
7775
  }
7817
- // Carry both halves of the addressed send. The server writes `dispatchBody`
7818
- // on a distinct message row; the turn row itself remains unaddressed speech.
7819
- sendField() {
7820
- const { agentId: target, message } = this.deps.sendState;
7821
- if (!target || !message || target === this.deps.agentId) return {};
7822
- return { dispatch: target, dispatchBody: message };
7823
- }
7824
- // CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
7825
- // The server validates the target (must be a workspace member/owner) and
7826
- // creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
7827
- // independent of `sendField` — a turn can send and ask independently.
7828
- askField() {
7829
- const { targetUserId, question, headline, options, questions } = this.deps.askState;
7830
- if (!targetUserId) return {};
7831
- if (questions && questions.length > 0) {
7832
- return {
7833
- ask: {
7834
- targetUserId,
7835
- questions: questions.map((q) => ({
7836
- headline: q.headline,
7837
- ...q.body ? { body: q.body } : {},
7838
- ...q.options && q.options.length > 0 ? { options: q.options } : {}
7839
- }))
7840
- }
7841
- };
7842
- }
7843
- if (!question) return {};
7844
- return {
7845
- ask: {
7846
- targetUserId,
7847
- question,
7848
- // CT400: the optional one-sentence headline question, when supplied.
7849
- ...headline ? { headline } : {},
7850
- ...options && options.length > 0 ? { options } : {}
7851
- }
7852
- };
7853
- }
7854
- // CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
7855
- // The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
7856
- // null = no wake this turn → attach nothing). The server computes `fire_at` and
7857
- // arms the CT441 schedule. Kept independent of send/ask — a turn could
7858
- // conceivably ask AND arm a wake.
7859
- //
7860
- // CT990: the field now carries three states, and the third can't be spelled by
7861
- // absence — attaching NOTHING means "leave the standing wake alone", which is
7862
- // exactly what a stand-down must not do. So a `cancel_wake` turn attaches the
7863
- // discriminated `{ cancel: true }` payload instead.
7864
- wakeField() {
7865
- return wakeCommitField(this.deps.wakeState);
7866
- }
7867
7776
  };
7868
7777
 
7869
7778
  // src/turn-execution.ts
@@ -7954,7 +7863,7 @@ var TurnExecution = class {
7954
7863
  agentId: payload.agentId,
7955
7864
  messageId: payload.messageId
7956
7865
  });
7957
- this.turnId = handleOpts.turnId ?? randomUUID();
7866
+ this.turnId = handleOpts.turnId ?? randomUUID2();
7958
7867
  }
7959
7868
  opts;
7960
7869
  supervisor;
@@ -7974,12 +7883,8 @@ var TurnExecution = class {
7974
7883
  effectiveCwd;
7975
7884
  hookEnv;
7976
7885
  turnEnv;
7977
- sendState;
7978
7886
  skipState;
7979
- askState;
7980
- wakeState;
7981
7887
  replyState;
7982
- controlOrder;
7983
7888
  turnControlServer;
7984
7889
  request;
7985
7890
  adapter;
@@ -8149,7 +8054,7 @@ var TurnExecution = class {
8149
8054
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8150
8055
  body: `${MISSING_SECRET_PREFIX} ${list}`,
8151
8056
  kind: "final",
8152
- turnId: randomUUID(),
8057
+ turnId: randomUUID2(),
8153
8058
  // CT113: even a turn that fails before it runs answers a message.
8154
8059
  parentMessageId: payload.messageId
8155
8060
  });
@@ -8356,19 +8261,12 @@ ${reason}`,
8356
8261
  }
8357
8262
  buildRequest() {
8358
8263
  const { payload, workspaceId } = this;
8359
- const sendState = this.sendState = createSendState();
8360
8264
  const skipState = this.skipState = createSkipState();
8361
- const askState = this.askState = createAskState();
8362
- const wakeState = this.wakeState = createWakeState();
8363
8265
  const replyState = this.replyState = createReplyState();
8364
- const controlOrder = this.controlOrder = createTurnControlOrder();
8365
8266
  const turnControlServer = createTurnControlMcpServer(
8366
- sendState,
8267
+ { api: this.opts.api, workspaceId, turnId: this.turnId },
8367
8268
  skipState,
8368
- askState,
8369
- wakeState,
8370
- replyState,
8371
- controlOrder
8269
+ replyState
8372
8270
  );
8373
8271
  this.request = buildCompanionTurnRequest({
8374
8272
  turnContext: this.turnContext,
@@ -8470,16 +8368,6 @@ ${reason}`,
8470
8368
  signal: abortController.signal,
8471
8369
  log: turnLog,
8472
8370
  nextSeq: this.nextSeq,
8473
- // The committer reads this at commit to carry the addressed send on the
8474
- // terminal row; the server writes the send itself as a distinct message.
8475
- sendState: this.sendState,
8476
- // CT326: likewise the ask payload. CT1281: the server writes the ask as its
8477
- // own addressed message to the human — which ENQUEUES like any other send —
8478
- // and creates the `asks` row against that carrier, not against turn speech.
8479
- askState: this.askState,
8480
- // CT442: likewise the wake payload — attached to the `final` row so the
8481
- // server arms the wake schedule atomically with the reply it rode on.
8482
- wakeState: this.wakeState,
8483
8371
  replyState: this.replyState,
8484
8372
  // The ledger-derived owed reply, for the runtime's own declaration when
8485
8373
  // the agent doesn't call `reply_to` (resolveDeclaredReplyField).
@@ -8502,41 +8390,8 @@ ${reason}`,
8502
8390
  payload.agentId,
8503
8391
  turnId
8504
8392
  );
8505
- if (intent.ask) {
8506
- this.askState.targetUserId = intent.ask.targetUserId;
8507
- if (intent.ask.questions && intent.ask.questions.length > 0) {
8508
- this.askState.questions = intent.ask.questions;
8509
- this.askState.question = null;
8510
- this.askState.headline = null;
8511
- this.askState.options = null;
8512
- } else {
8513
- this.askState.question = intent.ask.question ?? null;
8514
- this.askState.headline = intent.ask.headline ?? null;
8515
- this.askState.options = intent.ask.options ?? null;
8516
- this.askState.questions = null;
8517
- }
8518
- }
8519
- if (intent.wake) {
8520
- if ("cancel" in intent.wake) {
8521
- this.wakeState.cancelled = true;
8522
- this.wakeState.afterSeconds = null;
8523
- this.wakeState.at = null;
8524
- this.wakeState.note = null;
8525
- } else {
8526
- this.wakeState.cancelled = false;
8527
- this.wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
8528
- this.wakeState.at = intent.wake.at ?? null;
8529
- this.wakeState.note = intent.wake.note;
8530
- }
8531
- }
8532
- if (intent.sendAgentId && intent.sendBody) {
8533
- this.sendState.agentId = intent.sendAgentId;
8534
- this.sendState.message = intent.sendBody;
8535
- this.sendState.order = intent.sendOrder ?? null;
8536
- }
8537
8393
  if (intent.answersMessageId) {
8538
8394
  this.replyState.answersMessageId = intent.answersMessageId;
8539
- this.replyState.order = intent.replyOrder ?? null;
8540
8395
  }
8541
8396
  if (intent.skipped) {
8542
8397
  this.skipState.skipped = true;
@@ -8545,7 +8400,7 @@ ${reason}`,
8545
8400
  } catch (err) {
8546
8401
  turnLog.warn(
8547
8402
  { err: err instanceof Error ? err.message : String(err) },
8548
- "dispatcher: turn-control intent fetch failed; turn-control effects for this turn are dropped"
8403
+ "dispatcher: turn-control intent fetch failed; the declared reply / skip for this turn are dropped"
8549
8404
  );
8550
8405
  }
8551
8406
  };
@@ -8688,15 +8543,13 @@ ${reason}`,
8688
8543
  { reason: this.skipState.reason, turnId, ok: o.okResult },
8689
8544
  "agent skipped turn (skip_turn)"
8690
8545
  );
8691
- const { wake: skipWake } = wakeCommitField(this.wakeState);
8692
8546
  try {
8693
8547
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8694
8548
  body: SKIPPED_MARKER_BODY,
8695
8549
  kind: "skipped",
8696
8550
  turnId,
8697
8551
  seq: this.nextSeq(),
8698
- parentMessageId: payload.messageId,
8699
- ...skipWake ? { wake: skipWake } : {}
8552
+ parentMessageId: payload.messageId
8700
8553
  });
8701
8554
  } catch (err) {
8702
8555
  turnLog.warn(
@@ -8714,9 +8567,9 @@ ${reason}`,
8714
8567
  turnId,
8715
8568
  seq: this.nextSeq(),
8716
8569
  parentMessageId: payload.messageId,
8717
- // ask, wake and send must survive a wordless turn exactly as
8718
- // they survive a textual final; dropping one can strand a person
8719
- // or the next actor with no visible failure.
8570
+ // An explicit `reply_to` survives a wordless turn exactly as it
8571
+ // survives a textual final (the auto-declaration does not a
8572
+ // marker has no answer to bind).
8720
8573
  ...committer.turnControlFields("silent")
8721
8574
  });
8722
8575
  o.silentMarkerEmitted = true;
@@ -10028,7 +9881,7 @@ var CompanionSupervisor = class {
10028
9881
  );
10029
9882
  }
10030
9883
  const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
10031
- const turnId = resumedTurnId ?? randomUUID2();
9884
+ const turnId = resumedTurnId ?? randomUUID3();
10032
9885
  if (ev.id) {
10033
9886
  const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
10034
9887
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
@@ -10436,7 +10289,7 @@ async function createCompanionRuntime(opts = {}) {
10436
10289
  opencodeServerUrl: cfg.opencode?.serverUrl,
10437
10290
  codex: isCodexEnabled(cfg)
10438
10291
  });
10439
- const instanceId = randomUUID3();
10292
+ const instanceId = randomUUID4();
10440
10293
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
10441
10294
  const pre = readLiveRuntimeState();
10442
10295
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();