@cabane/companion 0.6.79 → 0.6.82

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 +356 -377
  2. package/dist/runtime.js +356 -377
  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
@@ -4599,6 +4620,10 @@ async function* decodeOpencodeStream(events, ctx) {
4599
4620
  if (sealed) yield sealed;
4600
4621
  ok = false;
4601
4622
  const errorText = readSessionError(ev.properties);
4623
+ const notice = errorText.trim().slice(0, 200).trimEnd();
4624
+ if (notice && !/^[\w:-]+$/.test(notice)) {
4625
+ yield { type: "runtime_notice", body: notice };
4626
+ }
4602
4627
  const failure = classifyErrorText(errorText);
4603
4628
  reason = failure ? encodeFailureReason(failure) : `error:${errorText.slice(0, 200)}`;
4604
4629
  settled = true;
@@ -5008,6 +5033,7 @@ var opencodeAdapter = createOpencodeAdapter();
5008
5033
  // packages/agent-runtime/src/opencode/conformance.ts
5009
5034
  var ABORT_SENTINEL2 = { __abortHere: true };
5010
5035
  var NEW_SESSION_ID = "sess_new";
5036
+ var BOUNDED_NOTICE = "You've hit your usage limit \xB7 ".padEnd(200, "x");
5011
5037
  var COMPANION_POLICY = {
5012
5038
  hostFs: false,
5013
5039
  web: true,
@@ -5199,6 +5225,7 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
5199
5225
  expected: [
5200
5226
  sessionEvent2(NEW_SESSION_ID),
5201
5227
  { type: "text", body: "Partial work.", terminal: false },
5228
+ { type: "runtime_notice", body: "provider exploded" },
5202
5229
  { type: "result", ok: false, reason: "error:provider exploded" }
5203
5230
  ]
5204
5231
  },
@@ -5215,7 +5242,11 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
5215
5242
  properties: { error: { name: "RateLimitError", message: "rate limit exceeded (429)" } }
5216
5243
  }
5217
5244
  ],
5218
- expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "rate_limited" }]
5245
+ expected: [
5246
+ sessionEvent2(NEW_SESSION_ID),
5247
+ { type: "runtime_notice", body: "RateLimitError: rate limit exceeded (429)" },
5248
+ { type: "result", ok: false, reason: "rate_limited" }
5249
+ ]
5219
5250
  },
5220
5251
  {
5221
5252
  // CT592: a subscription cap surfaced as text ("usage limit reached") → the cap
@@ -5229,7 +5260,11 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
5229
5260
  properties: { error: { name: "Error", message: "Claude usage limit reached" } }
5230
5261
  }
5231
5262
  ],
5232
- expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "usage_capped" }]
5263
+ expected: [
5264
+ sessionEvent2(NEW_SESSION_ID),
5265
+ { type: "runtime_notice", body: "Claude usage limit reached" },
5266
+ { type: "result", ok: false, reason: "usage_capped" }
5267
+ ]
5233
5268
  },
5234
5269
  {
5235
5270
  // CT592: a transient provider outage (5xx / overload) surfaced as text → the
@@ -5245,7 +5280,11 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
5245
5280
  }
5246
5281
  }
5247
5282
  ],
5248
- expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "server_error" }]
5283
+ expected: [
5284
+ sessionEvent2(NEW_SESSION_ID),
5285
+ { type: "runtime_notice", body: "AI_APICallError: overloaded_error (529)" },
5286
+ { type: "result", ok: false, reason: "server_error" }
5287
+ ]
5249
5288
  },
5250
5289
  {
5251
5290
  // CT558: an expired/invalid credential classifies as `auth_expired`.
@@ -5257,7 +5296,66 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
5257
5296
  properties: { error: { name: "AuthenticationError", message: "HTTP 401 Unauthorized" } }
5258
5297
  }
5259
5298
  ],
5260
- expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "auth_expired" }]
5299
+ expected: [
5300
+ sessionEvent2(NEW_SESSION_ID),
5301
+ { type: "runtime_notice", body: "AuthenticationError: HTTP 401 Unauthorized" },
5302
+ { type: "result", ok: false, reason: "auth_expired" }
5303
+ ]
5304
+ },
5305
+ {
5306
+ // CT1351: an unclassified failure remains as informative as a classified one:
5307
+ // its provider text is visible while the raw machine reason stays unchanged.
5308
+ name: "unclassified failure keeps a runtime notice and raw reason",
5309
+ request: makeRequest2(),
5310
+ nativeStream: [errored("something local broke")],
5311
+ expected: [
5312
+ sessionEvent2(NEW_SESSION_ID),
5313
+ { type: "runtime_notice", body: "something local broke" },
5314
+ { type: "result", ok: false, reason: "error:something local broke" }
5315
+ ]
5316
+ },
5317
+ {
5318
+ name: "runtime notice is trimmed and bounded to 200 characters",
5319
+ request: makeRequest2(),
5320
+ nativeStream: [errored(` ${BOUNDED_NOTICE}${"y".repeat(20)} `)],
5321
+ expected: [
5322
+ sessionEvent2(NEW_SESSION_ID),
5323
+ { type: "runtime_notice", body: BOUNDED_NOTICE },
5324
+ { type: "result", ok: false, reason: "usage_capped" }
5325
+ ]
5326
+ },
5327
+ {
5328
+ // CT1351: empty, whitespace-only, and bare status-token failures still set
5329
+ // the machine reason but do not add an empty/unhelpful transcript notice.
5330
+ name: "empty failure text emits no runtime notice",
5331
+ request: makeRequest2(),
5332
+ nativeStream: [errored("")],
5333
+ expected: [
5334
+ sessionEvent2(NEW_SESSION_ID),
5335
+ { type: "result", ok: false, reason: "error:unknown" }
5336
+ ]
5337
+ },
5338
+ {
5339
+ name: "whitespace-only failure text emits no runtime notice",
5340
+ request: makeRequest2(),
5341
+ nativeStream: [errored(" ")],
5342
+ expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "error: " }]
5343
+ },
5344
+ {
5345
+ name: "bare status token emits no runtime notice",
5346
+ request: makeRequest2(),
5347
+ nativeStream: [errored("429")],
5348
+ expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "rate_limited" }]
5349
+ },
5350
+ {
5351
+ name: "single-word provider sentence emits a runtime notice",
5352
+ request: makeRequest2(),
5353
+ nativeStream: [errored("Unavailable.")],
5354
+ expected: [
5355
+ sessionEvent2(NEW_SESSION_ID),
5356
+ { type: "runtime_notice", body: "Unavailable." },
5357
+ { type: "result", ok: false, reason: "error:Unavailable." }
5358
+ ]
5261
5359
  },
5262
5360
  {
5263
5361
  // CT1144: the same zero-content success the claude-code suite pins, in
@@ -5679,6 +5777,10 @@ async function* decodeCodexStream(events, ctx) {
5679
5777
  yield* flushInterim();
5680
5778
  ok = false;
5681
5779
  const text = readErrorMessage(ev);
5780
+ const notice = text.trim().slice(0, 200).trimEnd();
5781
+ if (notice && !/^[\w:-]+$/.test(notice)) {
5782
+ yield { type: "runtime_notice", body: notice };
5783
+ }
5682
5784
  const failure = classifyErrorText(text);
5683
5785
  reason = failure ? encodeFailureReason(failure) : `error:${text.slice(0, 200)}`;
5684
5786
  settled = true;
@@ -5997,6 +6099,7 @@ var codexAdapter = createCodexAdapter();
5997
6099
  // packages/agent-runtime/src/codex/conformance.ts
5998
6100
  var ABORT_SENTINEL3 = { __abortHere: true };
5999
6101
  var NEW_THREAD_ID = "th_new";
6102
+ var BOUNDED_NOTICE2 = "You've hit your usage limit \xB7 ".padEnd(200, "x");
6000
6103
  var COMPANION_POLICY2 = {
6001
6104
  hostFs: false,
6002
6105
  web: true,
@@ -6526,6 +6629,7 @@ var CODEX_CONFORMANCE_FIXTURES = [
6526
6629
  expected: [
6527
6630
  sessionEvent3(NEW_THREAD_ID),
6528
6631
  { type: "text", body: "Partial work.", terminal: false },
6632
+ { type: "runtime_notice", body: "provider exploded" },
6529
6633
  { type: "result", ok: false, reason: "error:provider exploded" }
6530
6634
  ]
6531
6635
  },
@@ -6542,6 +6646,7 @@ var CODEX_CONFORMANCE_FIXTURES = [
6542
6646
  expected: [
6543
6647
  sessionEvent3(NEW_THREAD_ID),
6544
6648
  { type: "text", body: "Starting.", terminal: false },
6649
+ { type: "runtime_notice", body: "stream died" },
6545
6650
  { type: "result", ok: false, reason: "error:stream died" }
6546
6651
  ]
6547
6652
  },
@@ -6554,25 +6659,47 @@ var CODEX_CONFORMANCE_FIXTURES = [
6554
6659
  name: "turn.failed 429 \u2192 rate_limited (throttle)",
6555
6660
  request: makeRequest3(),
6556
6661
  nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("429 Too Many Requests")],
6557
- expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: false, reason: "rate_limited" }]
6662
+ expected: [
6663
+ sessionEvent3(NEW_THREAD_ID),
6664
+ { type: "runtime_notice", body: "429 Too Many Requests" },
6665
+ { type: "result", ok: false, reason: "rate_limited" }
6666
+ ]
6558
6667
  },
6559
6668
  {
6560
6669
  name: "turn.failed overload \u2192 server_error",
6561
6670
  request: makeRequest3(),
6562
6671
  nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("503 Service Unavailable")],
6563
- expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: false, reason: "server_error" }]
6672
+ expected: [
6673
+ sessionEvent3(NEW_THREAD_ID),
6674
+ { type: "runtime_notice", body: "503 Service Unavailable" },
6675
+ { type: "result", ok: false, reason: "server_error" }
6676
+ ]
6564
6677
  },
6565
6678
  {
6566
6679
  name: "turn.failed usage cap \u2192 usage_capped",
6567
6680
  request: makeRequest3(),
6568
- nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("You have hit your usage limit")],
6569
- expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: false, reason: "usage_capped" }]
6681
+ nativeStream: [
6682
+ threadStarted(NEW_THREAD_ID),
6683
+ turnFailed("You've hit your session limit \xB7 resets 8:20pm (UTC)")
6684
+ ],
6685
+ expected: [
6686
+ sessionEvent3(NEW_THREAD_ID),
6687
+ {
6688
+ type: "runtime_notice",
6689
+ body: "You've hit your session limit \xB7 resets 8:20pm (UTC)"
6690
+ },
6691
+ { type: "result", ok: false, reason: "usage_capped" }
6692
+ ]
6570
6693
  },
6571
6694
  {
6572
6695
  name: "turn.failed auth \u2192 auth_expired",
6573
6696
  request: makeRequest3(),
6574
6697
  nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("401 Unauthorized: invalid api key")],
6575
- expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: false, reason: "auth_expired" }]
6698
+ expected: [
6699
+ sessionEvent3(NEW_THREAD_ID),
6700
+ { type: "runtime_notice", body: "401 Unauthorized: invalid api key" },
6701
+ { type: "result", ok: false, reason: "auth_expired" }
6702
+ ]
6576
6703
  },
6577
6704
  {
6578
6705
  // CT592: a Codex failure text the classifier can't place still keeps today's
@@ -6582,9 +6709,53 @@ var CODEX_CONFORMANCE_FIXTURES = [
6582
6709
  nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("something local broke")],
6583
6710
  expected: [
6584
6711
  sessionEvent3(NEW_THREAD_ID),
6712
+ { type: "runtime_notice", body: "something local broke" },
6585
6713
  { type: "result", ok: false, reason: "error:something local broke" }
6586
6714
  ]
6587
6715
  },
6716
+ {
6717
+ name: "runtime notice is trimmed and bounded to 200 characters",
6718
+ request: makeRequest3(),
6719
+ nativeStream: [
6720
+ threadStarted(NEW_THREAD_ID),
6721
+ turnFailed(` ${BOUNDED_NOTICE2}${"y".repeat(20)} `)
6722
+ ],
6723
+ expected: [
6724
+ sessionEvent3(NEW_THREAD_ID),
6725
+ { type: "runtime_notice", body: BOUNDED_NOTICE2 },
6726
+ { type: "result", ok: false, reason: "usage_capped" }
6727
+ ]
6728
+ },
6729
+ {
6730
+ // CT1351: empty, whitespace-only, and bare status-token failures still set
6731
+ // the machine reason but do not add an empty/unhelpful transcript notice.
6732
+ name: "empty failure text emits no runtime notice",
6733
+ request: makeRequest3(),
6734
+ nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("")],
6735
+ expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: false, reason: "error:unknown" }]
6736
+ },
6737
+ {
6738
+ name: "whitespace-only failure text emits no runtime notice",
6739
+ request: makeRequest3(),
6740
+ nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed(" ")],
6741
+ expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: false, reason: "error: " }]
6742
+ },
6743
+ {
6744
+ name: "bare status token emits no runtime notice",
6745
+ request: makeRequest3(),
6746
+ nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("429")],
6747
+ expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: false, reason: "rate_limited" }]
6748
+ },
6749
+ {
6750
+ name: "single-word provider sentence emits a runtime notice",
6751
+ request: makeRequest3(),
6752
+ nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("Unavailable.")],
6753
+ expected: [
6754
+ sessionEvent3(NEW_THREAD_ID),
6755
+ { type: "runtime_notice", body: "Unavailable." },
6756
+ { type: "result", ok: false, reason: "error:Unavailable." }
6757
+ ]
6758
+ },
6588
6759
  {
6589
6760
  // CT481: fail LOUD on unknown model metadata. An unknown `--model` makes Codex
6590
6761
  // emit an `error` item ("… Defaulting to fallback metadata …") and then go
@@ -6891,10 +7062,11 @@ import { existsSync as existsSync11, readdirSync as readdirSync2, statSync } fro
6891
7062
  import { join as join14 } from "path";
6892
7063
 
6893
7064
  // src/turn-execution.ts
6894
- import { createHash as createHash2, randomUUID } from "crypto";
7065
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
6895
7066
  import { existsSync as existsSync10 } from "fs";
6896
7067
 
6897
7068
  // src/turn-control-tools.ts
7069
+ import { randomUUID } from "crypto";
6898
7070
  import { z as z13 } from "zod";
6899
7071
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
6900
7072
  var SEND_TOOL = "send";
@@ -6910,89 +7082,83 @@ var WAKE_ME_TOOL = "wake_me";
6910
7082
  var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
6911
7083
  var CANCEL_WAKE_TOOL = "cancel_wake";
6912
7084
  var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
7085
+ var TURN_ACT_TIMEOUT_MS = 45e3;
6913
7086
  function createReplyState() {
6914
- return { answersMessageId: null, order: null };
6915
- }
6916
- function createSendState() {
6917
- return { agentId: null, message: null, order: null };
6918
- }
6919
- function createTurnControlOrder() {
6920
- let calls = 0;
6921
- return {
6922
- next: () => {
6923
- calls += 1;
6924
- return calls;
6925
- }
6926
- };
7087
+ return { answersMessageId: null };
6927
7088
  }
6928
7089
  function createSkipState() {
6929
7090
  return { skipped: false, reason: null };
6930
7091
  }
6931
- function createAskState() {
6932
- return { targetUserId: null, question: null, headline: null, options: null, questions: null };
6933
- }
6934
- function createWakeState() {
6935
- return { afterSeconds: null, at: null, note: null, cancelled: false };
6936
- }
6937
7092
  function resolveDeclaredReplyField(input) {
6938
7093
  const explicit = input.replyState.answersMessageId;
6939
7094
  if (explicit) return { answersMessageId: explicit };
6940
7095
  if (input.kind !== "final") return {};
6941
7096
  if (!input.owedReplyMessageId) return {};
6942
- const outwardSend = Boolean(
6943
- input.sendState.agentId && input.sendState.message && input.sendState.agentId !== input.agentId
6944
- );
6945
- const askRaised = Boolean(input.askState.targetUserId);
6946
- const wakeArmed = input.wakeState.afterSeconds !== null || input.wakeState.at !== null;
6947
- if (outwardSend || askRaised || wakeArmed) return {};
6948
7097
  return { answersMessageId: input.owedReplyMessageId, answersAutoDeclared: true };
6949
7098
  }
6950
- function wakeCommitField(state) {
6951
- if (state.cancelled) return { wake: { cancel: true } };
6952
- const { afterSeconds, at, note } = state;
6953
- if (!note || afterSeconds === null && at === null) return {};
6954
- return {
6955
- wake: {
6956
- ...afterSeconds !== null ? { afterSeconds } : {},
6957
- ...at !== null ? { at } : {},
6958
- note
7099
+ function toolError(text) {
7100
+ return { isError: true, content: [{ type: "text", text }] };
7101
+ }
7102
+ async function performAct(acts, act) {
7103
+ let result;
7104
+ try {
7105
+ result = await acts.api.turnAct(
7106
+ acts.workspaceId,
7107
+ acts.turnId,
7108
+ act,
7109
+ AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
7110
+ );
7111
+ } catch (err) {
7112
+ if (err instanceof ApiError) {
7113
+ if (err.status === 409) {
7114
+ return toolError(
7115
+ "No active turn: this turn has already settled, so the act was not performed."
7116
+ );
7117
+ }
7118
+ return toolError(`The act was refused (${err.status}): ${err.message}`);
6959
7119
  }
6960
- };
7120
+ const message = err instanceof Error ? err.message : String(err);
7121
+ return toolError(
7122
+ `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.`
7123
+ );
7124
+ }
7125
+ if (!result.ok) return toolError(JSON.stringify(result));
7126
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
6961
7127
  }
6962
- function createTurnControlMcpServer(sendState, skipState, askState, wakeState, replyState, controlOrder) {
7128
+ function createTurnControlMcpServer(acts, skipState, replyState) {
7129
+ const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
7130
+ const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
7131
+ const RECORDER_TOOL = { readOnlyHint: true, openWorldHint: false };
6963
7132
  return createSdkMcpServer({
6964
7133
  name: COMPANION_LOCAL_MCP_SERVER,
6965
7134
  version: "0.0.0",
6966
7135
  tools: [
6967
7136
  tool(
6968
7137
  SEND_TOOL,
6969
- "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.",
7138
+ "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.",
6970
7139
  {
6971
7140
  agentId: z13.string().uuid().describe(
6972
7141
  "The peer agent to address \u2014 a workspace agent id, from your turn context's roster."
6973
7142
  ),
6974
7143
  message: z13.string().min(1).max(65536).describe("The complete new request the peer should receive and act on.")
6975
7144
  },
6976
- async (args) => {
6977
- sendState.agentId = args.agentId;
6978
- sendState.message = args.message;
6979
- sendState.order = controlOrder?.next() ?? null;
6980
- return {
6981
- content: [{ type: "text", text: JSON.stringify({ sent: args.agentId }) }]
6982
- };
6983
- },
6984
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7145
+ async (args) => performAct(acts, {
7146
+ kind: "send",
7147
+ callId: randomUUID(),
7148
+ agentId: args.agentId,
7149
+ message: args.message
7150
+ }),
7151
+ { annotations: ACT_TOOL, alwaysLoad: true }
6985
7152
  ),
6986
7153
  ...replyState ? [
6987
7154
  tool(
6988
7155
  REPLY_TO_TOOL,
6989
- "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.",
7156
+ "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.",
6990
7157
  {
6991
7158
  messageId: z13.string().uuid().describe("The owed ask message id from this turn context.")
6992
7159
  },
6993
7160
  async (args) => {
6994
7161
  replyState.answersMessageId = args.messageId;
6995
- replyState.order = controlOrder?.next() ?? null;
6996
7162
  return {
6997
7163
  content: [
6998
7164
  {
@@ -7002,7 +7168,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, wakeState, r
7002
7168
  ]
7003
7169
  };
7004
7170
  },
7005
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7171
+ { annotations: RECORDER_TOOL, alwaysLoad: true }
7006
7172
  )
7007
7173
  ] : [],
7008
7174
  ...skipState ? [
@@ -7019,161 +7185,112 @@ function createTurnControlMcpServer(sendState, skipState, askState, wakeState, r
7019
7185
  content: [{ type: "text", text: JSON.stringify({ skipped: true }) }]
7020
7186
  };
7021
7187
  },
7022
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7188
+ { annotations: RECORDER_TOOL, alwaysLoad: true }
7023
7189
  )
7024
7190
  ] : [],
7025
- ...askState ? [
7026
- tool(
7027
- ASK_TOOL,
7028
- `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.`,
7029
- {
7030
- targetUserId: z13.string().uuid().describe(
7031
- "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
7032
- ),
7033
- question: z13.string().min(1).max(400).optional().describe(
7034
- "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`."
7035
- ),
7036
- headline: z13.string().min(1).max(120).optional().describe(
7037
- '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.'
7038
- ),
7039
- 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."),
7040
- questions: z13.array(
7041
- z13.object({
7042
- headline: z13.string().min(1).max(120).describe(
7043
- 'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
7044
- ),
7045
- body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
7046
- options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
7047
- })
7048
- ).min(1).max(MAX_ASK_ITEMS).optional().describe(
7049
- `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.`
7050
- )
7051
- },
7052
- async (args) => {
7053
- const hasSingle = args.question !== void 0;
7054
- const hasArray = args.questions !== void 0 && args.questions.length > 0;
7055
- if (hasSingle && hasArray) {
7056
- return {
7057
- isError: true,
7058
- content: [
7059
- {
7060
- type: "text",
7061
- text: "Provide either `question` (single) or `questions` (array), not both."
7062
- }
7063
- ]
7064
- };
7065
- }
7066
- if (!hasSingle && !hasArray) {
7067
- return {
7068
- isError: true,
7069
- content: [
7070
- {
7071
- type: "text",
7072
- text: "Provide `question` (single) or `questions` (array)."
7073
- }
7074
- ]
7075
- };
7076
- }
7077
- askState.targetUserId = args.targetUserId;
7078
- if (hasArray) {
7079
- askState.questions = args.questions;
7080
- askState.question = null;
7081
- askState.headline = null;
7082
- askState.options = null;
7083
- } else {
7084
- askState.question = args.question;
7085
- askState.headline = args.headline ?? null;
7086
- askState.options = args.options ?? null;
7087
- askState.questions = null;
7088
- }
7089
- return {
7090
- content: [
7091
- { type: "text", text: JSON.stringify({ asked: args.targetUserId }) }
7092
- ]
7093
- };
7094
- },
7095
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7096
- )
7097
- ] : [],
7098
- ...wakeState ? [
7099
- tool(
7100
- WAKE_ME_TOOL,
7101
- "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.",
7102
- {
7103
- afterSeconds: z13.number().int().positive().optional().describe(
7104
- "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."
7105
- ),
7106
- at: z13.string().datetime({ offset: true }).optional().describe(
7107
- "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."
7108
- ),
7109
- note: z13.string().min(1).max(2e3).describe(
7110
- '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").'
7111
- )
7112
- },
7113
- async (args) => {
7114
- const hasAfter = args.afterSeconds !== void 0;
7115
- const hasAt = args.at !== void 0;
7116
- if (hasAfter && hasAt) {
7117
- return {
7118
- isError: true,
7119
- content: [
7120
- {
7121
- type: "text",
7122
- text: "Provide either `afterSeconds` (relative) or `at` (absolute), not both."
7123
- }
7124
- ]
7125
- };
7191
+ tool(
7192
+ ASK_TOOL,
7193
+ `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. After asking, finish your reply and stop; when the person replies addressed to you, the ask resolves and you resume. Targets a human only; to hand work to another AGENT use send/dispatch instead.`,
7194
+ {
7195
+ targetUserId: z13.string().uuid().describe(
7196
+ "The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
7197
+ ),
7198
+ question: z13.string().min(1).max(400).optional().describe(
7199
+ "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`."
7200
+ ),
7201
+ headline: z13.string().min(1).max(120).optional().describe(
7202
+ '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.'
7203
+ ),
7204
+ 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."),
7205
+ questions: z13.array(
7206
+ z13.object({
7207
+ headline: z13.string().min(1).max(120).describe(
7208
+ 'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
7209
+ ),
7210
+ body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
7211
+ options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
7212
+ })
7213
+ ).min(1).max(MAX_ASK_ITEMS).optional().describe(
7214
+ `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.`
7215
+ )
7216
+ },
7217
+ async (args) => {
7218
+ const hasSingle = args.question !== void 0;
7219
+ const hasArray = args.questions !== void 0 && args.questions.length > 0;
7220
+ if (hasSingle && hasArray) {
7221
+ return toolError(
7222
+ "Provide either `question` (single) or `questions` (array), not both."
7223
+ );
7224
+ }
7225
+ if (!hasSingle && !hasArray) {
7226
+ return toolError("Provide `question` (single) or `questions` (array).");
7227
+ }
7228
+ return performAct(acts, {
7229
+ kind: "ask",
7230
+ callId: randomUUID(),
7231
+ ask: hasArray ? {
7232
+ targetUserId: args.targetUserId,
7233
+ questions: args.questions.map((q) => ({
7234
+ headline: q.headline,
7235
+ ...q.body ? { body: q.body } : {},
7236
+ ...q.options && q.options.length > 0 ? { options: q.options } : {}
7237
+ }))
7238
+ } : {
7239
+ targetUserId: args.targetUserId,
7240
+ question: args.question,
7241
+ ...args.headline ? { headline: args.headline } : {},
7242
+ ...args.options && args.options.length > 0 ? { options: args.options } : {}
7126
7243
  }
7127
- if (!hasAfter && !hasAt) {
7128
- return {
7129
- isError: true,
7130
- content: [{ type: "text", text: "Provide `afterSeconds` or `at`." }]
7131
- };
7244
+ });
7245
+ },
7246
+ { annotations: ACT_TOOL, alwaysLoad: true }
7247
+ ),
7248
+ tool(
7249
+ WAKE_ME_TOOL,
7250
+ "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.",
7251
+ {
7252
+ afterSeconds: z13.number().int().positive().optional().describe(
7253
+ "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."
7254
+ ),
7255
+ at: z13.string().datetime({ offset: true }).optional().describe(
7256
+ "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."
7257
+ ),
7258
+ note: z13.string().min(1).max(2e3).describe(
7259
+ '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").'
7260
+ )
7261
+ },
7262
+ async (args) => {
7263
+ const hasAfter = args.afterSeconds !== void 0;
7264
+ const hasAt = args.at !== void 0;
7265
+ if (hasAfter && hasAt) {
7266
+ return toolError(
7267
+ "Provide either `afterSeconds` (relative) or `at` (absolute), not both."
7268
+ );
7269
+ }
7270
+ if (!hasAfter && !hasAt) {
7271
+ return toolError("Provide `afterSeconds` or `at`.");
7272
+ }
7273
+ return performAct(acts, {
7274
+ kind: "wake",
7275
+ callId: randomUUID(),
7276
+ wake: {
7277
+ ...hasAfter ? { afterSeconds: args.afterSeconds } : {},
7278
+ ...hasAt ? { at: args.at } : {},
7279
+ note: args.note
7132
7280
  }
7133
- wakeState.afterSeconds = args.afterSeconds ?? null;
7134
- wakeState.at = args.at ?? null;
7135
- wakeState.note = args.note;
7136
- wakeState.cancelled = false;
7137
- return {
7138
- content: [
7139
- {
7140
- type: "text",
7141
- text: JSON.stringify({
7142
- armed: hasAt ? { at: args.at } : { afterSeconds: args.afterSeconds },
7143
- note: "Recorded. It arms when this turn ends \u2014 end your turn now; you'll be woken with this note."
7144
- })
7145
- }
7146
- ]
7147
- };
7148
- },
7149
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7150
- ),
7151
- // CT990: the inverse. No arguments — it can only ever stand down the
7152
- // caller's own wake in the conversation it's holding a turn in.
7153
- tool(
7154
- CANCEL_WAKE_TOOL,
7155
- '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.*`).',
7156
- {},
7157
- async () => {
7158
- wakeState.cancelled = true;
7159
- wakeState.afterSeconds = null;
7160
- wakeState.at = null;
7161
- wakeState.note = null;
7162
- return {
7163
- content: [
7164
- {
7165
- type: "text",
7166
- text: JSON.stringify({
7167
- cancelled: true,
7168
- note: "Recorded. Any wake you have armed in this conversation is stood down when this turn ends. If you had none, nothing happens."
7169
- })
7170
- }
7171
- ]
7172
- };
7173
- },
7174
- { annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
7175
- )
7176
- ] : []
7281
+ });
7282
+ },
7283
+ { annotations: ACT_TOOL, alwaysLoad: true }
7284
+ ),
7285
+ // CT990: the inverse. No arguments — it can only ever stand down the
7286
+ // caller's own wake in the conversation it's holding a turn in.
7287
+ tool(
7288
+ CANCEL_WAKE_TOOL,
7289
+ '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.*`).',
7290
+ {},
7291
+ async () => performAct(acts, { kind: "cancel_wake", callId: randomUUID() }),
7292
+ { annotations: CANCEL_TOOL, alwaysLoad: true }
7293
+ )
7177
7294
  ]
7178
7295
  });
7179
7296
  }
@@ -7600,107 +7717,25 @@ var TurnCommitter = class {
7600
7717
  return this.pump.finalEmitted;
7601
7718
  }
7602
7719
  // A closing textual reply and a wordless terminal marker carry the same
7603
- // per-turn control state. Keep this one projection so ask/wake/send cannot
7604
- // silently diverge when the agent ends without words. The KIND matters to
7605
- // exactly one field: the auto-declared reply binds only a textual `final` —
7606
- // a wordless turn has no answer to bind, and the server's mute-settle
7607
- // notice speaks for it.
7720
+ // declared-reply projection. The KIND matters: the auto-declared reply binds
7721
+ // only a textual `final` a wordless turn has no answer to bind, and the
7722
+ // server's mute-settle notice speaks for it.
7608
7723
  turnControlFields(kind) {
7609
- const fields = {
7610
- ...this.answersField(kind),
7611
- ...this.sendField(),
7612
- ...this.askField(),
7613
- ...this.wakeField()
7614
- };
7615
- return { ...fields, ...this.orderField(fields) };
7616
- }
7617
- // CT1281: the order the control tools were CALLED, for the addressed rows the
7618
- // server writes from this one commit. It reports a position only for an intent
7619
- // that actually made it into the commit — a self-send the committer stripped
7620
- // contributes no row and so has no place in the line. An intent with no
7621
- // recorded call (the runtime's auto-declared reply) is deliberately absent, and
7622
- // the server sorts it after every recorded one — it binds the turn's closing
7623
- // words.
7624
- //
7625
- // The ask is absent by design, not by omission: its carrier queues last among
7626
- // the turn's rows however early the tool fired, so there is no position to
7627
- // report.
7628
- orderField(fields) {
7629
- const order = {};
7630
- const replyOrder = this.deps.replyState.order;
7631
- if (fields.answersMessageId !== void 0 && replyOrder !== null) order.reply = replyOrder;
7632
- if (fields.dispatch !== void 0 && this.deps.sendState.order !== null) {
7633
- order.send = this.deps.sendState.order;
7634
- }
7635
- return Object.keys(order).length > 0 ? { turnControlOrder: order } : {};
7724
+ return this.answersField(kind);
7636
7725
  }
7637
7726
  // The declared reply: explicit `reply_to` first, else the runtime's own
7638
7727
  // declaration for the turn that just answers — see
7639
7728
  // `resolveDeclaredReplyField` for the whole rule and its reasons. CT1224: an
7640
- // auto-declaration is flagged as such on the wire, because the server drops it
7641
- // when the turn also opened a conversation (a fan-out is mid-arc). An explicit
7642
- // `reply_to` carries no flag and always stands.
7729
+ // auto-declaration is flagged as such on the wire, because the server drops
7730
+ // it when the turn performed an outward act (CT1354: any of them, read off
7731
+ // the ledger). An explicit `reply_to` carries no flag and always stands.
7643
7732
  answersField(kind) {
7644
7733
  return resolveDeclaredReplyField({
7645
7734
  kind,
7646
7735
  owedReplyMessageId: this.deps.owedReplyMessageId,
7647
- agentId: this.deps.agentId,
7648
- replyState: this.deps.replyState,
7649
- sendState: this.deps.sendState,
7650
- askState: this.deps.askState,
7651
- wakeState: this.deps.wakeState
7736
+ replyState: this.deps.replyState
7652
7737
  });
7653
7738
  }
7654
- // Carry both halves of the addressed send. The server writes `dispatchBody`
7655
- // on a distinct message row; the turn row itself remains unaddressed speech.
7656
- sendField() {
7657
- const { agentId: target, message } = this.deps.sendState;
7658
- if (!target || !message || target === this.deps.agentId) return {};
7659
- return { dispatch: target, dispatchBody: message };
7660
- }
7661
- // CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
7662
- // The server validates the target (must be a workspace member/owner) and
7663
- // creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
7664
- // independent of `sendField` — a turn can send and ask independently.
7665
- askField() {
7666
- const { targetUserId, question, headline, options, questions } = this.deps.askState;
7667
- if (!targetUserId) return {};
7668
- if (questions && questions.length > 0) {
7669
- return {
7670
- ask: {
7671
- targetUserId,
7672
- questions: questions.map((q) => ({
7673
- headline: q.headline,
7674
- ...q.body ? { body: q.body } : {},
7675
- ...q.options && q.options.length > 0 ? { options: q.options } : {}
7676
- }))
7677
- }
7678
- };
7679
- }
7680
- if (!question) return {};
7681
- return {
7682
- ask: {
7683
- targetUserId,
7684
- question,
7685
- // CT400: the optional one-sentence headline question, when supplied.
7686
- ...headline ? { headline } : {},
7687
- ...options && options.length > 0 ? { options } : {}
7688
- }
7689
- };
7690
- }
7691
- // CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
7692
- // The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
7693
- // null = no wake this turn → attach nothing). The server computes `fire_at` and
7694
- // arms the CT441 schedule. Kept independent of send/ask — a turn could
7695
- // conceivably ask AND arm a wake.
7696
- //
7697
- // CT990: the field now carries three states, and the third can't be spelled by
7698
- // absence — attaching NOTHING means "leave the standing wake alone", which is
7699
- // exactly what a stand-down must not do. So a `cancel_wake` turn attaches the
7700
- // discriminated `{ cancel: true }` payload instead.
7701
- wakeField() {
7702
- return wakeCommitField(this.deps.wakeState);
7703
- }
7704
7739
  };
7705
7740
 
7706
7741
  // src/turn-execution.ts
@@ -7791,7 +7826,7 @@ var TurnExecution = class {
7791
7826
  agentId: payload.agentId,
7792
7827
  messageId: payload.messageId
7793
7828
  });
7794
- this.turnId = handleOpts.turnId ?? randomUUID();
7829
+ this.turnId = handleOpts.turnId ?? randomUUID2();
7795
7830
  }
7796
7831
  opts;
7797
7832
  supervisor;
@@ -7811,12 +7846,8 @@ var TurnExecution = class {
7811
7846
  effectiveCwd;
7812
7847
  hookEnv;
7813
7848
  turnEnv;
7814
- sendState;
7815
7849
  skipState;
7816
- askState;
7817
- wakeState;
7818
7850
  replyState;
7819
- controlOrder;
7820
7851
  turnControlServer;
7821
7852
  request;
7822
7853
  adapter;
@@ -7986,7 +8017,7 @@ var TurnExecution = class {
7986
8017
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
7987
8018
  body: `${MISSING_SECRET_PREFIX} ${list}`,
7988
8019
  kind: "final",
7989
- turnId: randomUUID(),
8020
+ turnId: randomUUID2(),
7990
8021
  // CT113: even a turn that fails before it runs answers a message.
7991
8022
  parentMessageId: payload.messageId
7992
8023
  });
@@ -8193,19 +8224,12 @@ ${reason}`,
8193
8224
  }
8194
8225
  buildRequest() {
8195
8226
  const { payload, workspaceId } = this;
8196
- const sendState = this.sendState = createSendState();
8197
8227
  const skipState = this.skipState = createSkipState();
8198
- const askState = this.askState = createAskState();
8199
- const wakeState = this.wakeState = createWakeState();
8200
8228
  const replyState = this.replyState = createReplyState();
8201
- const controlOrder = this.controlOrder = createTurnControlOrder();
8202
8229
  const turnControlServer = createTurnControlMcpServer(
8203
- sendState,
8230
+ { api: this.opts.api, workspaceId, turnId: this.turnId },
8204
8231
  skipState,
8205
- askState,
8206
- wakeState,
8207
- replyState,
8208
- controlOrder
8232
+ replyState
8209
8233
  );
8210
8234
  this.request = buildCompanionTurnRequest({
8211
8235
  turnContext: this.turnContext,
@@ -8307,16 +8331,6 @@ ${reason}`,
8307
8331
  signal: abortController.signal,
8308
8332
  log: turnLog,
8309
8333
  nextSeq: this.nextSeq,
8310
- // The committer reads this at commit to carry the addressed send on the
8311
- // terminal row; the server writes the send itself as a distinct message.
8312
- sendState: this.sendState,
8313
- // CT326: likewise the ask payload. CT1281: the server writes the ask as its
8314
- // own addressed message to the human — which ENQUEUES like any other send —
8315
- // and creates the `asks` row against that carrier, not against turn speech.
8316
- askState: this.askState,
8317
- // CT442: likewise the wake payload — attached to the `final` row so the
8318
- // server arms the wake schedule atomically with the reply it rode on.
8319
- wakeState: this.wakeState,
8320
8334
  replyState: this.replyState,
8321
8335
  // The ledger-derived owed reply, for the runtime's own declaration when
8322
8336
  // the agent doesn't call `reply_to` (resolveDeclaredReplyField).
@@ -8339,41 +8353,8 @@ ${reason}`,
8339
8353
  payload.agentId,
8340
8354
  turnId
8341
8355
  );
8342
- if (intent.ask) {
8343
- this.askState.targetUserId = intent.ask.targetUserId;
8344
- if (intent.ask.questions && intent.ask.questions.length > 0) {
8345
- this.askState.questions = intent.ask.questions;
8346
- this.askState.question = null;
8347
- this.askState.headline = null;
8348
- this.askState.options = null;
8349
- } else {
8350
- this.askState.question = intent.ask.question ?? null;
8351
- this.askState.headline = intent.ask.headline ?? null;
8352
- this.askState.options = intent.ask.options ?? null;
8353
- this.askState.questions = null;
8354
- }
8355
- }
8356
- if (intent.wake) {
8357
- if ("cancel" in intent.wake) {
8358
- this.wakeState.cancelled = true;
8359
- this.wakeState.afterSeconds = null;
8360
- this.wakeState.at = null;
8361
- this.wakeState.note = null;
8362
- } else {
8363
- this.wakeState.cancelled = false;
8364
- this.wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
8365
- this.wakeState.at = intent.wake.at ?? null;
8366
- this.wakeState.note = intent.wake.note;
8367
- }
8368
- }
8369
- if (intent.sendAgentId && intent.sendBody) {
8370
- this.sendState.agentId = intent.sendAgentId;
8371
- this.sendState.message = intent.sendBody;
8372
- this.sendState.order = intent.sendOrder ?? null;
8373
- }
8374
8356
  if (intent.answersMessageId) {
8375
8357
  this.replyState.answersMessageId = intent.answersMessageId;
8376
- this.replyState.order = intent.replyOrder ?? null;
8377
8358
  }
8378
8359
  if (intent.skipped) {
8379
8360
  this.skipState.skipped = true;
@@ -8382,7 +8363,7 @@ ${reason}`,
8382
8363
  } catch (err) {
8383
8364
  turnLog.warn(
8384
8365
  { err: err instanceof Error ? err.message : String(err) },
8385
- "dispatcher: turn-control intent fetch failed; turn-control effects for this turn are dropped"
8366
+ "dispatcher: turn-control intent fetch failed; the declared reply / skip for this turn are dropped"
8386
8367
  );
8387
8368
  }
8388
8369
  };
@@ -8525,15 +8506,13 @@ ${reason}`,
8525
8506
  { reason: this.skipState.reason, turnId, ok: o.okResult },
8526
8507
  "agent skipped turn (skip_turn)"
8527
8508
  );
8528
- const { wake: skipWake } = wakeCommitField(this.wakeState);
8529
8509
  try {
8530
8510
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8531
8511
  body: SKIPPED_MARKER_BODY,
8532
8512
  kind: "skipped",
8533
8513
  turnId,
8534
8514
  seq: this.nextSeq(),
8535
- parentMessageId: payload.messageId,
8536
- ...skipWake ? { wake: skipWake } : {}
8515
+ parentMessageId: payload.messageId
8537
8516
  });
8538
8517
  } catch (err) {
8539
8518
  turnLog.warn(
@@ -8551,9 +8530,9 @@ ${reason}`,
8551
8530
  turnId,
8552
8531
  seq: this.nextSeq(),
8553
8532
  parentMessageId: payload.messageId,
8554
- // ask, wake and send must survive a wordless turn exactly as
8555
- // they survive a textual final; dropping one can strand a person
8556
- // or the next actor with no visible failure.
8533
+ // An explicit `reply_to` survives a wordless turn exactly as it
8534
+ // survives a textual final (the auto-declaration does not a
8535
+ // marker has no answer to bind).
8557
8536
  ...committer.turnControlFields("silent")
8558
8537
  });
8559
8538
  o.silentMarkerEmitted = true;
@@ -9865,7 +9844,7 @@ var CompanionSupervisor = class {
9865
9844
  );
9866
9845
  }
9867
9846
  const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
9868
- const turnId = resumedTurnId ?? randomUUID2();
9847
+ const turnId = resumedTurnId ?? randomUUID3();
9869
9848
  if (ev.id) {
9870
9849
  const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
9871
9850
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
@@ -10273,7 +10252,7 @@ async function createCompanionRuntime(opts = {}) {
10273
10252
  opencodeServerUrl: cfg.opencode?.serverUrl,
10274
10253
  codex: isCodexEnabled(cfg)
10275
10254
  });
10276
- const instanceId = randomUUID3();
10255
+ const instanceId = randomUUID4();
10277
10256
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
10278
10257
  const pre = readLiveRuntimeState();
10279
10258
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();