@cabane/companion 0.6.202 → 0.6.203

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 +429 -113
  2. package/dist/runtime.js +264 -28
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1395,6 +1395,18 @@ var init_boot_id = __esm({
1395
1395
  function activeRunOutboxKey(conversationId, agentId) {
1396
1396
  return `activerun:${conversationId}:${agentId}`;
1397
1397
  }
1398
+ function isAlreadyPublished(err) {
1399
+ return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "already_published";
1400
+ }
1401
+ function trailOf(body) {
1402
+ const b = body;
1403
+ return {
1404
+ turnId: b.turnId,
1405
+ text: b.body,
1406
+ ...b.seq !== void 0 ? { seq: b.seq } : {},
1407
+ ...b.executionSourceId !== void 0 ? { executionSourceId: b.executionSourceId } : {}
1408
+ };
1409
+ }
1398
1410
  function isRetryable(err) {
1399
1411
  if (err instanceof ApiError) return err.status >= 500;
1400
1412
  if (isAbortError(err)) return false;
@@ -1522,7 +1534,7 @@ var init_api = __esm({
1522
1534
  // text use (turnId, seq) within each kind for nonzero seq; tool activity
1523
1535
  // uses (turnId, toolUseId). Legacy zero/missing seq bypasses row deduplication.
1524
1536
  // Server fences and the outbox's discard/eviction bounds still apply.
1525
- async durableCommit(kind, path, body, turnId, seq, signal) {
1537
+ async durableCommit(kind, path, body, turnId, seq, signal, trailPath) {
1526
1538
  try {
1527
1539
  await this.request("POST", path, body, {
1528
1540
  retry: true,
@@ -1533,7 +1545,16 @@ var init_api = __esm({
1533
1545
  if (!outbox) throw err;
1534
1546
  if (signal?.aborted || isAbortError(err)) throw err;
1535
1547
  if (!isRetryable(err)) throw err;
1536
- outbox.persist({ enqueuedAt: Date.now(), turnId, seq, method: "POST", path, body, kind });
1548
+ outbox.persist({
1549
+ enqueuedAt: Date.now(),
1550
+ turnId,
1551
+ seq,
1552
+ method: "POST",
1553
+ path,
1554
+ body,
1555
+ kind,
1556
+ ...trailPath ? { trailPath } : {}
1557
+ });
1537
1558
  this.opts.log?.debug(
1538
1559
  { kind, turnId, seq, err: err instanceof Error ? err.message : String(err) },
1539
1560
  "companion: commit queued to outbox after transient failure (will drain when the API returns)"
@@ -1558,6 +1579,29 @@ var init_api = __esm({
1558
1579
  progressed = true;
1559
1580
  } catch (err) {
1560
1581
  if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
1582
+ if (entry.kind === "message" && entry.trailPath && isAlreadyPublished(err)) {
1583
+ try {
1584
+ await this.request("POST", entry.trailPath, trailOf(entry.body), {
1585
+ retry: true
1586
+ });
1587
+ } catch (trailErr) {
1588
+ if (!(trailErr instanceof ApiError && trailErr.status >= 400 && trailErr.status < 500)) {
1589
+ break;
1590
+ }
1591
+ this.opts.log?.warn(
1592
+ {
1593
+ kind: entry.kind,
1594
+ turnId: entry.turnId,
1595
+ seq: entry.seq,
1596
+ ...apiErrorLogFields(trailErr)
1597
+ },
1598
+ `Cabane rejected a postscript; it could not be filed. ${mayNeedUpdate()}`
1599
+ );
1600
+ }
1601
+ outbox.remove(entry.turnId, entry.seq);
1602
+ progressed = true;
1603
+ continue;
1604
+ }
1561
1605
  if (entry.kind === "active-run" && isTurnEndedRefusal(err)) {
1562
1606
  this.opts.log?.debug(
1563
1607
  { kind: entry.kind, turnId: entry.turnId, ...apiErrorLogFields(err) },
@@ -1633,6 +1677,19 @@ var init_api = __esm({
1633
1677
  (e) => e.turnId === turnId && e.kind === "message" && e.body?.kind !== "runtime_notice"
1634
1678
  );
1635
1679
  }
1680
+ // The `seq` of this turn's SELECTED ANSWER still waiting in the outbox — the
1681
+ // `declared` commit the server's `answerArmed` cannot see — or null when none
1682
+ // is queued. A resumed run that re-armed over it would let a newer text
1683
+ // consume the arm first; with the selection queued, the resumed run starts
1684
+ // declared, its texts are trail, and the drain delivers the selection.
1685
+ outboxDeclaredSeqForTurn(turnId) {
1686
+ const outbox = this.opts.outbox;
1687
+ if (!outbox) return null;
1688
+ const queued = outbox.list().find(
1689
+ (e) => e.turnId === turnId && e.kind === "message" && e.body?.declared === true
1690
+ );
1691
+ return queued ? queued.seq : null;
1692
+ }
1636
1693
  // ADOPT a turn context's issuance as the execution source of its work, once,
1637
1694
  // before preparation emits or queues anything, so every row can name a source
1638
1695
  // the server durably recorded and will never reap. Adoption is idempotent, so
@@ -1806,15 +1863,36 @@ var init_api = __esm({
1806
1863
  // seq also orders speech against the turn's tool/thinking/text trail.
1807
1864
  // Transport/5xx failures get bounded retries and optional outbox delivery;
1808
1865
  // terminal 4xx and cancellation stop delivery rather than proving no commit.
1809
- postTurnMessage(workspaceId, conversationId, body, signal) {
1810
- return this.durableCommit(
1811
- "message",
1812
- `/api/workspaces/${workspaceId}/conversations/${conversationId}/messages`,
1813
- body,
1814
- body.turnId,
1815
- body.seq ?? 0,
1816
- signal
1817
- );
1866
+ //
1867
+ // A turn's answer can be out before a later speech commit arrives — the
1868
+ // declaration published it, or another host's selection drained first — and
1869
+ // the server refuses the commit `409 already_published`. The text is a
1870
+ // postscript then: with `trail` (the committing agent, which names the text
1871
+ // route), it is filed as trail at the same seq, live here and from the outbox
1872
+ // on a drain. Without `trail` (a runtime notice) the refusal is terminal.
1873
+ async postTurnMessage(workspaceId, conversationId, body, signal, trail) {
1874
+ const trailPath = trail ? `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${trail.agentId}/text` : void 0;
1875
+ try {
1876
+ await this.durableCommit(
1877
+ "message",
1878
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/messages`,
1879
+ body,
1880
+ body.turnId,
1881
+ body.seq ?? 0,
1882
+ signal,
1883
+ trailPath
1884
+ );
1885
+ } catch (err) {
1886
+ if (!trailPath || !isAlreadyPublished(err)) throw err;
1887
+ await this.durableCommit(
1888
+ "text",
1889
+ trailPath,
1890
+ trailOf(body),
1891
+ body.turnId,
1892
+ body.seq ?? 0,
1893
+ signal
1894
+ );
1895
+ }
1818
1896
  }
1819
1897
  // Report a tool transition (start/done/error). The server checks span
1820
1898
  // ownership, upserts by (turnId, toolUseId), and publishes agent_activity SSE.
@@ -2017,6 +2095,8 @@ var init_turn_diagnostics = __esm({
2017
2095
  turnSessionModes = ["fresh", "resumed", "degraded"];
2018
2096
  turnRuntimeResultKinds = ["success", "error", "no_terminal"];
2019
2097
  turnFinalSources = [
2098
+ // The agent declared its answer with the `answer` turn act (CT1973).
2099
+ "declared",
2020
2100
  "runtime_text",
2021
2101
  "progress_promotion",
2022
2102
  "host_fallback",
@@ -2495,39 +2575,145 @@ var init_transcript_sink = __esm({
2495
2575
  }
2496
2576
  });
2497
2577
 
2578
+ // packages/agent-runtime/src/turn-control/descriptions.ts
2579
+ var MAX_ASK_QUESTION_BYTES, MAX_ASK_HEADLINE_BYTES, MAX_ASK_ITEMS, MAX_ASK_OPTION_BYTES, ASK_OPTION_NOT_A_STRING, MIN_ASK_OPTIONS, MAX_ASK_OPTIONS, ASK_TARGET_PATTERN, ASK_TARGET_INVALID, MAX_WAKE_NOTE_BYTES, MAX_WITHDRAW_REASON_BYTES, MAX_SKIP_REASON_BYTES, TURN_CONTROL_MCP_SERVER, TURN_CONTROL_TOOLS, TURN_CONTROL_TOOL_DESCRIPTIONS, TURN_CONTROL_FIELD_DESCRIPTIONS;
2580
+ var init_descriptions = __esm({
2581
+ "packages/agent-runtime/src/turn-control/descriptions.ts"() {
2582
+ "use strict";
2583
+ MAX_ASK_QUESTION_BYTES = 400;
2584
+ MAX_ASK_HEADLINE_BYTES = 120;
2585
+ MAX_ASK_ITEMS = 20;
2586
+ MAX_ASK_OPTION_BYTES = 200;
2587
+ ASK_OPTION_NOT_A_STRING = "An ask option is a plain string label.";
2588
+ MIN_ASK_OPTIONS = 2;
2589
+ MAX_ASK_OPTIONS = 4;
2590
+ ASK_TARGET_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|@?[A-Za-z0-9-]{2,32})$/;
2591
+ ASK_TARGET_INVALID = "An ask target is a user id or an @handle.";
2592
+ MAX_WAKE_NOTE_BYTES = 2e3;
2593
+ MAX_WITHDRAW_REASON_BYTES = 400;
2594
+ MAX_SKIP_REASON_BYTES = 500;
2595
+ TURN_CONTROL_MCP_SERVER = "cabane_companion";
2596
+ TURN_CONTROL_TOOLS = [
2597
+ "answer",
2598
+ "ask",
2599
+ "cancel_wake",
2600
+ "skip_turn",
2601
+ "wake_me",
2602
+ "withdraw_ask"
2603
+ ];
2604
+ TURN_CONTROL_TOOL_DESCRIPTIONS = {
2605
+ answer: `Call this just before you write your answer: the text you write next is what the conversation keeps as your message. Call it once.`,
2606
+ ask: `Ask a person a structured question you need answered to continue, then end your turn; don't wait for the reply. \`targetUserId\` is a person on your roster, by their \`@handle\`; to bring in an agent, name them with \`@handle\` in your reply.
2607
+ - Form: one question (\`headline\`, optional \`question\` body, \`options\`) or \`questions\`, 1\u2013${MAX_ASK_ITEMS} \`{ headline, body?, options? }\`; never both. Decisions that arise together go in ONE call, in the order you want them answered. A second \`ask\` is a separate item, never an edit; raise one only for a genuinely new question.
2608
+ - \`headline\`: one sentence ending in \`?\`. Body (\`question\` or each item's \`body\`): a sentence or two of framing, not a report; inline Markdown only, no lists or headings. \`options\`: ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} one-click answers (always for a yes/no go-ahead), each a distinct course of action you can carry out without asking back.
2609
+ - Voice, in headline, body and options: "I" for yourself, never your own name; name everyone else who decides or acts, as on your roster. Never "you", "your", "we", "us", "our" or their contractions, nor "he", "she" or "they" for a named actor. Each option reads correctly alone. A quotation stays accurate: say who said it.
2610
+ - It must stand alone, since it can arrive first: the reply carries the findings, the ask the question; don't repeat the reply in it.
2611
+ - Recorded when you call this; delivered when your turn ends, or after 3 minutes if it is still running. Ask as soon as you know. The answer reaches you on a later turn.
2612
+ - The person sends one response; unanswered questions end as Unanswered. A person can dismiss an ask; you aren't told and nothing wakes you. Check an ask's status before raising it again.
2613
+ - To replace a stale ask (yours or anyone's here), pass \`replaces: { askId, reason }\`: both land in one write and the person sees the reason. To withdraw without a replacement, use \`withdraw_ask\`.`,
2614
+ cancel_wake: `Stand down the wake you have armed in this conversation, now, when you call it. No arguments. Use it when what the wake was to check has already happened or the work it watched is over; an unneeded wake fires into a turn with nothing to do. Your turn context prints the armed wake ("Wake armed: \u2026"); one no longer printed has fired or been dismissed. Nothing armed is a clean no-op. Calls act in order: \`cancel_wake\` then \`wake_me\` leaves the new wake armed. It reaches only your own wake here, never a peer's and never a recurring schedule (\`schedules.*\`).`,
2615
+ skip_turn: `End your current turn WITHOUT posting a reply. Call this when you've been woken to read a message that genuinely doesn't need a response from you \u2014 a thanks/aside, a question already answered, chatter outside your lane, or something another participant clearly owns. Your turn ends silently: no message bubble is posted. The \`reason\` is a short free-text note for telemetry (e.g. "already answered by cabane", "thanks, nothing to add"). Prefer this over posting a low-value "ok!"/"got it" reply. Don't also write a reply when you skip \u2014 skipping IS the whole turn.`,
2616
+ wake_me: `Wake yourself later: end this turn now and be re-dispatched at a time you pick, with a note to yourself. Use it to wait for a condition you can name (a PR merging, a session limit resetting, an answer landing): arm, end the turn, and you're woken to CHECK and either act or re-arm. Most wakes are short, seconds to a couple of hours. Reach past a few hours only when a person asked for that timing or the wait is pinned to a real external event (a report that runs Mondays); if no one asked and you can't name both what clears the wait and why it takes that long, don't arm it: finish now, or raise an \`ask\`. Pass EXACTLY ONE of \`afterSeconds\` (\`300\` for five minutes) or \`at\` (an ISO-8601 timestamp WITH a zone, e.g. \`2026-07-16T09:00:00-07:00\`, which YOU compute; nothing parses natural-language time). \`note\` becomes the body of the message that re-dispatches you: write the condition to re-check. The wake arms NOW, when you call this; a later \`wake_me\` in the same turn replaces it, \`cancel_wake\` stands it down, and one that comes due mid-turn is delivered after your reply. This is the ONLY way to schedule your own continuation; never a host cron or scheduler. Floor 60s, horizon 14 days, enforced here so you can recompute. Widen the interval as a loop ages (5m \u2192 15m \u2192 1h\u2026); after many re-arms with no other activity you'll be steered to an \`ask\` instead.`,
2617
+ withdraw_ask: `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.`
2618
+ };
2619
+ TURN_CONTROL_FIELD_DESCRIPTIONS = {
2620
+ answer: {},
2621
+ ask: {
2622
+ targetUserId: "The person to ask: their `@handle` from your turn context's roster (preferred), or their user id.",
2623
+ question: `SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (up to ${MAX_ASK_QUESTION_BYTES} characters, inline markdown only). Provide EITHER this or \`questions\`, not both. Put the crisp one-sentence question in \`headline\`.`,
2624
+ headline: `SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in \`?\` ("Should Ledger deploy once Mia approves?"), up to ${MAX_ASK_HEADLINE_BYTES} characters. What the person reads first in For you and on the chip \u2014 one scannable question, no elaboration (that goes in \`question\`). Strongly encouraged.`,
2625
+ options: `SINGLE-question form: optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} suggested one-click answers, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must make sense on its own.`,
2626
+ questions: `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.`,
2627
+ "questions.headline": `The one-sentence question ("Should Ledger deploy once Mia approves?") \u2014 required for each item, up to ${MAX_ASK_HEADLINE_BYTES} characters.`,
2628
+ "questions.body": `Optional short framing beneath the headline, up to ${MAX_ASK_QUESTION_BYTES} characters, inline markdown only. NOT a report.`,
2629
+ "questions.options": `Optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} one-click answers for this question, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must make sense on its own.`,
2630
+ replaces: "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.",
2631
+ "replaces.askId": "The id of the open ask to withdraw.",
2632
+ "replaces.reason": `Why the old question is no longer the question \u2014 shown to the person on their Done row. Up to ${MAX_WITHDRAW_REASON_BYTES} characters.`
2633
+ },
2634
+ cancel_wake: {},
2635
+ skip_turn: {
2636
+ reason: `Short reason you are declining \u2014 used for telemetry/debugging, never posted. Up to ${MAX_SKIP_REASON_BYTES} characters.`
2637
+ },
2638
+ wake_me: {
2639
+ afterSeconds: "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.",
2640
+ at: "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.",
2641
+ note: `A note to your future self \u2014 becomes the body of the wake message that re-dispatches you, up to ${MAX_WAKE_NOTE_BYTES} characters. Write the condition to re-check in a sentence or two ("check whether the PR merged"), not a plan. You never need to name yourself: the wake reaches you on its own.`
2642
+ },
2643
+ withdraw_ask: {
2644
+ askId: "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.",
2645
+ reason: `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. Up to ${MAX_WITHDRAW_REASON_BYTES} characters.`
2646
+ }
2647
+ };
2648
+ }
2649
+ });
2650
+
2498
2651
  // packages/agent-runtime/src/pump.ts
2499
- var THINKING_TEXT_MAX_CHARS, TurnPump;
2652
+ function isTurnControlAnswer(evt) {
2653
+ if (evt.mcpServer !== TURN_CONTROL_MCP_SERVER) return false;
2654
+ const name = evt.toolName;
2655
+ return name === ANSWER_TOOL || name === `${TURN_CONTROL_MCP_SERVER}.${ANSWER_TOOL}` || name === `${TURN_CONTROL_MCP_SERVER}_${ANSWER_TOOL}`;
2656
+ }
2657
+ var ANSWER_TOOL, THINKING_TEXT_MAX_CHARS, TurnPump;
2500
2658
  var init_pump = __esm({
2501
2659
  "packages/agent-runtime/src/pump.ts"() {
2502
2660
  "use strict";
2503
2661
  init_empty_result();
2504
2662
  init_transcript();
2663
+ init_descriptions();
2664
+ ANSWER_TOOL = "answer";
2505
2665
  THINKING_TEXT_MAX_CHARS = 3900;
2506
2666
  TurnPump = class {
2507
2667
  constructor(opts) {
2508
2668
  this.opts = opts;
2509
2669
  this.hasSpoken = opts.spoke ?? false;
2670
+ this.armed = opts.armed ?? false;
2671
+ if (opts.declaredSeq !== void 0) {
2672
+ this.hasSpoken = true;
2673
+ this.armed = false;
2674
+ this.declared = true;
2675
+ this.emittedFinal = true;
2676
+ this.finalSeq = opts.declaredSeq;
2677
+ this.emittedFinalSource = "declared";
2678
+ }
2510
2679
  this.sink = {
2511
2680
  onAssistantText: async (evt) => {
2512
2681
  if (opts.signal.aborted) return;
2513
2682
  const body = normalizeCommitText(evt.text);
2514
2683
  if (!body) return;
2515
2684
  const seq = opts.nextSeq();
2516
- if (!evt.final && this.hasSpoken) {
2685
+ if (this.armed) {
2686
+ await opts.commit.commitMessage({ body, kind: "message", seq, declared: true });
2687
+ this.armed = false;
2688
+ this.declared = true;
2689
+ this.hasSpoken = true;
2690
+ this.emittedFinal = true;
2691
+ this.finalSeq = seq;
2692
+ this.lastTextSeq = seq;
2693
+ this.finalReplyBody = body;
2694
+ this.emittedFinalSource = "declared";
2695
+ return;
2696
+ }
2697
+ if (this.declared || !evt.final && this.hasSpoken) {
2517
2698
  await opts.commit.reportText({ text: body, seq });
2699
+ this.lastTextSeq = seq;
2518
2700
  return;
2519
2701
  }
2520
2702
  await opts.commit.commitMessage({ body, kind: "message", seq });
2521
2703
  this.hasSpoken = true;
2704
+ this.lastTextSeq = seq;
2522
2705
  if (evt.final) {
2523
2706
  this.emittedFinal = true;
2524
- this.lastSeq = seq;
2707
+ this.finalSeq = seq;
2525
2708
  this.finalReplyBody = body;
2526
2709
  this.emittedFinalSource = "runtime_text";
2527
2710
  }
2528
2711
  },
2529
2712
  onToolActivity: async (evt) => {
2530
2713
  if (opts.signal.aborted) return;
2714
+ if (evt.phase === "done" && !this.declared && isTurnControlAnswer(evt)) {
2715
+ this.armed = true;
2716
+ }
2531
2717
  let call = this.toolCallByProviderId.get(evt.toolUseId);
2532
2718
  if (evt.phase === "start" || call === void 0) {
2533
2719
  call = { persistedId: crypto.randomUUID(), seq: opts.nextSeq() };
@@ -2568,9 +2754,16 @@ var init_pump = __esm({
2568
2754
  // A message has landed under this span — its first words. Every later
2569
2755
  // non-terminal text is the trail. Seeded from the host on a resume.
2570
2756
  hasSpoken;
2571
- // The last words landed, and their `seq` — what the settle declares.
2757
+ // An accepted `answer` act this turn has not yet written the text for.
2758
+ armed;
2759
+ // The answer committed with `declared: true`; every text after it is trail.
2760
+ declared = false;
2761
+ // The answer landed — declared, or the adapter's terminal text — and its `seq`.
2572
2762
  emittedFinal = false;
2573
- lastSeq = null;
2763
+ finalSeq = null;
2764
+ // The `seq` of the turn's last text through either door. Undeclared, this is
2765
+ // what the settle names: the server makes that text the body at the end.
2766
+ lastTextSeq = null;
2574
2767
  // The turn's final reply text, captured for the dashboard feed / logging.
2575
2768
  finalReplyBody = null;
2576
2769
  emittedFinalSource = "none";
@@ -2592,14 +2785,20 @@ var init_pump = __esm({
2592
2785
  get spoke() {
2593
2786
  return this.hasSpoken;
2594
2787
  }
2595
- // Whether the turn has committed its last words.
2788
+ // Whether the turn has committed its answer: declared, or the adapter's
2789
+ // terminal text.
2596
2790
  get finalEmitted() {
2597
2791
  return this.emittedFinal;
2598
2792
  }
2599
- // The `seq` of the last words, for the settle to declare — or null when the
2600
- // span ended without any. Nothing is put in their place.
2793
+ // What the settle names, by `seq`: the declared answer, or — undeclared —
2794
+ // the turn's last text through either door, which the server makes the body
2795
+ // at the end. Null when the turn wrote no text at all.
2601
2796
  get lastMessageSeq() {
2602
- return this.lastSeq;
2797
+ return this.declared ? this.finalSeq : this.lastTextSeq;
2798
+ }
2799
+ // Whether an accepted `answer` act is still waiting for its text.
2800
+ get answerArmed() {
2801
+ return this.armed;
2603
2802
  }
2604
2803
  // The turn's final reply text, or null if none landed (failure / cancel /
2605
2804
  // empty). Used for the dashboard feed's reply preview.
@@ -2613,75 +2812,6 @@ var init_pump = __esm({
2613
2812
  }
2614
2813
  });
2615
2814
 
2616
- // packages/agent-runtime/src/turn-control/descriptions.ts
2617
- var MAX_ASK_QUESTION_BYTES, MAX_ASK_HEADLINE_BYTES, MAX_ASK_ITEMS, MAX_ASK_OPTION_BYTES, ASK_OPTION_NOT_A_STRING, MIN_ASK_OPTIONS, MAX_ASK_OPTIONS, ASK_TARGET_PATTERN, ASK_TARGET_INVALID, MAX_WAKE_NOTE_BYTES, MAX_WITHDRAW_REASON_BYTES, MAX_SKIP_REASON_BYTES, TURN_CONTROL_TOOLS, TURN_CONTROL_TOOL_DESCRIPTIONS, TURN_CONTROL_FIELD_DESCRIPTIONS;
2618
- var init_descriptions = __esm({
2619
- "packages/agent-runtime/src/turn-control/descriptions.ts"() {
2620
- "use strict";
2621
- MAX_ASK_QUESTION_BYTES = 400;
2622
- MAX_ASK_HEADLINE_BYTES = 120;
2623
- MAX_ASK_ITEMS = 20;
2624
- MAX_ASK_OPTION_BYTES = 200;
2625
- ASK_OPTION_NOT_A_STRING = "An ask option is a plain string label.";
2626
- MIN_ASK_OPTIONS = 2;
2627
- MAX_ASK_OPTIONS = 4;
2628
- ASK_TARGET_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|@?[A-Za-z0-9-]{2,32})$/;
2629
- ASK_TARGET_INVALID = "An ask target is a user id or an @handle.";
2630
- MAX_WAKE_NOTE_BYTES = 2e3;
2631
- MAX_WITHDRAW_REASON_BYTES = 400;
2632
- MAX_SKIP_REASON_BYTES = 500;
2633
- TURN_CONTROL_TOOLS = [
2634
- "ask",
2635
- "cancel_wake",
2636
- "skip_turn",
2637
- "wake_me",
2638
- "withdraw_ask"
2639
- ];
2640
- TURN_CONTROL_TOOL_DESCRIPTIONS = {
2641
- ask: `Ask a person a structured question you need answered to continue, then end your turn; don't wait for the reply. \`targetUserId\` is a person on your roster, by their \`@handle\`; to bring in an agent, name them with \`@handle\` in your reply.
2642
- - Form: one question (\`headline\`, optional \`question\` body, \`options\`) or \`questions\`, 1\u2013${MAX_ASK_ITEMS} \`{ headline, body?, options? }\`; never both. Decisions that arise together go in ONE call, in the order you want them answered. A second \`ask\` is a separate item, never an edit; raise one only for a genuinely new question.
2643
- - \`headline\`: one sentence ending in \`?\`. Body (\`question\` or each item's \`body\`): a sentence or two of framing, not a report; inline Markdown only, no lists or headings. \`options\`: ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} one-click answers (always for a yes/no go-ahead), each a distinct course of action you can carry out without asking back.
2644
- - Voice, in headline, body and options: "I" for yourself, never your own name; name everyone else who decides or acts, as on your roster. Never "you", "your", "we", "us", "our" or their contractions, nor "he", "she" or "they" for a named actor. Each option reads correctly alone. A quotation stays accurate: say who said it.
2645
- - It must stand alone, since it can arrive first: the reply carries the findings, the ask the question; don't repeat the reply in it.
2646
- - Recorded when you call this; delivered when your turn ends, or after 3 minutes if it is still running. Ask as soon as you know. The answer reaches you on a later turn.
2647
- - The person sends one response; unanswered questions end as Unanswered. A person can dismiss an ask; you aren't told and nothing wakes you. Check an ask's status before raising it again.
2648
- - To replace a stale ask (yours or anyone's here), pass \`replaces: { askId, reason }\`: both land in one write and the person sees the reason. To withdraw without a replacement, use \`withdraw_ask\`.`,
2649
- cancel_wake: `Stand down the wake you have armed in this conversation, now, when you call it. No arguments. Use it when what the wake was to check has already happened or the work it watched is over; an unneeded wake fires into a turn with nothing to do. Your turn context prints the armed wake ("Wake armed: \u2026"); one no longer printed has fired or been dismissed. Nothing armed is a clean no-op. Calls act in order: \`cancel_wake\` then \`wake_me\` leaves the new wake armed. It reaches only your own wake here, never a peer's and never a recurring schedule (\`schedules.*\`).`,
2650
- skip_turn: `End your current turn WITHOUT posting a reply. Call this when you've been woken to read a message that genuinely doesn't need a response from you \u2014 a thanks/aside, a question already answered, chatter outside your lane, or something another participant clearly owns. Your turn ends silently: no message bubble is posted. The \`reason\` is a short free-text note for telemetry (e.g. "already answered by cabane", "thanks, nothing to add"). Prefer this over posting a low-value "ok!"/"got it" reply. Don't also write a reply when you skip \u2014 skipping IS the whole turn.`,
2651
- wake_me: `Wake yourself later: end this turn now and be re-dispatched at a time you pick, with a note to yourself. Use it to wait for a condition you can name (a PR merging, a session limit resetting, an answer landing): arm, end the turn, and you're woken to CHECK and either act or re-arm. Most wakes are short, seconds to a couple of hours. Reach past a few hours only when a person asked for that timing or the wait is pinned to a real external event (a report that runs Mondays); if no one asked and you can't name both what clears the wait and why it takes that long, don't arm it: finish now, or raise an \`ask\`. Pass EXACTLY ONE of \`afterSeconds\` (\`300\` for five minutes) or \`at\` (an ISO-8601 timestamp WITH a zone, e.g. \`2026-07-16T09:00:00-07:00\`, which YOU compute; nothing parses natural-language time). \`note\` becomes the body of the message that re-dispatches you: write the condition to re-check. The wake arms NOW, when you call this; a later \`wake_me\` in the same turn replaces it, \`cancel_wake\` stands it down, and one that comes due mid-turn is delivered after your reply. This is the ONLY way to schedule your own continuation; never a host cron or scheduler. Floor 60s, horizon 14 days, enforced here so you can recompute. Widen the interval as a loop ages (5m \u2192 15m \u2192 1h\u2026); after many re-arms with no other activity you'll be steered to an \`ask\` instead.`,
2652
- withdraw_ask: `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.`
2653
- };
2654
- TURN_CONTROL_FIELD_DESCRIPTIONS = {
2655
- ask: {
2656
- targetUserId: "The person to ask: their `@handle` from your turn context's roster (preferred), or their user id.",
2657
- question: `SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (up to ${MAX_ASK_QUESTION_BYTES} characters, inline markdown only). Provide EITHER this or \`questions\`, not both. Put the crisp one-sentence question in \`headline\`.`,
2658
- headline: `SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in \`?\` ("Should Ledger deploy once Mia approves?"), up to ${MAX_ASK_HEADLINE_BYTES} characters. What the person reads first in For you and on the chip \u2014 one scannable question, no elaboration (that goes in \`question\`). Strongly encouraged.`,
2659
- options: `SINGLE-question form: optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} suggested one-click answers, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must make sense on its own.`,
2660
- questions: `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.`,
2661
- "questions.headline": `The one-sentence question ("Should Ledger deploy once Mia approves?") \u2014 required for each item, up to ${MAX_ASK_HEADLINE_BYTES} characters.`,
2662
- "questions.body": `Optional short framing beneath the headline, up to ${MAX_ASK_QUESTION_BYTES} characters, inline markdown only. NOT a report.`,
2663
- "questions.options": `Optional ${MIN_ASK_OPTIONS}\u2013${MAX_ASK_OPTIONS} one-click answers for this question, up to ${MAX_ASK_OPTION_BYTES} characters each. Each option must make sense on its own.`,
2664
- replaces: "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.",
2665
- "replaces.askId": "The id of the open ask to withdraw.",
2666
- "replaces.reason": `Why the old question is no longer the question \u2014 shown to the person on their Done row. Up to ${MAX_WITHDRAW_REASON_BYTES} characters.`
2667
- },
2668
- cancel_wake: {},
2669
- skip_turn: {
2670
- reason: `Short reason you are declining \u2014 used for telemetry/debugging, never posted. Up to ${MAX_SKIP_REASON_BYTES} characters.`
2671
- },
2672
- wake_me: {
2673
- afterSeconds: "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.",
2674
- at: "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.",
2675
- note: `A note to your future self \u2014 becomes the body of the wake message that re-dispatches you, up to ${MAX_WAKE_NOTE_BYTES} characters. Write the condition to re-check in a sentence or two ("check whether the PR merged"), not a plan. You never need to name yourself: the wake reaches you on its own.`
2676
- },
2677
- withdraw_ask: {
2678
- askId: "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.",
2679
- reason: `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. Up to ${MAX_WITHDRAW_REASON_BYTES} characters.`
2680
- }
2681
- };
2682
- }
2683
- });
2684
-
2685
2815
  // packages/agent-runtime/src/turn-control/prompt-guidance.ts
2686
2816
  function turnControlPromptGuidance(prefix) {
2687
2817
  const controls = TURN_CONTROL_TOOLS.map((name) => `\`${prefix}${name}\` (${MEANINGS[name]})`);
@@ -2693,6 +2823,7 @@ var init_prompt_guidance = __esm({
2693
2823
  "use strict";
2694
2824
  init_descriptions();
2695
2825
  MEANINGS = {
2826
+ answer: "declare the text you write next as your answer",
2696
2827
  ask: "put a structured question to a person",
2697
2828
  cancel_wake: "cancel your own armed wake in this conversation",
2698
2829
  skip_turn: "end without posting a reply",
@@ -4231,6 +4362,15 @@ var init_events = __esm({
4231
4362
  });
4232
4363
 
4233
4364
  // packages/agent-runtime/src/opencode/loop.ts
4365
+ function opencodeMcpServerOf(tool2, servers) {
4366
+ let best = null;
4367
+ for (const server of servers ?? []) {
4368
+ if (tool2.length > server.length + 1 && tool2.startsWith(`${server}_`)) {
4369
+ if (best === null || server.length > best.length) best = server;
4370
+ }
4371
+ }
4372
+ return best;
4373
+ }
4234
4374
  async function* decodeOpencodeStream(events, ctx2) {
4235
4375
  let held = null;
4236
4376
  const pending = /* @__PURE__ */ new Map();
@@ -4257,12 +4397,14 @@ async function* decodeOpencodeStream(events, ctx2) {
4257
4397
  if (sealed) yield sealed;
4258
4398
  const key = part.callID ?? part.id;
4259
4399
  const input = part.state.input;
4400
+ const server = opencodeMcpServerOf(part.tool, ctx2.mcpServers);
4401
+ const origin = server ? { mcpServer: server } : {};
4260
4402
  if (!startedTools.has(key)) {
4261
4403
  const name = prettyToolName(part.tool);
4262
4404
  const summary = summarizeToolInput(input);
4263
4405
  pending.set(key, { name, summary, input });
4264
4406
  startedTools.add(key);
4265
- yield { type: "tool", id: key, name, phase: "start", summary, input };
4407
+ yield { type: "tool", id: key, name, phase: "start", summary, input, ...origin };
4266
4408
  }
4267
4409
  const entry = pending.get(key);
4268
4410
  if (entry && hasToolInput(input)) {
@@ -4282,7 +4424,8 @@ async function* decodeOpencodeStream(events, ctx2) {
4282
4424
  phase,
4283
4425
  summary: started?.summary ?? "",
4284
4426
  input: started?.input ?? input,
4285
- result
4427
+ result,
4428
+ ...origin
4286
4429
  };
4287
4430
  }
4288
4431
  } else {
@@ -4480,15 +4623,15 @@ function buildMcp(req) {
4480
4623
  function isStringRecord(v) {
4481
4624
  return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
4482
4625
  }
4483
- var CABANE_MCP_SERVER2, TURN_CONTROL_MCP_SERVER, ACTIVE_CONVERSATION_HEADER2;
4626
+ var CABANE_MCP_SERVER2, ACTIVE_CONVERSATION_HEADER2;
4484
4627
  var init_run_spec = __esm({
4485
4628
  "packages/agent-runtime/src/opencode/run-spec.ts"() {
4486
4629
  "use strict";
4630
+ init_descriptions();
4487
4631
  init_mcp_secret_env();
4488
4632
  init_policy2();
4489
4633
  init_model2();
4490
4634
  CABANE_MCP_SERVER2 = "cabane";
4491
- TURN_CONTROL_MCP_SERVER = "cabane_companion";
4492
4635
  ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
4493
4636
  }
4494
4637
  });
@@ -4799,7 +4942,10 @@ function createOpencodeAdapter(deps = {}) {
4799
4942
  }
4800
4943
  yield* decodeOpencodeStream(result.events, {
4801
4944
  signal,
4802
- sessionId: result.sessionId
4945
+ sessionId: result.sessionId,
4946
+ // The keys the turn's servers were installed under: what the decoder
4947
+ // splits a raw `<server>_<tool>` id on.
4948
+ mcpServers: Object.keys(spec.mcp)
4803
4949
  });
4804
4950
  }
4805
4951
  };
@@ -4961,7 +5107,15 @@ var init_conformance3 = __esm({
4961
5107
  expected: [
4962
5108
  sessionEvent2(NEW_SESSION_ID),
4963
5109
  // start: fires off the first (empty) snapshot — empty input + summary, by design.
4964
- { type: "tool", id: "call_1", name: "cabane_list", phase: "start", summary: "", input: {} },
5110
+ {
5111
+ type: "tool",
5112
+ id: "call_1",
5113
+ name: "cabane_list",
5114
+ phase: "start",
5115
+ summary: "",
5116
+ input: {},
5117
+ mcpServer: "cabane"
5118
+ },
4965
5119
  // done: carries the full args captured from the later snapshot, and the
4966
5120
  // summary recomputed from them — NOT the first-seen `{}`.
4967
5121
  {
@@ -4971,11 +5125,63 @@ var init_conformance3 = __esm({
4971
5125
  phase: "done",
4972
5126
  summary: "/product/tasks",
4973
5127
  input: { path: "/product/tasks" },
4974
- result: "CT301.md"
5128
+ result: "CT301.md",
5129
+ mcpServer: "cabane"
4975
5130
  },
4976
5131
  { type: "result", ok: true }
4977
5132
  ]
4978
5133
  },
5134
+ {
5135
+ // The turn-control server's `answer` (CT1973). opencode names an MCP tool `<server>_<tool>`,
5136
+ // so the installed keys split the raw id: `cabane_companion_answer` is the turn-control
5137
+ // server's, never the workspace server's `companion_answer`. The pump binds on that.
5138
+ name: "turn-control answer is read as the turn-control server\u2019s, not cabane\u2019s",
5139
+ request: makeRequest2({
5140
+ cabane: {
5141
+ mcpUrl: "https://cabane.test/api/mcp",
5142
+ turnControlUrl: "https://cabane.test/api/turn-control",
5143
+ bearer: "tok",
5144
+ activeConversationId: "conv"
5145
+ }
5146
+ }),
5147
+ nativeStream: [
5148
+ textPart("p1", "On it."),
5149
+ toolPart("t1", "cabane_companion_answer", { status: "running", input: {} }, "call_1"),
5150
+ toolPart(
5151
+ "t1",
5152
+ "cabane_companion_answer",
5153
+ { status: "completed", input: {}, output: '{"ok":true,"kind":"answer"}' },
5154
+ "call_1"
5155
+ ),
5156
+ textPart("p2", "The answer."),
5157
+ idle()
5158
+ ],
5159
+ expected: [
5160
+ sessionEvent2(NEW_SESSION_ID),
5161
+ { type: "text", body: "On it.", terminal: false },
5162
+ {
5163
+ type: "tool",
5164
+ id: "call_1",
5165
+ name: "cabane_companion_answer",
5166
+ phase: "start",
5167
+ summary: "",
5168
+ input: {},
5169
+ mcpServer: "cabane_companion"
5170
+ },
5171
+ {
5172
+ type: "tool",
5173
+ id: "call_1",
5174
+ name: "cabane_companion_answer",
5175
+ phase: "done",
5176
+ summary: "",
5177
+ input: {},
5178
+ result: '{"ok":true,"kind":"answer"}',
5179
+ mcpServer: "cabane_companion"
5180
+ },
5181
+ { type: "text", body: "The answer.", terminal: true },
5182
+ { type: "result", ok: true }
5183
+ ]
5184
+ },
4979
5185
  {
4980
5186
  // Highest risk: cancel mid-stream. The abort lands after the tool result; the adapter stops before
4981
5187
  // the closing reply — no final text, no `result` event (the server writes the stopped marker).
@@ -5339,6 +5545,7 @@ function readToolItem(item, eventType) {
5339
5545
  name: server && server !== "cabane" ? `${server}.${tool2}` : tool2,
5340
5546
  input: item.arguments,
5341
5547
  status: toStatus(item.status),
5548
+ ...server ? { server } : {},
5342
5549
  ...item.status === "completed" ? { result } : item.status === "failed" ? { result: hasMcpReceipt(result) ? result : readMcpError(item.error) } : {}
5343
5550
  };
5344
5551
  }
@@ -5577,11 +5784,20 @@ async function* decodeCodexStream(events, ctx2) {
5577
5784
  if (!tool2) continue;
5578
5785
  yield* flushInterim();
5579
5786
  const name = prettyToolName(tool2.name);
5787
+ const origin = tool2.server ? { mcpServer: tool2.server } : {};
5580
5788
  if (!startedTools.has(tool2.id)) {
5581
5789
  const summary = summarizeToolInput(tool2.input);
5582
5790
  pending.set(tool2.id, { name, summary, input: tool2.input });
5583
5791
  startedTools.add(tool2.id);
5584
- yield { type: "tool", id: tool2.id, name, phase: "start", summary, input: tool2.input };
5792
+ yield {
5793
+ type: "tool",
5794
+ id: tool2.id,
5795
+ name,
5796
+ phase: "start",
5797
+ summary,
5798
+ input: tool2.input,
5799
+ ...origin
5800
+ };
5585
5801
  }
5586
5802
  const entry = pending.get(tool2.id);
5587
5803
  if (entry && hasToolInput2(tool2.input)) {
@@ -5599,7 +5815,8 @@ async function* decodeCodexStream(events, ctx2) {
5599
5815
  phase: tool2.status === "completed" ? "done" : "error",
5600
5816
  summary: started?.summary ?? "",
5601
5817
  input: started?.input ?? tool2.input,
5602
- result: tool2.result
5818
+ result: tool2.result,
5819
+ ...origin
5603
5820
  };
5604
5821
  }
5605
5822
  continue;
@@ -5830,7 +6047,7 @@ function buildConfig(req) {
5830
6047
  default_tools_approval_mode: "approve"
5831
6048
  };
5832
6049
  if (req.cabane.turnControlUrl) {
5833
- mcp_servers[TURN_CONTROL_MCP_SERVER2] = {
6050
+ mcp_servers[TURN_CONTROL_MCP_SERVER] = {
5834
6051
  url: req.cabane.turnControlUrl,
5835
6052
  http_headers: {
5836
6053
  Authorization: `Bearer ${req.cabane.bearer}`,
@@ -5855,16 +6072,16 @@ function buildConfig(req) {
5855
6072
  function isStringRecord2(v) {
5856
6073
  return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
5857
6074
  }
5858
- var CABANE_MCP_SERVER3, TURN_CONTROL_MCP_SERVER2, ACTIVE_CONVERSATION_HEADER3, ENV_ENVELOPE_KEYS, CORE_REPLACES, CONVERSATION_REPLACES;
6075
+ var CABANE_MCP_SERVER3, ACTIVE_CONVERSATION_HEADER3, ENV_ENVELOPE_KEYS, CORE_REPLACES, CONVERSATION_REPLACES;
5859
6076
  var init_run_spec2 = __esm({
5860
6077
  "packages/agent-runtime/src/codex/run-spec.ts"() {
5861
6078
  "use strict";
6079
+ init_descriptions();
5862
6080
  init_standing();
5863
6081
  init_policy3();
5864
6082
  init_model3();
5865
6083
  init_session3();
5866
6084
  CABANE_MCP_SERVER3 = "cabane";
5867
- TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
5868
6085
  ACTIVE_CONVERSATION_HEADER3 = "x-cabane-active-conversation";
5869
6086
  ENV_ENVELOPE_KEYS = [
5870
6087
  "CABANE_CONVERSATION_ID",
@@ -6336,6 +6553,7 @@ var init_conformance4 = __esm({
6336
6553
  type: "tool",
6337
6554
  id: "t1",
6338
6555
  name: "read",
6556
+ mcpServer: "cabane",
6339
6557
  phase: "start",
6340
6558
  summary: "sleep.md",
6341
6559
  input: { path: "sleep.md" }
@@ -6344,6 +6562,7 @@ var init_conformance4 = __esm({
6344
6562
  type: "tool",
6345
6563
  id: "t1",
6346
6564
  name: "read",
6565
+ mcpServer: "cabane",
6347
6566
  phase: "done",
6348
6567
  summary: "sleep.md",
6349
6568
  input: { path: "sleep.md" },
@@ -6389,6 +6608,7 @@ var init_conformance4 = __esm({
6389
6608
  type: "tool",
6390
6609
  id: "query-success",
6391
6610
  name: "query",
6611
+ mcpServer: "cabane",
6392
6612
  phase: "start",
6393
6613
  summary: "",
6394
6614
  input: { op: "files.read", args: { path: "/ABOUT.md" } }
@@ -6397,6 +6617,7 @@ var init_conformance4 = __esm({
6397
6617
  type: "tool",
6398
6618
  id: "query-success",
6399
6619
  name: "query",
6620
+ mcpServer: "cabane",
6400
6621
  phase: "done",
6401
6622
  summary: "",
6402
6623
  input: { op: "files.read", args: { path: "/ABOUT.md" } },
@@ -6443,6 +6664,7 @@ var init_conformance4 = __esm({
6443
6664
  type: "tool",
6444
6665
  id: "query-failed",
6445
6666
  name: "query",
6667
+ mcpServer: "cabane",
6446
6668
  phase: "start",
6447
6669
  summary: "",
6448
6670
  input: { op: "files.read", args: { path: "/ABOUT.md" } }
@@ -6451,6 +6673,7 @@ var init_conformance4 = __esm({
6451
6673
  type: "tool",
6452
6674
  id: "query-failed",
6453
6675
  name: "query",
6676
+ mcpServer: "cabane",
6454
6677
  phase: "error",
6455
6678
  summary: "",
6456
6679
  input: { op: "files.read", args: { path: "/ABOUT.md" } },
@@ -6460,6 +6683,54 @@ var init_conformance4 = __esm({
6460
6683
  { type: "result", ok: true }
6461
6684
  ]
6462
6685
  },
6686
+ {
6687
+ // The turn-control server's `answer` (CT1973): the tool event carries the server Codex names
6688
+ // beside the tool, which is what the pump binds the declaration to. A non-cabane server keeps
6689
+ // its prefix on the card as before.
6690
+ name: "turn-control answer carries its server",
6691
+ request: makeRequest3(),
6692
+ nativeStream: [
6693
+ threadStarted(NEW_THREAD_ID),
6694
+ agentMessage("m1", "On it."),
6695
+ toolFrame("item.completed", {
6696
+ id: "answer-1",
6697
+ type: "mcp_tool_call",
6698
+ server: "cabane_companion",
6699
+ tool: "answer",
6700
+ arguments: {},
6701
+ status: "completed",
6702
+ result: { content: [{ type: "text", text: '{"ok":true,"kind":"answer"}' }] }
6703
+ }),
6704
+ agentMessage("m2", "The answer."),
6705
+ turnCompleted()
6706
+ ],
6707
+ expected: [
6708
+ startedSession(NEW_THREAD_ID),
6709
+ { type: "text", body: "On it.", terminal: false },
6710
+ {
6711
+ type: "tool",
6712
+ id: "answer-1",
6713
+ name: "cabane_companion.answer",
6714
+ mcpServer: "cabane_companion",
6715
+ phase: "start",
6716
+ summary: "",
6717
+ input: {}
6718
+ },
6719
+ {
6720
+ type: "tool",
6721
+ id: "answer-1",
6722
+ name: "cabane_companion.answer",
6723
+ mcpServer: "cabane_companion",
6724
+ phase: "done",
6725
+ summary: "",
6726
+ input: {},
6727
+ result: [{ type: "text", text: '{"ok":true,"kind":"answer"}' }]
6728
+ },
6729
+ { type: "text", body: "The answer.", terminal: true },
6730
+ closingSession(NEW_THREAD_ID),
6731
+ { type: "result", ok: true }
6732
+ ]
6733
+ },
6463
6734
  {
6464
6735
  // A transport/tool failure with no MCP result payload still uses the real adapter error message,
6465
6736
  // never `undefined`.
@@ -6485,6 +6756,7 @@ var init_conformance4 = __esm({
6485
6756
  type: "tool",
6486
6757
  id: "mcp-failed",
6487
6758
  name: "other.lookup",
6759
+ mcpServer: "other",
6488
6760
  phase: "start",
6489
6761
  summary: "receipt",
6490
6762
  input: { q: "receipt" }
@@ -6493,6 +6765,7 @@ var init_conformance4 = __esm({
6493
6765
  type: "tool",
6494
6766
  id: "mcp-failed",
6495
6767
  name: "other.lookup",
6768
+ mcpServer: "other",
6496
6769
  phase: "error",
6497
6770
  summary: "receipt",
6498
6771
  input: { q: "receipt" },
@@ -6529,6 +6802,7 @@ var init_conformance4 = __esm({
6529
6802
  type: "tool",
6530
6803
  id: "mcp-image",
6531
6804
  name: "other.render",
6805
+ mcpServer: "other",
6532
6806
  phase: "start",
6533
6807
  summary: "",
6534
6808
  input: {}
@@ -6537,6 +6811,7 @@ var init_conformance4 = __esm({
6537
6811
  type: "tool",
6538
6812
  id: "mcp-image",
6539
6813
  name: "other.render",
6814
+ mcpServer: "other",
6540
6815
  phase: "done",
6541
6816
  summary: "",
6542
6817
  input: {},
@@ -6584,11 +6859,20 @@ var init_conformance4 = __esm({
6584
6859
  ],
6585
6860
  expected: [
6586
6861
  startedSession(NEW_THREAD_ID),
6587
- { type: "tool", id: "t1", name: "list", phase: "start", summary: "", input: {} },
6588
6862
  {
6589
6863
  type: "tool",
6590
6864
  id: "t1",
6591
6865
  name: "list",
6866
+ mcpServer: "cabane",
6867
+ phase: "start",
6868
+ summary: "",
6869
+ input: {}
6870
+ },
6871
+ {
6872
+ type: "tool",
6873
+ id: "t1",
6874
+ name: "list",
6875
+ mcpServer: "cabane",
6592
6876
  phase: "done",
6593
6877
  summary: "/product/tasks",
6594
6878
  input: { path: "/product/tasks" },
@@ -8428,6 +8712,13 @@ function createTurnControlMcpServer(acts, skipState) {
8428
8712
  }),
8429
8713
  { annotations: ACT_TOOL, alwaysLoad: true }
8430
8714
  ),
8715
+ tool(
8716
+ ANSWER_TOOL2,
8717
+ TURN_CONTROL_TOOL_DESCRIPTIONS.answer,
8718
+ {},
8719
+ async () => performAct(acts, { kind: "answer", callId: randomUUID2() }),
8720
+ { annotations: ACT_TOOL, alwaysLoad: true }
8721
+ ),
8431
8722
  // The inverse. No arguments — it can only ever stand down the
8432
8723
  // caller's own wake in the conversation it's holding a turn in.
8433
8724
  tool(
@@ -8440,7 +8731,7 @@ function createTurnControlMcpServer(acts, skipState) {
8440
8731
  ]
8441
8732
  });
8442
8733
  }
8443
- var COMPANION_LOCAL_MCP_SERVER, COMPANION_LOCAL_TOOL_GLOB, SKIP_TURN_TOOL, ASK_TOOL, ASK_TOOL_NAME, WITHDRAW_ASK_TOOL, WITHDRAW_ASK_TOOL_NAME, WAKE_ME_TOOL, WAKE_ME_TOOL_NAME, CANCEL_WAKE_TOOL, CANCEL_WAKE_TOOL_NAME, TURN_ACT_TIMEOUT_MS;
8734
+ var COMPANION_LOCAL_MCP_SERVER, COMPANION_LOCAL_TOOL_GLOB, SKIP_TURN_TOOL, ASK_TOOL, ASK_TOOL_NAME, WITHDRAW_ASK_TOOL, WITHDRAW_ASK_TOOL_NAME, ANSWER_TOOL2, WAKE_ME_TOOL, WAKE_ME_TOOL_NAME, CANCEL_WAKE_TOOL, CANCEL_WAKE_TOOL_NAME, TURN_ACT_TIMEOUT_MS;
8444
8735
  var init_turn_control_tools = __esm({
8445
8736
  "src/turn-control-tools.ts"() {
8446
8737
  "use strict";
@@ -8448,13 +8739,14 @@ var init_turn_control_tools = __esm({
8448
8739
  init_descriptions();
8449
8740
  init_errors();
8450
8741
  init_api_error_shape();
8451
- COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
8742
+ COMPANION_LOCAL_MCP_SERVER = TURN_CONTROL_MCP_SERVER;
8452
8743
  COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
8453
8744
  SKIP_TURN_TOOL = "skip_turn";
8454
8745
  ASK_TOOL = "ask";
8455
8746
  ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
8456
8747
  WITHDRAW_ASK_TOOL = "withdraw_ask";
8457
8748
  WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
8749
+ ANSWER_TOOL2 = "answer";
8458
8750
  WAKE_ME_TOOL = "wake_me";
8459
8751
  WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
8460
8752
  CANCEL_WAKE_TOOL = "cancel_wake";
@@ -9111,7 +9403,7 @@ var init_turn_committer = __esm({
9111
9403
  deps.onCommitFailed?.(err);
9112
9404
  };
9113
9405
  const commit = {
9114
- commitMessage: async ({ body, kind, seq }) => {
9406
+ commitMessage: async ({ body, kind, seq, declared }) => {
9115
9407
  await deps.api.postTurnMessage(
9116
9408
  deps.workspaceId,
9117
9409
  deps.conversationId,
@@ -9121,9 +9413,11 @@ var init_turn_committer = __esm({
9121
9413
  turnId: deps.turnId,
9122
9414
  seq,
9123
9415
  parentMessageId: deps.parentMessageId,
9124
- ...source
9416
+ ...source,
9417
+ ...declared ? { declared } : {}
9125
9418
  },
9126
- deps.signal
9419
+ deps.signal,
9420
+ { agentId: deps.agentId }
9127
9421
  );
9128
9422
  },
9129
9423
  reportTool: async ({
@@ -9182,7 +9476,9 @@ var init_turn_committer = __esm({
9182
9476
  commit,
9183
9477
  signal: deps.signal,
9184
9478
  nextSeq: deps.nextSeq,
9185
- ...deps.spoke !== void 0 ? { spoke: deps.spoke } : {}
9479
+ ...deps.spoke !== void 0 ? { spoke: deps.spoke } : {},
9480
+ ...deps.armed !== void 0 ? { armed: deps.armed } : {},
9481
+ ...deps.declaredSeq !== void 0 ? { declaredSeq: deps.declaredSeq } : {}
9186
9482
  });
9187
9483
  this.emit = sinkEmitter(this.pump.sink, this.onError);
9188
9484
  }
@@ -9204,14 +9500,15 @@ var init_turn_committer = __esm({
9204
9500
  }
9205
9501
  }
9206
9502
  // A message has landed under this span — its first words at least. A span
9207
- // that spoke settles; one that never did is a pass. Nothing is promoted at
9208
- // the end of a turn: the held-text flush is the adapter's (it emits the
9209
- // closing reply as a terminal `text` event before `result`), and a span
9210
- // that ended on working text ends without last words.
9503
+ // that spoke settles; one that never did is a pass. The held-text flush is
9504
+ // the adapter's (it emits the closing reply as a terminal `text` event before
9505
+ // `result`); a span that ended on working text names that text as its last,
9506
+ // and the server makes it the body at the end.
9211
9507
  get spoke() {
9212
9508
  return this.pump.spoke;
9213
9509
  }
9214
- // The `seq` of the last words, for the settle to declare; null when none.
9510
+ // What the settle names by `seq`: the declared answer, or the turn's last
9511
+ // text through either door; null when the turn wrote none.
9215
9512
  get lastMessageSeq() {
9216
9513
  return this.pump.lastMessageSeq;
9217
9514
  }
@@ -9442,6 +9739,12 @@ var init_turn_execution = __esm({
9442
9739
  // Whether the span this resumed run continues already has first words —
9443
9740
  // from the server (`turnSpoke`) or from speech still queued in the outbox.
9444
9741
  resumedSpoke = false;
9742
+ // Whether the resumed run starts with the `answer` act armed: the server
9743
+ // holds an accepted act (`answerArmed`) and this device's outbox holds no
9744
+ // `declared` commit for the turn — a queued selection stands, and the run
9745
+ // starts declared at its seq instead.
9746
+ resumedArmed = false;
9747
+ resumedDeclaredSeq;
9445
9748
  turnContext;
9446
9749
  resolvedMcpServers;
9447
9750
  // The harness's secret environment for this turn. Only ever handed to the
@@ -9627,6 +9930,7 @@ var init_turn_execution = __esm({
9627
9930
  const { payload, turnId, turnLog } = this;
9628
9931
  const outboxFloor = this.resumed ? readOutboxFloor((id) => this.opts.api.outboxMaxSeqForTurn(id), turnId, turnLog) : 0;
9629
9932
  const outboxSpoke = this.resumed && this.opts.api.outboxHasSpeechForTurn(turnId);
9933
+ const outboxDeclaredSeq = this.resumed ? this.opts.api.outboxDeclaredSeqForTurn(turnId) : null;
9630
9934
  let turnContext;
9631
9935
  try {
9632
9936
  turnContext = await this.opts.api.getTurnContext(
@@ -9681,6 +9985,16 @@ var init_turn_execution = __esm({
9681
9985
  this.seqCounter = Math.max(this.seqCounter, this.resumedFromSeq);
9682
9986
  this.resumedSpoke = turnContext.turnSpoke === true || outboxSpoke;
9683
9987
  if (this.resumedSpoke) turnLog.debug({ turnId }, "dispatcher: resumed span already spoke");
9988
+ this.resumedArmed = turnContext.answerArmed === true && outboxDeclaredSeq === null;
9989
+ this.resumedDeclaredSeq = outboxDeclaredSeq ?? void 0;
9990
+ if (this.resumedArmed)
9991
+ turnLog.debug({ turnId }, "dispatcher: resumed turn re-armed its answer");
9992
+ if (outboxDeclaredSeq !== null) {
9993
+ turnLog.debug(
9994
+ { turnId, seq: outboxDeclaredSeq },
9995
+ "dispatcher: resumed turn keeps the answer queued in its outbox"
9996
+ );
9997
+ }
9684
9998
  }
9685
9999
  }
9686
10000
  // Register this context's issuance as the execution source of the work about to
@@ -10188,6 +10502,8 @@ var init_turn_execution = __esm({
10188
10502
  log: turnLog,
10189
10503
  nextSeq: this.nextSeq,
10190
10504
  spoke: this.resumedSpoke,
10505
+ armed: this.resumedArmed,
10506
+ ...this.resumedDeclaredSeq !== void 0 ? { declaredSeq: this.resumedDeclaredSeq } : {},
10191
10507
  // Admitted by now, so this is the source the model's work runs on.
10192
10508
  ...this.executionSourceId !== void 0 ? { executionSourceId: this.executionSourceId } : {},
10193
10509
  // A commit that didn't land may mean the lease is gone; see