@cabane/companion 0.6.202 → 0.6.204

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.
package/dist/cli.js CHANGED
@@ -321,25 +321,7 @@ var init_prepare_hook = __esm({
321
321
  // Wall-clock cap for the hook. Provisioning is slow (minutes), so the
322
322
  // default is generous; a hook that hangs past this is killed and the turn
323
323
  // fails with a clear timeout message rather than pinning the companion.
324
- timeoutMs: z.number().int().positive().optional(),
325
- // Re-invoke the hook when a prepared result already exists, in VALIDATE
326
- // mode: the cached result is handed back as `prepared` and stdout is
327
- // ignored, so the cwd cannot move. Its only power is to exit non-zero and
328
- // fail the turn. The hook resolves once per (conversation, agent), so a
329
- // later message asking for a different environment is otherwise silently
330
- // ignored; this lets a hook refuse it out loud.
331
- //
332
- // Default OFF: a hook that provisions unconditionally must not start
333
- // running on every turn because the companion upgraded.
334
- validateCached: z.boolean().optional(),
335
- // Independent operator-owned reporter. Receives sanitized failure metadata
336
- // on stdin; never re-enters this hook and cannot change the turn outcome.
337
- failureReporter: z.object({
338
- command: z.string().min(1),
339
- args: z.array(z.string()).optional(),
340
- env: z.record(z.string(), z.string()).optional(),
341
- timeoutMs: z.number().int().positive().max(3e4).optional()
342
- }).strict().optional()
324
+ timeoutMs: z.number().int().positive().optional()
343
325
  }).strict();
344
326
  DEFAULT_TIMEOUT_MS = 10 * 6e4;
345
327
  prepareResultSchema = z.object({
@@ -369,15 +351,7 @@ var init_prepare_hook = __esm({
369
351
  CABANE_AGENT_USERNAME: input.agentUsername,
370
352
  CABANE_DEFAULT_CWD: input.defaultCwd,
371
353
  CABANE_RUNTIME: input.runtime ?? "",
372
- // Newline-joined — workspace paths never contain a newline, so a
373
- // hook splits on `\n` unambiguously. Empty string when none.
374
- CABANE_TRIGGER_ENTRY_PATHS: input.triggerEntryPaths.join("\n"),
375
- CABANE_CONVERSATION_TITLE: input.title ?? "",
376
- // The dispatching message's text, and — in validate mode only —
377
- // the prepared result the hook is being asked to check this dispatch
378
- // against. Both empty/absent on an ordinary first run.
379
- CABANE_TRIGGER_MESSAGE: input.messageBody ?? "",
380
- ...input.prepared ? { CABANE_PREPARED: JSON.stringify(input.prepared) } : {}
354
+ CABANE_CONVERSATION_TITLE: input.title ?? ""
381
355
  }
382
356
  });
383
357
  } catch (err) {
@@ -415,10 +389,6 @@ var init_prepare_hook = __esm({
415
389
  );
416
390
  return;
417
391
  }
418
- if (input.prepared) {
419
- resolve2(input.prepared);
420
- return;
421
- }
422
392
  try {
423
393
  resolve2(parsePrepareOutput(stdout));
424
394
  } catch (err) {
@@ -487,33 +457,36 @@ function workRoot(cfg) {
487
457
  if (root.startsWith("~/")) return join4(homedir5(), root.slice(2));
488
458
  return root;
489
459
  }
490
- function retiredConfigKeys(raw) {
491
- if (!raw || typeof raw !== "object") return [];
492
- const obj = raw;
460
+ function isObject(value) {
461
+ return !!value && typeof value === "object" && !Array.isArray(value);
462
+ }
463
+ function splitRetired(raw) {
493
464
  const keys = [];
494
- if ("runners" in obj) keys.push("runners");
495
- const agents = obj.agents;
496
- if (agents && typeof agents === "object") {
497
- for (const [key, row3] of Object.entries(agents)) {
498
- if (row3 && typeof row3 === "object" && "sandbox" in row3) keys.push(`agents.${key}.sandbox`);
465
+ const without = (obj, names, path) => {
466
+ const out = {};
467
+ for (const [k, v] of Object.entries(obj)) {
468
+ if (names.includes(k)) keys.push(`${path}${k}`);
469
+ else out[k] = v;
470
+ }
471
+ return out;
472
+ };
473
+ const hook = (row3, path) => isObject(row3.prepareHook) ? { ...row3, prepareHook: without(row3.prepareHook, RETIRED_HOOK_KEYS, `${path}prepareHook.`) } : row3;
474
+ const kept = hook(without(raw, ["runners"], ""), "");
475
+ if (isObject(kept.agents)) {
476
+ const agents = {};
477
+ for (const [key, row3] of Object.entries(kept.agents)) {
478
+ const path = `agents.${key}.`;
479
+ agents[key] = isObject(row3) ? hook(without(row3, RETIRED_ROW_KEYS, path), path) : row3;
499
480
  }
481
+ kept.agents = agents;
500
482
  }
501
- return keys;
483
+ return { keys, kept };
484
+ }
485
+ function retiredConfigKeys(raw) {
486
+ return isObject(raw) ? splitRetired(raw).keys : [];
502
487
  }
503
488
  function withoutRetiredKeys(raw) {
504
- if (retiredConfigKeys(raw).length === 0) return raw;
505
- const { runners: _runners, ...rest } = raw;
506
- if (!rest.agents || typeof rest.agents !== "object") return rest;
507
- const agents = {};
508
- for (const [key, row3] of Object.entries(rest.agents)) {
509
- if (row3 && typeof row3 === "object" && "sandbox" in row3) {
510
- const { sandbox: _sandbox, ...kept } = row3;
511
- agents[key] = kept;
512
- } else {
513
- agents[key] = row3;
514
- }
515
- }
516
- return { ...rest, agents };
489
+ return isObject(raw) ? splitRetired(raw).kept : raw;
517
490
  }
518
491
  function retiredConfigKeysOnDisk() {
519
492
  try {
@@ -708,7 +681,7 @@ function deleteConfig() {
708
681
  writeFileSync(path, "", { mode: 384 });
709
682
  }
710
683
  }
711
- var HARNESS_RUNTIMES, realHomeCache, localAgentConfigSchema, agentsConfigSchema, companionConfigSchema, legacyOfferSchema;
684
+ var HARNESS_RUNTIMES, realHomeCache, localAgentConfigSchema, agentsConfigSchema, companionConfigSchema, RETIRED_ROW_KEYS, RETIRED_HOOK_KEYS, legacyOfferSchema;
712
685
  var init_config = __esm({
713
686
  "src/config.ts"() {
714
687
  "use strict";
@@ -753,8 +726,7 @@ var init_config = __esm({
753
726
  // The DEVICE-LEVEL prepare hook, for every agent assigned here unless its own
754
727
  // `agents` row names one. Per-agent hooks alone make the config a second
755
728
  // silent roster: an agent nobody listed gets no hook and no error, and starts
756
- // in the operator's `$HOME` (an agent woke in `/home/joshed` this way on
757
- // 2026-08-17).
729
+ // in the operator's `$HOME`.
758
730
  prepareHook: prepareHookSchema.optional(),
759
731
  // `dashboardPort` and `autoOpen` are IGNORED by the CLI (no port, no browser)
760
732
  // but stay in the schema so an existing file still parses; the Electron shell
@@ -791,13 +763,16 @@ var init_config = __esm({
791
763
  root: z2.string().min(1).refine(isRootPath, { message: "must be an absolute path, or start with ~/" }).optional(),
792
764
  // An argv prefix the operator puts in front of every turn's runner: the companion starts
793
765
  // `<prefix…> <its own run-turn command…>` instead of the runner bare. What the prefix does
794
- // is the operator's (a box of their own making, ENG225); the companion knows nothing of it
766
+ // is the operator's (a container or VM per turn, say); the companion knows nothing of it
795
767
  // beyond handing the runner over, with the turn's inputs in its environment and the
796
- // credential and secrets on its stdin (`runner/launch.ts`, `boxed`).
768
+ // credential and secrets on its stdin (`runner/launch.ts`, `wrapped`). The README lists
769
+ // what a wrapper can rely on.
797
770
  runner: z2.object({
798
771
  command: z2.array(z2.string().min(1)).min(1)
799
772
  }).strict().optional()
800
773
  });
774
+ RETIRED_ROW_KEYS = ["sandbox"];
775
+ RETIRED_HOOK_KEYS = ["validateCached", "failureReporter"];
801
776
  legacyOfferSchema = z2.object({
802
777
  claudeCode: z2.object({ enabled: z2.boolean().optional() }).passthrough().optional(),
803
778
  codex: z2.object({ enabled: z2.boolean().optional() }).passthrough().optional(),
@@ -1395,6 +1370,18 @@ var init_boot_id = __esm({
1395
1370
  function activeRunOutboxKey(conversationId, agentId) {
1396
1371
  return `activerun:${conversationId}:${agentId}`;
1397
1372
  }
1373
+ function isAlreadyPublished(err) {
1374
+ return err instanceof ApiError && err.status === 409 && apiErrorCode(err) === "already_published";
1375
+ }
1376
+ function trailOf(body) {
1377
+ const b = body;
1378
+ return {
1379
+ turnId: b.turnId,
1380
+ text: b.body,
1381
+ ...b.seq !== void 0 ? { seq: b.seq } : {},
1382
+ ...b.executionSourceId !== void 0 ? { executionSourceId: b.executionSourceId } : {}
1383
+ };
1384
+ }
1398
1385
  function isRetryable(err) {
1399
1386
  if (err instanceof ApiError) return err.status >= 500;
1400
1387
  if (isAbortError(err)) return false;
@@ -1522,7 +1509,7 @@ var init_api = __esm({
1522
1509
  // text use (turnId, seq) within each kind for nonzero seq; tool activity
1523
1510
  // uses (turnId, toolUseId). Legacy zero/missing seq bypasses row deduplication.
1524
1511
  // Server fences and the outbox's discard/eviction bounds still apply.
1525
- async durableCommit(kind, path, body, turnId, seq, signal) {
1512
+ async durableCommit(kind, path, body, turnId, seq, signal, trailPath) {
1526
1513
  try {
1527
1514
  await this.request("POST", path, body, {
1528
1515
  retry: true,
@@ -1533,7 +1520,16 @@ var init_api = __esm({
1533
1520
  if (!outbox) throw err;
1534
1521
  if (signal?.aborted || isAbortError(err)) throw err;
1535
1522
  if (!isRetryable(err)) throw err;
1536
- outbox.persist({ enqueuedAt: Date.now(), turnId, seq, method: "POST", path, body, kind });
1523
+ outbox.persist({
1524
+ enqueuedAt: Date.now(),
1525
+ turnId,
1526
+ seq,
1527
+ method: "POST",
1528
+ path,
1529
+ body,
1530
+ kind,
1531
+ ...trailPath ? { trailPath } : {}
1532
+ });
1537
1533
  this.opts.log?.debug(
1538
1534
  { kind, turnId, seq, err: err instanceof Error ? err.message : String(err) },
1539
1535
  "companion: commit queued to outbox after transient failure (will drain when the API returns)"
@@ -1558,6 +1554,29 @@ var init_api = __esm({
1558
1554
  progressed = true;
1559
1555
  } catch (err) {
1560
1556
  if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
1557
+ if (entry.kind === "message" && entry.trailPath && isAlreadyPublished(err)) {
1558
+ try {
1559
+ await this.request("POST", entry.trailPath, trailOf(entry.body), {
1560
+ retry: true
1561
+ });
1562
+ } catch (trailErr) {
1563
+ if (!(trailErr instanceof ApiError && trailErr.status >= 400 && trailErr.status < 500)) {
1564
+ break;
1565
+ }
1566
+ this.opts.log?.warn(
1567
+ {
1568
+ kind: entry.kind,
1569
+ turnId: entry.turnId,
1570
+ seq: entry.seq,
1571
+ ...apiErrorLogFields(trailErr)
1572
+ },
1573
+ `Cabane rejected a postscript; it could not be filed. ${mayNeedUpdate()}`
1574
+ );
1575
+ }
1576
+ outbox.remove(entry.turnId, entry.seq);
1577
+ progressed = true;
1578
+ continue;
1579
+ }
1561
1580
  if (entry.kind === "active-run" && isTurnEndedRefusal(err)) {
1562
1581
  this.opts.log?.debug(
1563
1582
  { kind: entry.kind, turnId: entry.turnId, ...apiErrorLogFields(err) },
@@ -1633,6 +1652,19 @@ var init_api = __esm({
1633
1652
  (e) => e.turnId === turnId && e.kind === "message" && e.body?.kind !== "runtime_notice"
1634
1653
  );
1635
1654
  }
1655
+ // The `seq` of this turn's SELECTED ANSWER still waiting in the outbox — the
1656
+ // `declared` commit the server's `answerArmed` cannot see — or null when none
1657
+ // is queued. A resumed run that re-armed over it would let a newer text
1658
+ // consume the arm first; with the selection queued, the resumed run starts
1659
+ // declared, its texts are trail, and the drain delivers the selection.
1660
+ outboxDeclaredSeqForTurn(turnId) {
1661
+ const outbox = this.opts.outbox;
1662
+ if (!outbox) return null;
1663
+ const queued = outbox.list().find(
1664
+ (e) => e.turnId === turnId && e.kind === "message" && e.body?.declared === true
1665
+ );
1666
+ return queued ? queued.seq : null;
1667
+ }
1636
1668
  // ADOPT a turn context's issuance as the execution source of its work, once,
1637
1669
  // before preparation emits or queues anything, so every row can name a source
1638
1670
  // the server durably recorded and will never reap. Adoption is idempotent, so
@@ -1806,15 +1838,36 @@ var init_api = __esm({
1806
1838
  // seq also orders speech against the turn's tool/thinking/text trail.
1807
1839
  // Transport/5xx failures get bounded retries and optional outbox delivery;
1808
1840
  // 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
- );
1841
+ //
1842
+ // A turn's answer can be out before a later speech commit arrives — the
1843
+ // declaration published it, or another host's selection drained first — and
1844
+ // the server refuses the commit `409 already_published`. The text is a
1845
+ // postscript then: with `trail` (the committing agent, which names the text
1846
+ // route), it is filed as trail at the same seq, live here and from the outbox
1847
+ // on a drain. Without `trail` (a runtime notice) the refusal is terminal.
1848
+ async postTurnMessage(workspaceId, conversationId, body, signal, trail) {
1849
+ const trailPath = trail ? `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${trail.agentId}/text` : void 0;
1850
+ try {
1851
+ await this.durableCommit(
1852
+ "message",
1853
+ `/api/workspaces/${workspaceId}/conversations/${conversationId}/messages`,
1854
+ body,
1855
+ body.turnId,
1856
+ body.seq ?? 0,
1857
+ signal,
1858
+ trailPath
1859
+ );
1860
+ } catch (err) {
1861
+ if (!trailPath || !isAlreadyPublished(err)) throw err;
1862
+ await this.durableCommit(
1863
+ "text",
1864
+ trailPath,
1865
+ trailOf(body),
1866
+ body.turnId,
1867
+ body.seq ?? 0,
1868
+ signal
1869
+ );
1870
+ }
1818
1871
  }
1819
1872
  // Report a tool transition (start/done/error). The server checks span
1820
1873
  // ownership, upserts by (turnId, toolUseId), and publishes agent_activity SSE.
@@ -2017,6 +2070,8 @@ var init_turn_diagnostics = __esm({
2017
2070
  turnSessionModes = ["fresh", "resumed", "degraded"];
2018
2071
  turnRuntimeResultKinds = ["success", "error", "no_terminal"];
2019
2072
  turnFinalSources = [
2073
+ // The agent declared its answer with the `answer` turn act (CT1973).
2074
+ "declared",
2020
2075
  "runtime_text",
2021
2076
  "progress_promotion",
2022
2077
  "host_fallback",
@@ -2495,39 +2550,145 @@ var init_transcript_sink = __esm({
2495
2550
  }
2496
2551
  });
2497
2552
 
2553
+ // packages/agent-runtime/src/turn-control/descriptions.ts
2554
+ 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;
2555
+ var init_descriptions = __esm({
2556
+ "packages/agent-runtime/src/turn-control/descriptions.ts"() {
2557
+ "use strict";
2558
+ MAX_ASK_QUESTION_BYTES = 400;
2559
+ MAX_ASK_HEADLINE_BYTES = 120;
2560
+ MAX_ASK_ITEMS = 20;
2561
+ MAX_ASK_OPTION_BYTES = 200;
2562
+ ASK_OPTION_NOT_A_STRING = "An ask option is a plain string label.";
2563
+ MIN_ASK_OPTIONS = 2;
2564
+ MAX_ASK_OPTIONS = 4;
2565
+ 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})$/;
2566
+ ASK_TARGET_INVALID = "An ask target is a user id or an @handle.";
2567
+ MAX_WAKE_NOTE_BYTES = 2e3;
2568
+ MAX_WITHDRAW_REASON_BYTES = 400;
2569
+ MAX_SKIP_REASON_BYTES = 500;
2570
+ TURN_CONTROL_MCP_SERVER = "cabane_companion";
2571
+ TURN_CONTROL_TOOLS = [
2572
+ "answer",
2573
+ "ask",
2574
+ "cancel_wake",
2575
+ "skip_turn",
2576
+ "wake_me",
2577
+ "withdraw_ask"
2578
+ ];
2579
+ TURN_CONTROL_TOOL_DESCRIPTIONS = {
2580
+ 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.`,
2581
+ 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.
2582
+ - 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.
2583
+ - \`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.
2584
+ - 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.
2585
+ - 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.
2586
+ - 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.
2587
+ - 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.
2588
+ - 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\`.`,
2589
+ 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.*\`).`,
2590
+ 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.`,
2591
+ 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.`,
2592
+ 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.`
2593
+ };
2594
+ TURN_CONTROL_FIELD_DESCRIPTIONS = {
2595
+ answer: {},
2596
+ ask: {
2597
+ targetUserId: "The person to ask: their `@handle` from your turn context's roster (preferred), or their user id.",
2598
+ 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\`.`,
2599
+ 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.`,
2600
+ 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.`,
2601
+ 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.`,
2602
+ "questions.headline": `The one-sentence question ("Should Ledger deploy once Mia approves?") \u2014 required for each item, up to ${MAX_ASK_HEADLINE_BYTES} characters.`,
2603
+ "questions.body": `Optional short framing beneath the headline, up to ${MAX_ASK_QUESTION_BYTES} characters, inline markdown only. NOT a report.`,
2604
+ "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.`,
2605
+ 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.",
2606
+ "replaces.askId": "The id of the open ask to withdraw.",
2607
+ "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.`
2608
+ },
2609
+ cancel_wake: {},
2610
+ skip_turn: {
2611
+ reason: `Short reason you are declining \u2014 used for telemetry/debugging, never posted. Up to ${MAX_SKIP_REASON_BYTES} characters.`
2612
+ },
2613
+ wake_me: {
2614
+ 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.",
2615
+ 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.",
2616
+ 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.`
2617
+ },
2618
+ withdraw_ask: {
2619
+ 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.",
2620
+ 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.`
2621
+ }
2622
+ };
2623
+ }
2624
+ });
2625
+
2498
2626
  // packages/agent-runtime/src/pump.ts
2499
- var THINKING_TEXT_MAX_CHARS, TurnPump;
2627
+ function isTurnControlAnswer(evt) {
2628
+ if (evt.mcpServer !== TURN_CONTROL_MCP_SERVER) return false;
2629
+ const name = evt.toolName;
2630
+ return name === ANSWER_TOOL || name === `${TURN_CONTROL_MCP_SERVER}.${ANSWER_TOOL}` || name === `${TURN_CONTROL_MCP_SERVER}_${ANSWER_TOOL}`;
2631
+ }
2632
+ var ANSWER_TOOL, THINKING_TEXT_MAX_CHARS, TurnPump;
2500
2633
  var init_pump = __esm({
2501
2634
  "packages/agent-runtime/src/pump.ts"() {
2502
2635
  "use strict";
2503
2636
  init_empty_result();
2504
2637
  init_transcript();
2638
+ init_descriptions();
2639
+ ANSWER_TOOL = "answer";
2505
2640
  THINKING_TEXT_MAX_CHARS = 3900;
2506
2641
  TurnPump = class {
2507
2642
  constructor(opts) {
2508
2643
  this.opts = opts;
2509
2644
  this.hasSpoken = opts.spoke ?? false;
2645
+ this.armed = opts.armed ?? false;
2646
+ if (opts.declaredSeq !== void 0) {
2647
+ this.hasSpoken = true;
2648
+ this.armed = false;
2649
+ this.declared = true;
2650
+ this.emittedFinal = true;
2651
+ this.finalSeq = opts.declaredSeq;
2652
+ this.emittedFinalSource = "declared";
2653
+ }
2510
2654
  this.sink = {
2511
2655
  onAssistantText: async (evt) => {
2512
2656
  if (opts.signal.aborted) return;
2513
2657
  const body = normalizeCommitText(evt.text);
2514
2658
  if (!body) return;
2515
2659
  const seq = opts.nextSeq();
2516
- if (!evt.final && this.hasSpoken) {
2660
+ if (this.armed) {
2661
+ await opts.commit.commitMessage({ body, kind: "message", seq, declared: true });
2662
+ this.armed = false;
2663
+ this.declared = true;
2664
+ this.hasSpoken = true;
2665
+ this.emittedFinal = true;
2666
+ this.finalSeq = seq;
2667
+ this.lastTextSeq = seq;
2668
+ this.finalReplyBody = body;
2669
+ this.emittedFinalSource = "declared";
2670
+ return;
2671
+ }
2672
+ if (this.declared || !evt.final && this.hasSpoken) {
2517
2673
  await opts.commit.reportText({ text: body, seq });
2674
+ this.lastTextSeq = seq;
2518
2675
  return;
2519
2676
  }
2520
2677
  await opts.commit.commitMessage({ body, kind: "message", seq });
2521
2678
  this.hasSpoken = true;
2679
+ this.lastTextSeq = seq;
2522
2680
  if (evt.final) {
2523
2681
  this.emittedFinal = true;
2524
- this.lastSeq = seq;
2682
+ this.finalSeq = seq;
2525
2683
  this.finalReplyBody = body;
2526
2684
  this.emittedFinalSource = "runtime_text";
2527
2685
  }
2528
2686
  },
2529
2687
  onToolActivity: async (evt) => {
2530
2688
  if (opts.signal.aborted) return;
2689
+ if (evt.phase === "done" && !this.declared && isTurnControlAnswer(evt)) {
2690
+ this.armed = true;
2691
+ }
2531
2692
  let call = this.toolCallByProviderId.get(evt.toolUseId);
2532
2693
  if (evt.phase === "start" || call === void 0) {
2533
2694
  call = { persistedId: crypto.randomUUID(), seq: opts.nextSeq() };
@@ -2568,9 +2729,16 @@ var init_pump = __esm({
2568
2729
  // A message has landed under this span — its first words. Every later
2569
2730
  // non-terminal text is the trail. Seeded from the host on a resume.
2570
2731
  hasSpoken;
2571
- // The last words landed, and their `seq` — what the settle declares.
2732
+ // An accepted `answer` act this turn has not yet written the text for.
2733
+ armed;
2734
+ // The answer committed with `declared: true`; every text after it is trail.
2735
+ declared = false;
2736
+ // The answer landed — declared, or the adapter's terminal text — and its `seq`.
2572
2737
  emittedFinal = false;
2573
- lastSeq = null;
2738
+ finalSeq = null;
2739
+ // The `seq` of the turn's last text through either door. Undeclared, this is
2740
+ // what the settle names: the server makes that text the body at the end.
2741
+ lastTextSeq = null;
2574
2742
  // The turn's final reply text, captured for the dashboard feed / logging.
2575
2743
  finalReplyBody = null;
2576
2744
  emittedFinalSource = "none";
@@ -2592,14 +2760,20 @@ var init_pump = __esm({
2592
2760
  get spoke() {
2593
2761
  return this.hasSpoken;
2594
2762
  }
2595
- // Whether the turn has committed its last words.
2763
+ // Whether the turn has committed its answer: declared, or the adapter's
2764
+ // terminal text.
2596
2765
  get finalEmitted() {
2597
2766
  return this.emittedFinal;
2598
2767
  }
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.
2768
+ // What the settle names, by `seq`: the declared answer, or — undeclared —
2769
+ // the turn's last text through either door, which the server makes the body
2770
+ // at the end. Null when the turn wrote no text at all.
2601
2771
  get lastMessageSeq() {
2602
- return this.lastSeq;
2772
+ return this.declared ? this.finalSeq : this.lastTextSeq;
2773
+ }
2774
+ // Whether an accepted `answer` act is still waiting for its text.
2775
+ get answerArmed() {
2776
+ return this.armed;
2603
2777
  }
2604
2778
  // The turn's final reply text, or null if none landed (failure / cancel /
2605
2779
  // empty). Used for the dashboard feed's reply preview.
@@ -2613,75 +2787,6 @@ var init_pump = __esm({
2613
2787
  }
2614
2788
  });
2615
2789
 
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
2790
  // packages/agent-runtime/src/turn-control/prompt-guidance.ts
2686
2791
  function turnControlPromptGuidance(prefix) {
2687
2792
  const controls = TURN_CONTROL_TOOLS.map((name) => `\`${prefix}${name}\` (${MEANINGS[name]})`);
@@ -2693,6 +2798,7 @@ var init_prompt_guidance = __esm({
2693
2798
  "use strict";
2694
2799
  init_descriptions();
2695
2800
  MEANINGS = {
2801
+ answer: "declare the text you write next as your answer",
2696
2802
  ask: "put a structured question to a person",
2697
2803
  cancel_wake: "cancel your own armed wake in this conversation",
2698
2804
  skip_turn: "end without posting a reply",
@@ -4231,6 +4337,15 @@ var init_events = __esm({
4231
4337
  });
4232
4338
 
4233
4339
  // packages/agent-runtime/src/opencode/loop.ts
4340
+ function opencodeMcpServerOf(tool2, servers) {
4341
+ let best = null;
4342
+ for (const server of servers ?? []) {
4343
+ if (tool2.length > server.length + 1 && tool2.startsWith(`${server}_`)) {
4344
+ if (best === null || server.length > best.length) best = server;
4345
+ }
4346
+ }
4347
+ return best;
4348
+ }
4234
4349
  async function* decodeOpencodeStream(events, ctx2) {
4235
4350
  let held = null;
4236
4351
  const pending = /* @__PURE__ */ new Map();
@@ -4257,12 +4372,14 @@ async function* decodeOpencodeStream(events, ctx2) {
4257
4372
  if (sealed) yield sealed;
4258
4373
  const key = part.callID ?? part.id;
4259
4374
  const input = part.state.input;
4375
+ const server = opencodeMcpServerOf(part.tool, ctx2.mcpServers);
4376
+ const origin = server ? { mcpServer: server } : {};
4260
4377
  if (!startedTools.has(key)) {
4261
4378
  const name = prettyToolName(part.tool);
4262
4379
  const summary = summarizeToolInput(input);
4263
4380
  pending.set(key, { name, summary, input });
4264
4381
  startedTools.add(key);
4265
- yield { type: "tool", id: key, name, phase: "start", summary, input };
4382
+ yield { type: "tool", id: key, name, phase: "start", summary, input, ...origin };
4266
4383
  }
4267
4384
  const entry = pending.get(key);
4268
4385
  if (entry && hasToolInput(input)) {
@@ -4282,7 +4399,8 @@ async function* decodeOpencodeStream(events, ctx2) {
4282
4399
  phase,
4283
4400
  summary: started?.summary ?? "",
4284
4401
  input: started?.input ?? input,
4285
- result
4402
+ result,
4403
+ ...origin
4286
4404
  };
4287
4405
  }
4288
4406
  } else {
@@ -4480,15 +4598,15 @@ function buildMcp(req) {
4480
4598
  function isStringRecord(v) {
4481
4599
  return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
4482
4600
  }
4483
- var CABANE_MCP_SERVER2, TURN_CONTROL_MCP_SERVER, ACTIVE_CONVERSATION_HEADER2;
4601
+ var CABANE_MCP_SERVER2, ACTIVE_CONVERSATION_HEADER2;
4484
4602
  var init_run_spec = __esm({
4485
4603
  "packages/agent-runtime/src/opencode/run-spec.ts"() {
4486
4604
  "use strict";
4605
+ init_descriptions();
4487
4606
  init_mcp_secret_env();
4488
4607
  init_policy2();
4489
4608
  init_model2();
4490
4609
  CABANE_MCP_SERVER2 = "cabane";
4491
- TURN_CONTROL_MCP_SERVER = "cabane_companion";
4492
4610
  ACTIVE_CONVERSATION_HEADER2 = "x-cabane-active-conversation";
4493
4611
  }
4494
4612
  });
@@ -4799,7 +4917,10 @@ function createOpencodeAdapter(deps = {}) {
4799
4917
  }
4800
4918
  yield* decodeOpencodeStream(result.events, {
4801
4919
  signal,
4802
- sessionId: result.sessionId
4920
+ sessionId: result.sessionId,
4921
+ // The keys the turn's servers were installed under: what the decoder
4922
+ // splits a raw `<server>_<tool>` id on.
4923
+ mcpServers: Object.keys(spec.mcp)
4803
4924
  });
4804
4925
  }
4805
4926
  };
@@ -4961,7 +5082,15 @@ var init_conformance3 = __esm({
4961
5082
  expected: [
4962
5083
  sessionEvent2(NEW_SESSION_ID),
4963
5084
  // 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: {} },
5085
+ {
5086
+ type: "tool",
5087
+ id: "call_1",
5088
+ name: "cabane_list",
5089
+ phase: "start",
5090
+ summary: "",
5091
+ input: {},
5092
+ mcpServer: "cabane"
5093
+ },
4965
5094
  // done: carries the full args captured from the later snapshot, and the
4966
5095
  // summary recomputed from them — NOT the first-seen `{}`.
4967
5096
  {
@@ -4971,8 +5100,60 @@ var init_conformance3 = __esm({
4971
5100
  phase: "done",
4972
5101
  summary: "/product/tasks",
4973
5102
  input: { path: "/product/tasks" },
4974
- result: "CT301.md"
5103
+ result: "CT301.md",
5104
+ mcpServer: "cabane"
5105
+ },
5106
+ { type: "result", ok: true }
5107
+ ]
5108
+ },
5109
+ {
5110
+ // The turn-control server's `answer` (CT1973). opencode names an MCP tool `<server>_<tool>`,
5111
+ // so the installed keys split the raw id: `cabane_companion_answer` is the turn-control
5112
+ // server's, never the workspace server's `companion_answer`. The pump binds on that.
5113
+ name: "turn-control answer is read as the turn-control server\u2019s, not cabane\u2019s",
5114
+ request: makeRequest2({
5115
+ cabane: {
5116
+ mcpUrl: "https://cabane.test/api/mcp",
5117
+ turnControlUrl: "https://cabane.test/api/turn-control",
5118
+ bearer: "tok",
5119
+ activeConversationId: "conv"
5120
+ }
5121
+ }),
5122
+ nativeStream: [
5123
+ textPart("p1", "On it."),
5124
+ toolPart("t1", "cabane_companion_answer", { status: "running", input: {} }, "call_1"),
5125
+ toolPart(
5126
+ "t1",
5127
+ "cabane_companion_answer",
5128
+ { status: "completed", input: {}, output: '{"ok":true,"kind":"answer"}' },
5129
+ "call_1"
5130
+ ),
5131
+ textPart("p2", "The answer."),
5132
+ idle()
5133
+ ],
5134
+ expected: [
5135
+ sessionEvent2(NEW_SESSION_ID),
5136
+ { type: "text", body: "On it.", terminal: false },
5137
+ {
5138
+ type: "tool",
5139
+ id: "call_1",
5140
+ name: "cabane_companion_answer",
5141
+ phase: "start",
5142
+ summary: "",
5143
+ input: {},
5144
+ mcpServer: "cabane_companion"
5145
+ },
5146
+ {
5147
+ type: "tool",
5148
+ id: "call_1",
5149
+ name: "cabane_companion_answer",
5150
+ phase: "done",
5151
+ summary: "",
5152
+ input: {},
5153
+ result: '{"ok":true,"kind":"answer"}',
5154
+ mcpServer: "cabane_companion"
4975
5155
  },
5156
+ { type: "text", body: "The answer.", terminal: true },
4976
5157
  { type: "result", ok: true }
4977
5158
  ]
4978
5159
  },
@@ -5339,6 +5520,7 @@ function readToolItem(item, eventType) {
5339
5520
  name: server && server !== "cabane" ? `${server}.${tool2}` : tool2,
5340
5521
  input: item.arguments,
5341
5522
  status: toStatus(item.status),
5523
+ ...server ? { server } : {},
5342
5524
  ...item.status === "completed" ? { result } : item.status === "failed" ? { result: hasMcpReceipt(result) ? result : readMcpError(item.error) } : {}
5343
5525
  };
5344
5526
  }
@@ -5577,11 +5759,20 @@ async function* decodeCodexStream(events, ctx2) {
5577
5759
  if (!tool2) continue;
5578
5760
  yield* flushInterim();
5579
5761
  const name = prettyToolName(tool2.name);
5762
+ const origin = tool2.server ? { mcpServer: tool2.server } : {};
5580
5763
  if (!startedTools.has(tool2.id)) {
5581
5764
  const summary = summarizeToolInput(tool2.input);
5582
5765
  pending.set(tool2.id, { name, summary, input: tool2.input });
5583
5766
  startedTools.add(tool2.id);
5584
- yield { type: "tool", id: tool2.id, name, phase: "start", summary, input: tool2.input };
5767
+ yield {
5768
+ type: "tool",
5769
+ id: tool2.id,
5770
+ name,
5771
+ phase: "start",
5772
+ summary,
5773
+ input: tool2.input,
5774
+ ...origin
5775
+ };
5585
5776
  }
5586
5777
  const entry = pending.get(tool2.id);
5587
5778
  if (entry && hasToolInput2(tool2.input)) {
@@ -5599,7 +5790,8 @@ async function* decodeCodexStream(events, ctx2) {
5599
5790
  phase: tool2.status === "completed" ? "done" : "error",
5600
5791
  summary: started?.summary ?? "",
5601
5792
  input: started?.input ?? tool2.input,
5602
- result: tool2.result
5793
+ result: tool2.result,
5794
+ ...origin
5603
5795
  };
5604
5796
  }
5605
5797
  continue;
@@ -5830,7 +6022,7 @@ function buildConfig(req) {
5830
6022
  default_tools_approval_mode: "approve"
5831
6023
  };
5832
6024
  if (req.cabane.turnControlUrl) {
5833
- mcp_servers[TURN_CONTROL_MCP_SERVER2] = {
6025
+ mcp_servers[TURN_CONTROL_MCP_SERVER] = {
5834
6026
  url: req.cabane.turnControlUrl,
5835
6027
  http_headers: {
5836
6028
  Authorization: `Bearer ${req.cabane.bearer}`,
@@ -5855,16 +6047,16 @@ function buildConfig(req) {
5855
6047
  function isStringRecord2(v) {
5856
6048
  return !!v && typeof v === "object" && Object.values(v).every((x) => typeof x === "string");
5857
6049
  }
5858
- var CABANE_MCP_SERVER3, TURN_CONTROL_MCP_SERVER2, ACTIVE_CONVERSATION_HEADER3, ENV_ENVELOPE_KEYS, CORE_REPLACES, CONVERSATION_REPLACES;
6050
+ var CABANE_MCP_SERVER3, ACTIVE_CONVERSATION_HEADER3, ENV_ENVELOPE_KEYS, CORE_REPLACES, CONVERSATION_REPLACES;
5859
6051
  var init_run_spec2 = __esm({
5860
6052
  "packages/agent-runtime/src/codex/run-spec.ts"() {
5861
6053
  "use strict";
6054
+ init_descriptions();
5862
6055
  init_standing();
5863
6056
  init_policy3();
5864
6057
  init_model3();
5865
6058
  init_session3();
5866
6059
  CABANE_MCP_SERVER3 = "cabane";
5867
- TURN_CONTROL_MCP_SERVER2 = "cabane_companion";
5868
6060
  ACTIVE_CONVERSATION_HEADER3 = "x-cabane-active-conversation";
5869
6061
  ENV_ENVELOPE_KEYS = [
5870
6062
  "CABANE_CONVERSATION_ID",
@@ -6038,7 +6230,7 @@ function createCodexAdapter(deps = {}) {
6038
6230
  if (signal.aborted) return;
6039
6231
  if (compactions === null) {
6040
6232
  deps.onWarn?.(
6041
- "codex adapter: the thread's compaction count could not be read \u2014 re-sending the standing instructions (ENG210)",
6233
+ "codex adapter: the thread's compaction count could not be read \u2014 re-sending the standing instructions",
6042
6234
  { threadId: decision.resume }
6043
6235
  );
6044
6236
  } else if (decision.compactions === compactions) {
@@ -6048,7 +6240,7 @@ function createCodexAdapter(deps = {}) {
6048
6240
  };
6049
6241
  } else if (decision.compactions !== null && compactions > decision.compactions) {
6050
6242
  deps.onWarn?.(
6051
- "codex adapter: the thread compacted since the last turn \u2014 re-sending the standing instructions (ENG210)",
6243
+ "codex adapter: the thread compacted since the last turn \u2014 re-sending the standing instructions",
6052
6244
  {
6053
6245
  threadId: decision.resume,
6054
6246
  compactions,
@@ -6336,6 +6528,7 @@ var init_conformance4 = __esm({
6336
6528
  type: "tool",
6337
6529
  id: "t1",
6338
6530
  name: "read",
6531
+ mcpServer: "cabane",
6339
6532
  phase: "start",
6340
6533
  summary: "sleep.md",
6341
6534
  input: { path: "sleep.md" }
@@ -6344,6 +6537,7 @@ var init_conformance4 = __esm({
6344
6537
  type: "tool",
6345
6538
  id: "t1",
6346
6539
  name: "read",
6540
+ mcpServer: "cabane",
6347
6541
  phase: "done",
6348
6542
  summary: "sleep.md",
6349
6543
  input: { path: "sleep.md" },
@@ -6389,6 +6583,7 @@ var init_conformance4 = __esm({
6389
6583
  type: "tool",
6390
6584
  id: "query-success",
6391
6585
  name: "query",
6586
+ mcpServer: "cabane",
6392
6587
  phase: "start",
6393
6588
  summary: "",
6394
6589
  input: { op: "files.read", args: { path: "/ABOUT.md" } }
@@ -6397,6 +6592,7 @@ var init_conformance4 = __esm({
6397
6592
  type: "tool",
6398
6593
  id: "query-success",
6399
6594
  name: "query",
6595
+ mcpServer: "cabane",
6400
6596
  phase: "done",
6401
6597
  summary: "",
6402
6598
  input: { op: "files.read", args: { path: "/ABOUT.md" } },
@@ -6443,6 +6639,7 @@ var init_conformance4 = __esm({
6443
6639
  type: "tool",
6444
6640
  id: "query-failed",
6445
6641
  name: "query",
6642
+ mcpServer: "cabane",
6446
6643
  phase: "start",
6447
6644
  summary: "",
6448
6645
  input: { op: "files.read", args: { path: "/ABOUT.md" } }
@@ -6451,6 +6648,7 @@ var init_conformance4 = __esm({
6451
6648
  type: "tool",
6452
6649
  id: "query-failed",
6453
6650
  name: "query",
6651
+ mcpServer: "cabane",
6454
6652
  phase: "error",
6455
6653
  summary: "",
6456
6654
  input: { op: "files.read", args: { path: "/ABOUT.md" } },
@@ -6461,17 +6659,65 @@ var init_conformance4 = __esm({
6461
6659
  ]
6462
6660
  },
6463
6661
  {
6464
- // A transport/tool failure with no MCP result payload still uses the real adapter error message,
6465
- // never `undefined`.
6466
- name: "failed MCP call without result keeps error message fallback",
6662
+ // The turn-control server's `answer` (CT1973): the tool event carries the server Codex names
6663
+ // beside the tool, which is what the pump binds the declaration to. A non-cabane server keeps
6664
+ // its prefix on the card as before.
6665
+ name: "turn-control answer carries its server",
6467
6666
  request: makeRequest3(),
6468
6667
  nativeStream: [
6469
6668
  threadStarted(NEW_THREAD_ID),
6669
+ agentMessage("m1", "On it."),
6470
6670
  toolFrame("item.completed", {
6471
- id: "mcp-failed",
6671
+ id: "answer-1",
6472
6672
  type: "mcp_tool_call",
6473
- server: "other",
6474
- tool: "lookup",
6673
+ server: "cabane_companion",
6674
+ tool: "answer",
6675
+ arguments: {},
6676
+ status: "completed",
6677
+ result: { content: [{ type: "text", text: '{"ok":true,"kind":"answer"}' }] }
6678
+ }),
6679
+ agentMessage("m2", "The answer."),
6680
+ turnCompleted()
6681
+ ],
6682
+ expected: [
6683
+ startedSession(NEW_THREAD_ID),
6684
+ { type: "text", body: "On it.", terminal: false },
6685
+ {
6686
+ type: "tool",
6687
+ id: "answer-1",
6688
+ name: "cabane_companion.answer",
6689
+ mcpServer: "cabane_companion",
6690
+ phase: "start",
6691
+ summary: "",
6692
+ input: {}
6693
+ },
6694
+ {
6695
+ type: "tool",
6696
+ id: "answer-1",
6697
+ name: "cabane_companion.answer",
6698
+ mcpServer: "cabane_companion",
6699
+ phase: "done",
6700
+ summary: "",
6701
+ input: {},
6702
+ result: [{ type: "text", text: '{"ok":true,"kind":"answer"}' }]
6703
+ },
6704
+ { type: "text", body: "The answer.", terminal: true },
6705
+ closingSession(NEW_THREAD_ID),
6706
+ { type: "result", ok: true }
6707
+ ]
6708
+ },
6709
+ {
6710
+ // A transport/tool failure with no MCP result payload still uses the real adapter error message,
6711
+ // never `undefined`.
6712
+ name: "failed MCP call without result keeps error message fallback",
6713
+ request: makeRequest3(),
6714
+ nativeStream: [
6715
+ threadStarted(NEW_THREAD_ID),
6716
+ toolFrame("item.completed", {
6717
+ id: "mcp-failed",
6718
+ type: "mcp_tool_call",
6719
+ server: "other",
6720
+ tool: "lookup",
6475
6721
  arguments: { q: "receipt" },
6476
6722
  status: "failed",
6477
6723
  result: { content: [], isError: true },
@@ -6485,6 +6731,7 @@ var init_conformance4 = __esm({
6485
6731
  type: "tool",
6486
6732
  id: "mcp-failed",
6487
6733
  name: "other.lookup",
6734
+ mcpServer: "other",
6488
6735
  phase: "start",
6489
6736
  summary: "receipt",
6490
6737
  input: { q: "receipt" }
@@ -6493,6 +6740,7 @@ var init_conformance4 = __esm({
6493
6740
  type: "tool",
6494
6741
  id: "mcp-failed",
6495
6742
  name: "other.lookup",
6743
+ mcpServer: "other",
6496
6744
  phase: "error",
6497
6745
  summary: "receipt",
6498
6746
  input: { q: "receipt" },
@@ -6529,6 +6777,7 @@ var init_conformance4 = __esm({
6529
6777
  type: "tool",
6530
6778
  id: "mcp-image",
6531
6779
  name: "other.render",
6780
+ mcpServer: "other",
6532
6781
  phase: "start",
6533
6782
  summary: "",
6534
6783
  input: {}
@@ -6537,6 +6786,7 @@ var init_conformance4 = __esm({
6537
6786
  type: "tool",
6538
6787
  id: "mcp-image",
6539
6788
  name: "other.render",
6789
+ mcpServer: "other",
6540
6790
  phase: "done",
6541
6791
  summary: "",
6542
6792
  input: {},
@@ -6584,11 +6834,20 @@ var init_conformance4 = __esm({
6584
6834
  ],
6585
6835
  expected: [
6586
6836
  startedSession(NEW_THREAD_ID),
6587
- { type: "tool", id: "t1", name: "list", phase: "start", summary: "", input: {} },
6588
6837
  {
6589
6838
  type: "tool",
6590
6839
  id: "t1",
6591
6840
  name: "list",
6841
+ mcpServer: "cabane",
6842
+ phase: "start",
6843
+ summary: "",
6844
+ input: {}
6845
+ },
6846
+ {
6847
+ type: "tool",
6848
+ id: "t1",
6849
+ name: "list",
6850
+ mcpServer: "cabane",
6592
6851
  phase: "done",
6593
6852
  summary: "/product/tasks",
6594
6853
  input: { path: "/product/tasks" },
@@ -7582,11 +7841,10 @@ function required(env, name) {
7582
7841
  function runnerPlace(env) {
7583
7842
  const workRoot2 = env[PLACE_ENV.workRoot];
7584
7843
  const transcriptDir = env[PLACE_ENV.transcriptDir];
7585
- const box = env[PLACE_ENV.box];
7586
7844
  return {
7587
7845
  ...workRoot2 ? { workRoot: workRoot2 } : {},
7588
7846
  ...transcriptDir ? { transcriptDir } : {},
7589
- ...box ? { box } : {}
7847
+ ...env[PLACE_ENV.dedicatedRoot] === "1" ? { dedicatedRoot: true } : {}
7590
7848
  };
7591
7849
  }
7592
7850
  function parseRunnerInput(env, stdin) {
@@ -7671,7 +7929,7 @@ var init_protocol = __esm({
7671
7929
  PLACE_ENV = {
7672
7930
  workRoot: "CABANE_WORK_ROOT",
7673
7931
  transcriptDir: "CABANE_TRANSCRIPT_DIR",
7674
- box: "CABANE_BOX"
7932
+ dedicatedRoot: "CABANE_WORK_ROOT_DEDICATED"
7675
7933
  };
7676
7934
  runtimeHealthSchema = z14.object({
7677
7935
  quotaState: z14.enum(["ok", "limited", "auth_expired", "unknown"]),
@@ -7729,568 +7987,157 @@ var init_protocol = __esm({
7729
7987
  }
7730
7988
  });
7731
7989
 
7732
- // src/runner/launch.ts
7733
- import { createInterface } from "readline";
7734
- function selfRunnerCommand() {
7735
- const script = process.argv[1];
7736
- if (!script) throw new Error("runner: cannot tell which script started this companion");
7737
- return [process.execPath, ...process.execArgv, script, "run-turn"];
7990
+ // src/grants.ts
7991
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
7992
+ import { join as join14 } from "path";
7993
+ import { z as z15 } from "zod";
7994
+ function allowedGrantVariable(name) {
7995
+ return name.length <= 100 && VARIABLE_NAME_RE.test(name) && !name.startsWith("CABANE_");
7738
7996
  }
7739
- function wrappedRunnerCommand(prefix, own) {
7740
- return [...prefix ?? [], ...own];
7997
+ function environmentSafe(value) {
7998
+ return !value.includes("\0");
7741
7999
  }
7742
- function runnerExecEnv(base, boxed) {
7743
- const env = {};
7744
- for (const [k, v] of Object.entries(base)) {
7745
- if (v === void 0) continue;
7746
- if (boxed && BOXED_ENV_DROPPED.includes(k)) continue;
7747
- env[k] = v;
7748
- }
7749
- return env;
8000
+ function grantsPath() {
8001
+ return join14(cabaneDir(), "grants.json");
7750
8002
  }
7751
- function healthSnapshot(store) {
8003
+ function readGrants(keys, report) {
8004
+ if (keys.length === 0) return {};
8005
+ const file = loadGrantsFile(report);
7752
8006
  const out = {};
7753
- for (const r of store?.reports() ?? []) {
7754
- out[r.runtime] = {
7755
- quotaState: r.quotaState ?? "unknown",
7756
- limitedUntil: r.limitedUntil ?? null,
7757
- lastError: r.lastError ?? null
7758
- };
8007
+ for (const key of keys) {
8008
+ if (!allowedGrantVariable(key) || !Object.hasOwn(file, key)) continue;
8009
+ const entry = {};
8010
+ for (const [variable, value] of Object.entries(file[key])) {
8011
+ if (allowedGrantVariable(variable) && environmentSafe(value)) entry[variable] = value;
8012
+ else report.refusedVariable(key, variable);
8013
+ }
8014
+ out[key] = entry;
7759
8015
  }
7760
8016
  return out;
7761
8017
  }
7762
- function runnerSpec(opts, launch, duties, runnerId) {
7763
- return {
7764
- runnerId,
7765
- ...opts.deviceId ? { deviceId: opts.deviceId } : {},
7766
- bootId,
7767
- workspaceId: opts.workspaceId,
7768
- workspaceSlug: opts.workspaceSlug,
7769
- agentId: opts.agentId,
7770
- agentUsername: opts.agentUsername,
7771
- agentName: String(opts.log.bindings().agentName ?? opts.agentUsername),
7772
- local: opts.local,
7773
- runConfig: opts.runConfig,
7774
- workRoot: opts.workRoot,
7775
- ...opts.opencodeServerUrl ? { opencodeServerUrl: opts.opencodeServerUrl } : {},
7776
- // Read now, per turn, the way adapter selection reads them.
7777
- claudeCodeAvailable: opts.claudeCodeAvailable?.() ?? true,
7778
- codexAvailable: opts.codexAvailable?.() ?? false,
7779
- ...opts.idleTimeoutMs !== void 0 ? { idleTimeoutMs: opts.idleTimeoutMs } : {},
7780
- ...opts.totalTimeoutMs !== void 0 ? { totalTimeoutMs: opts.totalTimeoutMs } : {},
7781
- ...opts.leaseRenewalMs !== void 0 ? { leaseRenewalMs: opts.leaseRenewalMs } : {},
7782
- ...opts.preparingRowDelayMs !== void 0 ? { preparingRowDelayMs: opts.preparingRowDelayMs } : {},
7783
- transcripts: opts.transcriptDir !== void 0,
7784
- logLevel: opts.log.level,
7785
- connectorHealth: healthSnapshot(launch.connectorHealth),
7786
- sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
7787
- };
7788
- }
7789
- function relayRunnerLog(line, log2) {
7790
- if (!line.trim()) return;
7791
- let record = null;
8018
+ function loadGrantsFile(report) {
8019
+ const path = grantsPath();
8020
+ if (!existsSync9(path)) return {};
8021
+ let raw;
7792
8022
  try {
7793
- const parsed = JSON.parse(line);
7794
- if (parsed && typeof parsed === "object") record = parsed;
8023
+ raw = readFileSync7(path, "utf8");
8024
+ } catch (err) {
8025
+ const code = err.code ?? "unknown error";
8026
+ report.unreadable(`couldn't read ${path}: ${code}`);
8027
+ return {};
8028
+ }
8029
+ if (raw.trim().length === 0) return {};
8030
+ let parsed;
8031
+ try {
8032
+ parsed = JSON.parse(raw);
7795
8033
  } catch {
7796
- record = null;
8034
+ report.unreadable(`${path} is not valid JSON.`);
8035
+ return {};
7797
8036
  }
7798
- if (!record || typeof record.level !== "number") {
7799
- log2.debug({ runnerStderr: line }, "runner: output");
7800
- return;
8037
+ const result = grantsFileSchema.safeParse(parsed);
8038
+ if (!result.success) {
8039
+ report.unreadable(
8040
+ `${path} must map a secret's variable name to an object of "VARIABLE": "value" pairs (e.g. { "HAS_STAGING_DB": { "DATABASE_URL": "postgres://\u2026" } }).`
8041
+ );
8042
+ return {};
7801
8043
  }
7802
- const {
7803
- level,
7804
- msg,
7805
- time: _time,
7806
- pid: _pid,
7807
- hostname: _hostname,
7808
- agentName: _agentName,
7809
- ...fields
7810
- } = record;
7811
- const method = PINO_LEVELS[level] ?? "info";
7812
- log2[method](fields, typeof msg === "string" ? msg : "");
8044
+ return result.data;
7813
8045
  }
7814
- function lines(stream, onLine) {
7815
- const rl = createInterface({ input: stream, crlfDelay: Infinity });
7816
- rl.on("line", onLine);
7817
- return new Promise((resolve2) => rl.once("close", resolve2));
8046
+ function harnessSecretEnv(parts) {
8047
+ const env = {};
8048
+ const dropped = [];
8049
+ for (const [name, value] of Object.entries({
8050
+ ...parts.deviceDefaults,
8051
+ ...parts.granted,
8052
+ ...parts.issued
8053
+ })) {
8054
+ if (environmentSafe(value)) env[name] = value;
8055
+ else dropped.push(name);
8056
+ }
8057
+ return { env, dropped };
7818
8058
  }
7819
- function giveBackLocks(locks, prefix, remote, launch, log2) {
7820
- locks.withdraw(prefix);
7821
- const stop2 = launch.stopRemoteSession ?? ((url, id) => stopRemoteOpencodeSession(url, id));
7822
- for (const { key, owner } of locks.heldBy(prefix)) {
7823
- const left = remote.filter((r) => opencodeLockKey(r.serverUrl) === key);
7824
- if (!left.length) {
7825
- locks.release(owner);
7826
- continue;
8059
+ function grantEnv(sent, issuedBySecret, issued) {
8060
+ const env = {};
8061
+ for (const [key, entry] of Object.entries(sent ?? {})) {
8062
+ if (!Object.hasOwn(issuedBySecret, key)) continue;
8063
+ for (const [variable, value] of Object.entries(entry)) {
8064
+ if (Object.hasOwn(issued, variable) || Object.hasOwn(env, variable)) continue;
8065
+ env[variable] = value;
7827
8066
  }
7828
- void (async () => {
7829
- for (let attempt = 1; ; attempt++) {
7830
- const stopped = await Promise.all(left.map((r) => stop2(r.serverUrl, r.sessionId))).catch(
7831
- () => [false]
7832
- );
7833
- if (stopped.every(Boolean)) break;
7834
- if (attempt === 1)
7835
- log2.warn(
7836
- { key, sessions: left.map((r) => r.sessionId) },
7837
- "runner: a dead runner left work on a shared server that could not be confirmed stopped; holding the server until it is"
7838
- );
7839
- await new Promise((resolve2) => setTimeout(resolve2, launch.reconcileRetryMs ?? 5e3));
7840
- }
7841
- locks.release(owner);
7842
- })();
7843
8067
  }
8068
+ return env;
7844
8069
  }
7845
- async function mintForRunner(opts, payload, handleOpts, signal, log2) {
7846
- try {
7847
- const minted = await opts.api.mintRunnerCredential({
7848
- conversationId: payload.conversationId,
7849
- messageId: payload.messageId,
7850
- ...payload.episode !== void 0 ? { episode: payload.episode } : {},
7851
- // The one turn the credential will operate: this run's.
7852
- turnId: handleOpts.turnId
7853
- });
7854
- return {
7855
- ok: true,
7856
- credential: minted.credential,
7857
- runnerId: minted.runnerId,
7858
- deviceGrants: minted.deviceGrants ?? []
7859
- };
7860
- } catch (err) {
7861
- if (signal.aborted) return { ok: false, reason: "cancelled" };
7862
- const status2 = err instanceof ApiError ? err.status : 0;
7863
- if (status2 === 404) return { ok: false, reason: "runner_credential_not_found" };
7864
- if (status2 === 409 && apiErrorCode(err) === "dispatch_not_admitted")
7865
- return { ok: false, reason: "runner_credential_not_admitted" };
7866
- log2.warn(apiErrorLogFields(err), "runner: couldn't get a credential for the turn's runner");
7867
- const reason = `fetch_failed: ${describeError(err)}`;
7868
- return { ok: false, reason, errorReason: reason };
8070
+ var VARIABLE_NAME_RE, grantsFileSchema;
8071
+ var init_grants = __esm({
8072
+ "src/grants.ts"() {
8073
+ "use strict";
8074
+ init_config();
8075
+ VARIABLE_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
8076
+ grantsFileSchema = z15.record(z15.string(), z15.record(z15.string(), z15.string()));
7869
8077
  }
8078
+ });
8079
+
8080
+ // src/work-dir.ts
8081
+ import { statSync as statSync3 } from "fs";
8082
+ import { join as join15 } from "path";
8083
+ function pairDirectory(root, pair) {
8084
+ return join15(root, pair.workspaceSlug, pair.agentUsername, pair.conversationId);
7870
8085
  }
7871
- async function settleUnstarted(opts, payload, turnId, errorReason, log2) {
8086
+ function chooseStandingDirectory(named, pairDir, isDirectory = directoryExists) {
8087
+ const skipped = [];
8088
+ for (const candidate of named) {
8089
+ if (isDirectory(candidate.path)) return { cwd: candidate.path, skipped };
8090
+ skipped.push(candidate);
8091
+ }
8092
+ return { cwd: pairDir, skipped };
8093
+ }
8094
+ function directoryExists(path) {
7872
8095
  try {
7873
- await opts.api.setActiveRun(opts.workspaceId, payload.conversationId, payload.agentId, {
7874
- activeRunStartedAt: null,
7875
- turnId,
7876
- outcome: "failed",
7877
- ...errorReason ? { errorReason: errorReason.slice(0, ERROR_REASON_MAX) } : {},
7878
- readThrough: payload.messageId
7879
- });
7880
- } catch (err) {
7881
- log2.warn(
7882
- { ...apiErrorLogFields(err), turnId },
7883
- "Couldn't update the turn's status in Cabane. Check the conversation before trying again."
7884
- );
8096
+ return statSync3(path).isDirectory();
8097
+ } catch {
8098
+ return false;
7885
8099
  }
7886
8100
  }
7887
- async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
7888
- const startedAt = Date.now();
7889
- const { turnId } = handleOpts;
7890
- const oomKilled = (id) => (launch.oomKilled ?? turnScopeOomKilled)(id).catch(() => false);
7891
- const log2 = opts.log.child({
7892
- workspaceId: opts.workspaceId,
7893
- conversationId: payload.conversationId,
7894
- agentId: payload.agentId,
7895
- messageId: payload.messageId
7896
- });
7897
- const controller = new AbortController();
7898
- duties.registerAbort(turnId, controller);
7899
- const stopRunner = new AbortController();
7900
- let ready = false;
7901
- let child;
7902
- let stopAnnounced = false;
7903
- const announceStop = () => {
7904
- if (stopAnnounced || !child?.stdin.writable) return;
7905
- stopAnnounced = true;
7906
- child.stdin.write(encodeLauncherMessage({ cabaneLauncher: "stop" }));
7907
- };
7908
- const forwardStop = () => {
7909
- announceStop();
7910
- if (ready) stopRunner.abort();
7911
- };
7912
- controller.signal.addEventListener("abort", forwardStop, { once: true });
7913
- const attempt = {
7914
- workspaceId: opts.workspaceId,
7915
- conversationId: payload.conversationId,
7916
- agentId: payload.agentId,
7917
- turnId
7918
- };
7919
- const minted = await mintForRunner(opts, payload, handleOpts, controller.signal, log2);
7920
- if (!minted.ok) {
7921
- controller.signal.removeEventListener("abort", forwardStop);
7922
- duties.releaseAbort(turnId, controller);
7923
- const durationMs2 = Date.now() - startedAt;
7924
- await settleUnstarted(opts, payload, turnId, minted.errorReason, log2);
7925
- duties.notifyEnd({ id: payload.messageId, ok: false, durationMs: durationMs2, reason: minted.reason });
7926
- return { ok: false, durationMs: durationMs2, reason: minted.reason };
8101
+ var init_work_dir = __esm({
8102
+ "src/work-dir.ts"() {
8103
+ "use strict";
7927
8104
  }
8105
+ });
8106
+
8107
+ // src/turn-control-tools.ts
8108
+ import { randomUUID as randomUUID2 } from "crypto";
8109
+ import { z as z16 } from "zod";
8110
+ function createSkipState() {
8111
+ return { skipped: false, reason: null };
8112
+ }
8113
+ function toolError(text) {
8114
+ return { isError: true, content: [{ type: "text", text }] };
8115
+ }
8116
+ async function performAct(acts, act) {
7928
8117
  let result;
7929
- let thrown;
7930
- let code;
7931
- let handle;
7932
- const groups = [];
7933
- const remote = [];
7934
- const lockOwner = (id) => `${turnId}#${id}`;
7935
- const locks = launch.locks;
7936
- try {
7937
- handle = await launch.provider.acquire(attempt);
7938
- } catch (err) {
7939
- const reason2 = `runner_unavailable: ${describeError(err)}`;
7940
- log2.warn(
7941
- { turnId, provider: launch.provider.name, err: describeError(err) },
7942
- "runner: no place to run the turn"
7943
- );
7944
- controller.signal.removeEventListener("abort", forwardStop);
7945
- duties.releaseAbort(turnId, controller);
7946
- await settleUnstarted(opts, payload, turnId, reason2, log2);
7947
- await launch.reportRunnerGone?.({
7948
- workspaceId: opts.workspaceId,
7949
- conversationId: payload.conversationId,
7950
- agentId: payload.agentId,
7951
- messageId: payload.messageId,
7952
- runnerId: minted.runnerId,
7953
- reason: "provider_failed"
7954
- });
7955
- const durationMs2 = Date.now() - startedAt;
7956
- duties.notifyEnd({ id: payload.messageId, ok: false, durationMs: durationMs2, reason: reason2 });
7957
- return { ok: false, durationMs: durationMs2, reason: reason2 };
7958
- }
7959
8118
  try {
7960
- const boxed = launch.boxed === true;
7961
- const env = runnerExecEnv(launch.env ?? process.env, boxed);
7962
- const grants = minted.deviceGrants.length > 0 ? launch.grants?.(minted.deviceGrants) ?? {} : {};
7963
- const input = {
7964
- baseUrl: opts.baseUrl,
7965
- credential: minted.credential,
7966
- ...boxed ? { secrets: launch.secrets?.() ?? {} } : {},
7967
- ...Object.keys(grants).length > 0 ? { grants } : {},
7968
- dispatch: payload,
7969
- turnId,
7970
- resumed: handleOpts.resumed,
7971
- spec: runnerSpec(opts, launch, duties, minted.runnerId)
7972
- };
7973
- Object.assign(env, runnerEnv(input));
7974
- const running = launch.provider.exec(handle, launch.command, env, {
7975
- signal: stopRunner.signal,
7976
- stdin: runnerStdin(input)
7977
- });
7978
- child = running;
7979
- if (controller.signal.aborted) announceStop();
7980
- log2.debug(
7981
- { turnId, runnerPid: running.pid, provider: launch.provider.name },
7982
- "runner: started"
7983
- );
7984
- const stdoutDone = lines(running.stdout, (line) => {
7985
- const event = decodeRunnerEvent(line);
7986
- if (!event) {
7987
- log2.debug({ runnerStdout: line }, "runner: output");
7988
- return;
7989
- }
7990
- switch (event.cabaneRunner) {
7991
- case "ready":
7992
- ready = true;
7993
- if (controller.signal.aborted) stopRunner.abort();
7994
- return;
7995
- case "start":
7996
- duties.notifyStart({
7997
- id: event.id,
7998
- conversationId: event.conversationId,
7999
- message: event.message
8000
- });
8001
- return;
8002
- case "end": {
8003
- const { cabaneRunner: _kind, ...info } = event;
8004
- duties.notifyEnd(info);
8005
- return;
8006
- }
8007
- case "session_write_notice":
8008
- duties.markSessionWriteNotice();
8009
- return;
8010
- case "connector_health":
8011
- launch.connectorHealth?.recordSettle(event.runtime, {
8012
- ok: event.ok,
8013
- errorReason: event.errorReason
8014
- });
8015
- return;
8016
- case "result":
8017
- result = event.result;
8018
- return;
8019
- case "error":
8020
- thrown = event.message;
8021
- return;
8022
- case "lock":
8023
- if (!locks) {
8024
- log2.warn(
8025
- { key: event.key },
8026
- "runner: asked for a device lock this launcher has none of"
8027
- );
8028
- return;
8029
- }
8030
- locks.request(
8031
- event.key,
8032
- lockOwner(event.id),
8033
- () => running.stdin.write(encodeLauncherMessage({ cabaneLauncher: "granted", id: event.id }))
8034
- );
8035
- return;
8036
- case "unlock":
8037
- locks?.release(lockOwner(event.id));
8038
- return;
8039
- case "process_group":
8040
- if (!boxed) groups.push(event.pgid);
8041
- return;
8042
- case "remote_session":
8043
- remote.push({ serverUrl: event.serverUrl, sessionId: event.sessionId });
8044
- return;
8045
- case "terminated":
8046
- void oomKilled(turnId).then((oom) => {
8047
- if (running.stdin.writable) {
8048
- running.stdin.write(
8049
- encodeLauncherMessage({
8050
- cabaneLauncher: "cause",
8051
- cause: oom ? "out_of_memory" : null
8052
- })
8053
- );
8054
- }
8055
- });
8056
- return;
8057
- }
8058
- });
8059
- const stderrDone = lines(running.stderr, (line) => relayRunnerLog(line, opts.log));
8060
- code = await running.exit;
8061
- await Promise.all([stdoutDone, stderrDone]);
8062
- } finally {
8063
- if (locks) giveBackLocks(locks, `${turnId}#`, remote, launch, log2);
8064
- child?.stdin.end();
8065
- controller.signal.removeEventListener("abort", forwardStop);
8066
- duties.releaseAbort(turnId, controller);
8067
- await launch.provider.release(handle, { destroy: false }).catch(
8068
- (err) => log2.warn({ err: describeError(err) }, "runner: couldn't give its place back")
8069
- );
8070
- }
8071
- if (result) return result;
8072
- const durationMs = Date.now() - startedAt;
8073
- const stopped = controller.signal.aborted && thrown === void 0;
8074
- const reason = stopped ? "cancelled" : thrown !== void 0 ? `runner_failed: ${thrown}` : await oomKilled(turnId) ? "out_of_memory" : `runner_exited: ${code}`;
8075
- await child?.reap(groups);
8076
- const reaped = await (launch.reapTurn ?? reapTurnScopes)(turnId).catch((err) => ({
8077
- failures: [err instanceof Error ? err.message : String(err)]
8078
- }));
8079
- if (reaped.failures.length) {
8080
- log2.warn(
8081
- { turnId, failures: reaped.failures },
8082
- "runner: couldn't stop what the dead runner's turn left running"
8119
+ result = await acts.api.turnAct(
8120
+ acts.workspaceId,
8121
+ acts.turnId,
8122
+ act,
8123
+ AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
8083
8124
  );
8084
- }
8085
- try {
8086
- await opts.api.setActiveRun(opts.workspaceId, payload.conversationId, payload.agentId, {
8087
- activeRunStartedAt: null,
8088
- turnId,
8089
- ...stopped ? { outcome: "interrupted" } : { outcome: "failed", errorReason: reason.slice(0, ERROR_REASON_MAX) },
8090
- readThrough: payload.messageId
8091
- });
8092
8125
  } catch (err) {
8093
- log2.warn(
8094
- { ...apiErrorLogFields(err), turnId },
8095
- "Couldn't update the turn's status in Cabane. Check the conversation before trying again."
8096
- );
8126
+ if (err instanceof ApiError && err.status >= 500) {
8127
+ return uncertainOutcome(`${err.status}: ${apiErrorMessage(err)}`);
8128
+ }
8129
+ if (err instanceof ApiError) {
8130
+ if (err.status === 409 && apiErrorCode(err) === "no_active_turn") {
8131
+ return toolError(
8132
+ "No active turn: this turn has already settled, so the act was not performed."
8133
+ );
8134
+ }
8135
+ return toolError(`The act was refused (${err.status}): ${apiErrorMessage(err)}`);
8136
+ }
8137
+ return uncertainOutcome(err instanceof Error ? err.message : String(err));
8097
8138
  }
8098
- await launch.reportRunnerGone?.({
8099
- workspaceId: opts.workspaceId,
8100
- conversationId: payload.conversationId,
8101
- agentId: payload.agentId,
8102
- messageId: payload.messageId,
8103
- runnerId: minted.runnerId,
8104
- reason: "exited"
8105
- });
8106
- if (thrown !== void 0) throw new Error(thrown);
8107
- if (stopped) log2.info({ turnId, code }, "runner: stopped before it could report");
8108
- else log2.warn({ turnId, code }, "runner: exited without reporting a result");
8109
- duties.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
8110
- return { ok: false, durationMs, reason };
8111
- }
8112
- var BOXED_ENV_DROPPED, PINO_LEVELS;
8113
- var init_launch = __esm({
8114
- "src/runner/launch.ts"() {
8115
- "use strict";
8116
- init_src();
8117
- init_opencode_lock();
8118
- init_api_error_shape();
8119
- init_errors();
8120
- init_boot_id();
8121
- init_turn_containment();
8122
- init_protocol();
8123
- BOXED_ENV_DROPPED = [
8124
- "ANTHROPIC_API_KEY",
8125
- "CLAUDE_CODE_OAUTH_TOKEN",
8126
- "CODEX_API_KEY",
8127
- "GH_TOKEN",
8128
- "GITHUB_TOKEN",
8129
- "DBUS_SESSION_BUS_ADDRESS",
8130
- "XDG_RUNTIME_DIR"
8131
- ];
8132
- PINO_LEVELS = {
8133
- 10: "trace",
8134
- 20: "debug",
8135
- 30: "info",
8136
- 40: "warn",
8137
- 50: "error",
8138
- 60: "fatal"
8139
- };
8140
- }
8141
- });
8142
-
8143
- // src/grants.ts
8144
- import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
8145
- import { join as join14 } from "path";
8146
- import { z as z15 } from "zod";
8147
- function allowedGrantVariable(name) {
8148
- return name.length <= 100 && VARIABLE_NAME_RE.test(name) && !name.startsWith("CABANE_");
8149
- }
8150
- function environmentSafe(value) {
8151
- return !value.includes("\0");
8152
- }
8153
- function grantsPath() {
8154
- return join14(cabaneDir(), "grants.json");
8155
- }
8156
- function readGrants(keys, report) {
8157
- if (keys.length === 0) return {};
8158
- const file = loadGrantsFile(report);
8159
- const out = {};
8160
- for (const key of keys) {
8161
- if (!allowedGrantVariable(key) || !Object.hasOwn(file, key)) continue;
8162
- const entry = {};
8163
- for (const [variable, value] of Object.entries(file[key])) {
8164
- if (allowedGrantVariable(variable) && environmentSafe(value)) entry[variable] = value;
8165
- else report.refusedVariable(key, variable);
8166
- }
8167
- out[key] = entry;
8168
- }
8169
- return out;
8170
- }
8171
- function loadGrantsFile(report) {
8172
- const path = grantsPath();
8173
- if (!existsSync9(path)) return {};
8174
- let raw;
8175
- try {
8176
- raw = readFileSync7(path, "utf8");
8177
- } catch (err) {
8178
- const code = err.code ?? "unknown error";
8179
- report.unreadable(`couldn't read ${path}: ${code}`);
8180
- return {};
8181
- }
8182
- if (raw.trim().length === 0) return {};
8183
- let parsed;
8184
- try {
8185
- parsed = JSON.parse(raw);
8186
- } catch {
8187
- report.unreadable(`${path} is not valid JSON.`);
8188
- return {};
8189
- }
8190
- const result = grantsFileSchema.safeParse(parsed);
8191
- if (!result.success) {
8192
- report.unreadable(
8193
- `${path} must map a secret's variable name to an object of "VARIABLE": "value" pairs (e.g. { "HAS_PROD_ACCESS": { "RENDER_API_KEY": "rnd_\u2026" } }).`
8194
- );
8195
- return {};
8196
- }
8197
- return result.data;
8198
- }
8199
- function harnessSecretEnv(parts) {
8200
- const env = {};
8201
- const dropped = [];
8202
- for (const [name, value] of Object.entries({
8203
- ...parts.deviceDefaults,
8204
- ...parts.granted,
8205
- ...parts.issued
8206
- })) {
8207
- if (environmentSafe(value)) env[name] = value;
8208
- else dropped.push(name);
8209
- }
8210
- return { env, dropped };
8211
- }
8212
- function grantEnv(sent, issuedBySecret, issued) {
8213
- const env = {};
8214
- for (const [key, entry] of Object.entries(sent ?? {})) {
8215
- if (!Object.hasOwn(issuedBySecret, key)) continue;
8216
- for (const [variable, value] of Object.entries(entry)) {
8217
- if (Object.hasOwn(issued, variable) || Object.hasOwn(env, variable)) continue;
8218
- env[variable] = value;
8219
- }
8220
- }
8221
- return env;
8222
- }
8223
- var VARIABLE_NAME_RE, grantsFileSchema;
8224
- var init_grants = __esm({
8225
- "src/grants.ts"() {
8226
- "use strict";
8227
- init_config();
8228
- VARIABLE_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
8229
- grantsFileSchema = z15.record(z15.string(), z15.record(z15.string(), z15.string()));
8230
- }
8231
- });
8232
-
8233
- // src/work-dir.ts
8234
- import { statSync as statSync3 } from "fs";
8235
- import { join as join15 } from "path";
8236
- function pairDirectory(root, pair) {
8237
- return join15(root, pair.workspaceSlug, pair.agentUsername, pair.conversationId);
8238
- }
8239
- function chooseStandingDirectory(named, pairDir, isDirectory = directoryExists) {
8240
- const skipped = [];
8241
- for (const candidate of named) {
8242
- if (isDirectory(candidate.path)) return { cwd: candidate.path, skipped };
8243
- skipped.push(candidate);
8244
- }
8245
- return { cwd: pairDir, skipped };
8246
- }
8247
- function directoryExists(path) {
8248
- try {
8249
- return statSync3(path).isDirectory();
8250
- } catch {
8251
- return false;
8252
- }
8253
- }
8254
- var init_work_dir = __esm({
8255
- "src/work-dir.ts"() {
8256
- "use strict";
8257
- }
8258
- });
8259
-
8260
- // src/turn-control-tools.ts
8261
- import { randomUUID as randomUUID2 } from "crypto";
8262
- import { z as z16 } from "zod";
8263
- function createSkipState() {
8264
- return { skipped: false, reason: null };
8265
- }
8266
- function toolError(text) {
8267
- return { isError: true, content: [{ type: "text", text }] };
8268
- }
8269
- async function performAct(acts, act) {
8270
- let result;
8271
- try {
8272
- result = await acts.api.turnAct(
8273
- acts.workspaceId,
8274
- acts.turnId,
8275
- act,
8276
- AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
8277
- );
8278
- } catch (err) {
8279
- if (err instanceof ApiError && err.status >= 500) {
8280
- return uncertainOutcome(`${err.status}: ${apiErrorMessage(err)}`);
8281
- }
8282
- if (err instanceof ApiError) {
8283
- if (err.status === 409 && apiErrorCode(err) === "no_active_turn") {
8284
- return toolError(
8285
- "No active turn: this turn has already settled, so the act was not performed."
8286
- );
8287
- }
8288
- return toolError(`The act was refused (${err.status}): ${apiErrorMessage(err)}`);
8289
- }
8290
- return uncertainOutcome(err instanceof Error ? err.message : String(err));
8291
- }
8292
- if (!result.ok) return toolError(JSON.stringify(result));
8293
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
8139
+ if (!result.ok) return toolError(JSON.stringify(result));
8140
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
8294
8141
  }
8295
8142
  function uncertainOutcome(cause) {
8296
8143
  return toolError(
@@ -8428,6 +8275,13 @@ function createTurnControlMcpServer(acts, skipState) {
8428
8275
  }),
8429
8276
  { annotations: ACT_TOOL, alwaysLoad: true }
8430
8277
  ),
8278
+ tool(
8279
+ ANSWER_TOOL2,
8280
+ TURN_CONTROL_TOOL_DESCRIPTIONS.answer,
8281
+ {},
8282
+ async () => performAct(acts, { kind: "answer", callId: randomUUID2() }),
8283
+ { annotations: ACT_TOOL, alwaysLoad: true }
8284
+ ),
8431
8285
  // The inverse. No arguments — it can only ever stand down the
8432
8286
  // caller's own wake in the conversation it's holding a turn in.
8433
8287
  tool(
@@ -8440,7 +8294,7 @@ function createTurnControlMcpServer(acts, skipState) {
8440
8294
  ]
8441
8295
  });
8442
8296
  }
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;
8297
+ 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
8298
  var init_turn_control_tools = __esm({
8445
8299
  "src/turn-control-tools.ts"() {
8446
8300
  "use strict";
@@ -8448,13 +8302,14 @@ var init_turn_control_tools = __esm({
8448
8302
  init_descriptions();
8449
8303
  init_errors();
8450
8304
  init_api_error_shape();
8451
- COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
8305
+ COMPANION_LOCAL_MCP_SERVER = TURN_CONTROL_MCP_SERVER;
8452
8306
  COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
8453
8307
  SKIP_TURN_TOOL = "skip_turn";
8454
8308
  ASK_TOOL = "ask";
8455
8309
  ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
8456
8310
  WITHDRAW_ASK_TOOL = "withdraw_ask";
8457
8311
  WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
8312
+ ANSWER_TOOL2 = "answer";
8458
8313
  WAKE_ME_TOOL = "wake_me";
8459
8314
  WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
8460
8315
  CANCEL_WAKE_TOOL = "cancel_wake";
@@ -8713,73 +8568,6 @@ var init_turn_seq_floor = __esm({
8713
8568
  }
8714
8569
  });
8715
8570
 
8716
- // src/prepare-failure.ts
8717
- import { spawn as spawn4 } from "child_process";
8718
- import { hostname } from "os";
8719
- function prepareFailureCause(error) {
8720
- const message2 = error instanceof Error ? error.message : String(error);
8721
- if (/timed out/i.test(message2)) return "prepare hook timed out";
8722
- if (/MODULE_NOT_FOUND|Cannot find (?:module|package)/.test(message2))
8723
- return "prepare hook module missing; check the configured command and its installation for this device";
8724
- if (/not valid JSON|JSON must carry|produced no output/.test(message2))
8725
- return "prepare hook returned malformed output; expected a cwd path or JSON with a non-empty cwd";
8726
- if (/ENOENT/.test(message2)) return "prepare hook executable missing (ENOENT)";
8727
- if (/EACCES/.test(message2)) return "prepare hook executable is not accessible (EACCES)";
8728
- const exit = message2.match(/exited with code (\d+)/)?.[1];
8729
- return exit ? `prepare hook exited with code ${exit}` : "prepare hook failed; inspect the device-local hook log";
8730
- }
8731
- async function runPrepareWithReporting(runner, hook, input, context, reportError) {
8732
- try {
8733
- return await runner(hook, input);
8734
- } catch (error) {
8735
- if (hook.failureReporter) {
8736
- try {
8737
- await reportFailure(hook.failureReporter, {
8738
- workspaceId: input.workspaceId,
8739
- conversationId: input.conversationId,
8740
- agentId: input.agentId,
8741
- agentUsername: input.agentUsername,
8742
- turnId: context.turnId,
8743
- deviceId: context.deviceId ?? hostname(),
8744
- cause: prepareFailureCause(error)
8745
- });
8746
- } catch {
8747
- reportError();
8748
- }
8749
- }
8750
- throw error;
8751
- }
8752
- }
8753
- function reportFailure(config, body) {
8754
- return new Promise((resolve2, reject) => {
8755
- const child = spawn4(config.command, config.args ?? [], {
8756
- stdio: ["pipe", "ignore", "ignore"],
8757
- env: { ...process.env, ...config.env }
8758
- });
8759
- const timer = setTimeout(() => {
8760
- child.kill("SIGKILL");
8761
- reject(new Error("reporter timeout"));
8762
- }, config.timeoutMs ?? 15e3);
8763
- child.stdin.on("error", () => {
8764
- });
8765
- child.once("error", () => {
8766
- clearTimeout(timer);
8767
- reject(new Error("reporter failed to start"));
8768
- });
8769
- child.once("close", (code) => {
8770
- clearTimeout(timer);
8771
- if (code === 0) resolve2();
8772
- else reject(new Error("reporter failed"));
8773
- });
8774
- child.stdin.end(JSON.stringify(body));
8775
- });
8776
- }
8777
- var init_prepare_failure = __esm({
8778
- "src/prepare-failure.ts"() {
8779
- "use strict";
8780
- }
8781
- });
8782
-
8783
8571
  // src/prepared.ts
8784
8572
  import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync10 } from "fs";
8785
8573
  import { join as join19 } from "path";
@@ -9111,7 +8899,7 @@ var init_turn_committer = __esm({
9111
8899
  deps.onCommitFailed?.(err);
9112
8900
  };
9113
8901
  const commit = {
9114
- commitMessage: async ({ body, kind, seq }) => {
8902
+ commitMessage: async ({ body, kind, seq, declared }) => {
9115
8903
  await deps.api.postTurnMessage(
9116
8904
  deps.workspaceId,
9117
8905
  deps.conversationId,
@@ -9121,9 +8909,11 @@ var init_turn_committer = __esm({
9121
8909
  turnId: deps.turnId,
9122
8910
  seq,
9123
8911
  parentMessageId: deps.parentMessageId,
9124
- ...source
8912
+ ...source,
8913
+ ...declared ? { declared } : {}
9125
8914
  },
9126
- deps.signal
8915
+ deps.signal,
8916
+ { agentId: deps.agentId }
9127
8917
  );
9128
8918
  },
9129
8919
  reportTool: async ({
@@ -9182,7 +8972,9 @@ var init_turn_committer = __esm({
9182
8972
  commit,
9183
8973
  signal: deps.signal,
9184
8974
  nextSeq: deps.nextSeq,
9185
- ...deps.spoke !== void 0 ? { spoke: deps.spoke } : {}
8975
+ ...deps.spoke !== void 0 ? { spoke: deps.spoke } : {},
8976
+ ...deps.armed !== void 0 ? { armed: deps.armed } : {},
8977
+ ...deps.declaredSeq !== void 0 ? { declaredSeq: deps.declaredSeq } : {}
9186
8978
  });
9187
8979
  this.emit = sinkEmitter(this.pump.sink, this.onError);
9188
8980
  }
@@ -9204,14 +8996,15 @@ var init_turn_committer = __esm({
9204
8996
  }
9205
8997
  }
9206
8998
  // 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.
8999
+ // that spoke settles; one that never did is a pass. The held-text flush is
9000
+ // the adapter's (it emits the closing reply as a terminal `text` event before
9001
+ // `result`); a span that ended on working text names that text as its last,
9002
+ // and the server makes it the body at the end.
9211
9003
  get spoke() {
9212
9004
  return this.pump.spoke;
9213
9005
  }
9214
- // The `seq` of the last words, for the settle to declare; null when none.
9006
+ // What the settle names by `seq`: the declared answer, or the turn's last
9007
+ // text through either door; null when the turn wrote none.
9215
9008
  get lastMessageSeq() {
9216
9009
  return this.pump.lastMessageSeq;
9217
9010
  }
@@ -9317,7 +9110,7 @@ var init_turn_runtime_integrity = __esm({
9317
9110
 
9318
9111
  // src/turn-execution.ts
9319
9112
  import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
9320
- import { mkdirSync as mkdirSync9 } from "fs";
9113
+ import { existsSync as existsSync12, mkdirSync as mkdirSync9 } from "fs";
9321
9114
  function resolveIdleTimeoutMs(override) {
9322
9115
  return override ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
9323
9116
  }
@@ -9374,7 +9167,6 @@ var init_turn_execution = __esm({
9374
9167
  init_errors();
9375
9168
  init_turn_seq_floor();
9376
9169
  init_prepare_hook();
9377
- init_prepare_failure();
9378
9170
  init_prepared();
9379
9171
  init_secrets();
9380
9172
  init_turn_env();
@@ -9383,7 +9175,6 @@ var init_turn_execution = __esm({
9383
9175
  init_turn_containment();
9384
9176
  init_turn_committer();
9385
9177
  init_turn_runtime_integrity();
9386
- init_dispatcher();
9387
9178
  PREPARING_TOOL_NAME = "preparing";
9388
9179
  HARNESS_UNAVAILABLE_NOTICE = "**This harness isn't available on the companion.** Check the harness on the device, or choose another harness in the agent's settings. Then reply to try again.";
9389
9180
  NO_TURN_TOKEN_REASON = "no_turn_token: the server issued no turn credential for this turn";
@@ -9442,6 +9233,12 @@ var init_turn_execution = __esm({
9442
9233
  // Whether the span this resumed run continues already has first words —
9443
9234
  // from the server (`turnSpoke`) or from speech still queued in the outbox.
9444
9235
  resumedSpoke = false;
9236
+ // Whether the resumed run starts with the `answer` act armed: the server
9237
+ // holds an accepted act (`answerArmed`) and this device's outbox holds no
9238
+ // `declared` commit for the turn — a queued selection stands, and the run
9239
+ // starts declared at its seq instead.
9240
+ resumedArmed = false;
9241
+ resumedDeclaredSeq;
9445
9242
  turnContext;
9446
9243
  resolvedMcpServers;
9447
9244
  // The harness's secret environment for this turn. Only ever handed to the
@@ -9627,6 +9424,7 @@ var init_turn_execution = __esm({
9627
9424
  const { payload, turnId, turnLog } = this;
9628
9425
  const outboxFloor = this.resumed ? readOutboxFloor((id) => this.opts.api.outboxMaxSeqForTurn(id), turnId, turnLog) : 0;
9629
9426
  const outboxSpoke = this.resumed && this.opts.api.outboxHasSpeechForTurn(turnId);
9427
+ const outboxDeclaredSeq = this.resumed ? this.opts.api.outboxDeclaredSeqForTurn(turnId) : null;
9630
9428
  let turnContext;
9631
9429
  try {
9632
9430
  turnContext = await this.opts.api.getTurnContext(
@@ -9681,6 +9479,16 @@ var init_turn_execution = __esm({
9681
9479
  this.seqCounter = Math.max(this.seqCounter, this.resumedFromSeq);
9682
9480
  this.resumedSpoke = turnContext.turnSpoke === true || outboxSpoke;
9683
9481
  if (this.resumedSpoke) turnLog.debug({ turnId }, "dispatcher: resumed span already spoke");
9482
+ this.resumedArmed = turnContext.answerArmed === true && outboxDeclaredSeq === null;
9483
+ this.resumedDeclaredSeq = outboxDeclaredSeq ?? void 0;
9484
+ if (this.resumedArmed)
9485
+ turnLog.debug({ turnId }, "dispatcher: resumed turn re-armed its answer");
9486
+ if (outboxDeclaredSeq !== null) {
9487
+ turnLog.debug(
9488
+ { turnId, seq: outboxDeclaredSeq },
9489
+ "dispatcher: resumed turn keeps the answer queued in its outbox"
9490
+ );
9491
+ }
9684
9492
  }
9685
9493
  }
9686
9494
  // Register this context's issuance as the execution source of the work about to
@@ -9764,7 +9572,7 @@ var init_turn_execution = __esm({
9764
9572
  async prepareEnvironment() {
9765
9573
  const { payload, workspaceId, turnId, turnLog } = this;
9766
9574
  const prepareHook = this.opts.local.prepareHook;
9767
- const pairDir = this.opts.boxed ? this.opts.workRoot : pairDirectory(this.opts.workRoot, {
9575
+ const pairDir = this.opts.dedicatedRoot ? this.opts.workRoot : pairDirectory(this.opts.workRoot, {
9768
9576
  workspaceSlug: this.opts.workspaceSlug,
9769
9577
  agentUsername: this.opts.agentUsername,
9770
9578
  conversationId: payload.conversationId
@@ -9792,10 +9600,10 @@ var init_turn_execution = __esm({
9792
9600
  let hookEnv;
9793
9601
  if (prepareHook) {
9794
9602
  let cached2 = readPrepared(workspaceId, payload.conversationId, payload.agentId);
9795
- if (cached2 && !checkoutState(cached2.cwd).ok) {
9603
+ if (cached2 && !existsSync12(cached2.cwd)) {
9796
9604
  turnLog.debug(
9797
- { cwd: cached2.cwd, reason: checkoutState(cached2.cwd).reason },
9798
- "dispatcher: the prepared checkout is no longer usable \u2014 re-running the prepare hook to restore it"
9605
+ { cwd: cached2.cwd },
9606
+ "dispatcher: the prepared directory is gone \u2014 re-running the prepare hook"
9799
9607
  );
9800
9608
  clearPrepared(workspaceId, payload.conversationId, payload.agentId);
9801
9609
  cached2 = null;
@@ -9803,38 +9611,6 @@ var init_turn_execution = __esm({
9803
9611
  if (cached2) {
9804
9612
  effectiveCwd = cached2.cwd;
9805
9613
  hookEnv = cached2.env;
9806
- if (prepareHook.validateCached) {
9807
- const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
9808
- try {
9809
- await runPrepareWithReporting(
9810
- runHook,
9811
- prepareHook,
9812
- {
9813
- workspaceId,
9814
- conversationId: payload.conversationId,
9815
- agentId: payload.agentId,
9816
- agentUsername: this.opts.agentUsername,
9817
- defaultCwd: pairDir,
9818
- runtime: this.turnContext.runtime,
9819
- triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9820
- title: this.turnContext.conversation.title,
9821
- messageBody: this.turnContext.message.body,
9822
- prepared: cached2
9823
- },
9824
- { turnId, deviceId: this.opts.deviceId },
9825
- () => {
9826
- turnLog.error(
9827
- "dispatcher: prepare failure reporter failed; original failure preserved"
9828
- );
9829
- }
9830
- );
9831
- } catch (err) {
9832
- const reason = err instanceof Error ? err.message : String(err);
9833
- turnLog.debug({ err: reason }, "dispatcher: prepare hook rejected a prepared turn");
9834
- const failReason = `prepare_failed: ${reason}`;
9835
- throw this.concluded(failReason, failReason);
9836
- }
9837
- }
9838
9614
  } else {
9839
9615
  const delayMs = this.opts.preparingRowDelayMs ?? DEFAULT_PREPARING_ROW_DELAY_MS;
9840
9616
  let preparingStarted = false;
@@ -9866,33 +9642,17 @@ var init_turn_execution = __esm({
9866
9642
  preparingTimer.unref?.();
9867
9643
  const runHook = this.opts.prepareHookRunner ?? runPrepareHook;
9868
9644
  try {
9869
- const result = await runPrepareWithReporting(
9870
- runHook,
9871
- prepareHook,
9872
- {
9873
- workspaceId,
9874
- conversationId: payload.conversationId,
9875
- agentId: payload.agentId,
9876
- agentUsername: this.opts.agentUsername,
9877
- // The pair's own directory, already made, for a hook that only
9878
- // wants to add to it.
9879
- defaultCwd: pairDir,
9880
- runtime: this.turnContext.runtime,
9881
- // The trigger message's referenced-entry paths, which a prepare hook
9882
- // may key its per-task env off. `[]` from an older API.
9883
- triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
9884
- title: this.turnContext.conversation.title,
9885
- // Where an `env:` directive rides, so a dispatch can ask for its
9886
- // environment in words.
9887
- messageBody: this.turnContext.message.body
9888
- },
9889
- { turnId, deviceId: this.opts.deviceId },
9890
- () => {
9891
- turnLog.error(
9892
- "dispatcher: prepare failure reporter failed; original failure preserved"
9893
- );
9894
- }
9895
- );
9645
+ const result = await runHook(prepareHook, {
9646
+ workspaceId,
9647
+ conversationId: payload.conversationId,
9648
+ agentId: payload.agentId,
9649
+ agentUsername: this.opts.agentUsername,
9650
+ // The pair's own directory, already made, for a hook that only
9651
+ // wants to add to it.
9652
+ defaultCwd: pairDir,
9653
+ runtime: this.turnContext.runtime,
9654
+ title: this.turnContext.conversation.title
9655
+ });
9896
9656
  clearTimeout(preparingTimer);
9897
9657
  if (preparingStarted) reportPreparing("done");
9898
9658
  writePrepared(workspaceId, payload.conversationId, payload.agentId, result);
@@ -10188,6 +9948,8 @@ var init_turn_execution = __esm({
10188
9948
  log: turnLog,
10189
9949
  nextSeq: this.nextSeq,
10190
9950
  spoke: this.resumedSpoke,
9951
+ armed: this.resumedArmed,
9952
+ ...this.resumedDeclaredSeq !== void 0 ? { declaredSeq: this.resumedDeclaredSeq } : {},
10191
9953
  // Admitted by now, so this is the source the model's work runs on.
10192
9954
  ...this.executionSourceId !== void 0 ? { executionSourceId: this.executionSourceId } : {},
10193
9955
  // A commit that didn't land may mean the lease is gone; see
@@ -10537,171 +10299,38 @@ var init_turn_execution = __esm({
10537
10299
  }
10538
10300
  });
10539
10301
 
10540
- // src/dispatcher.ts
10541
- import { randomUUID as randomUUID4 } from "crypto";
10542
- import { existsSync as existsSync12, readdirSync as readdirSync2, statSync as statSync4 } from "fs";
10302
+ // src/outbox.ts
10303
+ import {
10304
+ existsSync as existsSync14,
10305
+ mkdirSync as mkdirSync10,
10306
+ readdirSync as readdirSync2,
10307
+ readFileSync as readFileSync11,
10308
+ renameSync as renameSync5,
10309
+ rmSync as rmSync7,
10310
+ writeFileSync as writeFileSync6
10311
+ } from "fs";
10543
10312
  import { join as join22 } from "path";
10544
- function checkoutState(cwd) {
10545
- if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
10546
- if (!existsSync12(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
10547
- let entries;
10548
- try {
10549
- entries = readdirSync2(cwd);
10550
- } catch (error) {
10551
- return { ok: false, reason: `${cwd} is unreadable (${error.message})` };
10552
- }
10553
- if (entries.length === 0) {
10554
- return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
10555
- }
10556
- const gitPath = join22(cwd, ".git");
10557
- if (!existsSync12(gitPath)) return { ok: true, reason: "usable" };
10558
- let stat;
10559
- try {
10560
- stat = statSync4(gitPath);
10561
- } catch (error) {
10562
- return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
10563
- }
10564
- if (stat.isDirectory() && !existsSync12(join22(gitPath, "HEAD")))
10565
- return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
10566
- return { ok: true, reason: "usable" };
10567
- }
10568
- function runKey(conversationId, agentId) {
10569
- return `${conversationId}|${agentId}`;
10570
- }
10571
- var Dispatcher;
10572
- var init_dispatcher = __esm({
10573
- "src/dispatcher.ts"() {
10313
+ var MAX_ENTRIES, MAX_AGE_MS, Outbox;
10314
+ var init_outbox = __esm({
10315
+ "src/outbox.ts"() {
10574
10316
  "use strict";
10575
- init_launch();
10576
- init_turn_execution();
10577
- Dispatcher = class {
10578
- constructor(opts) {
10579
- this.opts = opts;
10580
- this.aborts = opts.aborts ?? /* @__PURE__ */ new Map();
10317
+ init_config();
10318
+ MAX_ENTRIES = 2e3;
10319
+ MAX_AGE_MS = 24 * 60 * 60 * 1e3;
10320
+ Outbox = class {
10321
+ constructor(workspaceId, log2) {
10322
+ this.workspaceId = workspaceId;
10323
+ this.log = log2;
10581
10324
  }
10582
- opts;
10583
- // (conversation|agent) → per-turn controllers. A delayed cancel for an old
10584
- // turn must never reach its successor.
10585
- aborts;
10586
- // Pairs already told their session couldn't be saved: the notice is once per
10587
- // pair, while the rejection repeats every turn.
10588
- sessionWriteNotified = /* @__PURE__ */ new Set();
10589
- notifyStart(info) {
10590
- try {
10591
- this.opts.observer?.onStart(info);
10592
- } catch {
10593
- }
10594
- }
10595
- notifyEnd(info) {
10596
- try {
10597
- this.opts.observer?.onEnd(info);
10598
- } catch {
10599
- }
10600
- }
10601
- // The supervisor supplies a stable turnId on both fresh and resumed dispatch.
10602
- // resumed is separate: only replay seeds sequence/speech recovery from the
10603
- // prior span. A direct caller omitting turnId gets a fresh id.
10604
- async handle(payload, opts = {}) {
10605
- const key = runKey(payload.conversationId, payload.agentId);
10606
- return runTurnInRunner(
10607
- this.opts.turn,
10608
- this.opts.runner,
10609
- {
10610
- registerAbort: (turnId, controller) => {
10611
- let pairAborts = this.aborts.get(key);
10612
- if (!pairAborts) {
10613
- pairAborts = /* @__PURE__ */ new Map();
10614
- this.aborts.set(key, pairAborts);
10615
- }
10616
- pairAborts.set(turnId, controller);
10617
- },
10618
- releaseAbort: (turnId, controller) => {
10619
- const pair = this.aborts.get(key);
10620
- if (pair?.get(turnId) !== controller) return;
10621
- pair.delete(turnId);
10622
- if (pair.size === 0) this.aborts.delete(key);
10623
- },
10624
- sessionWriteNoticeClaimed: () => this.sessionWriteNotified.has(key),
10625
- markSessionWriteNotice: () => {
10626
- this.sessionWriteNotified.add(key);
10627
- },
10628
- notifyStart: (info) => this.notifyStart(info),
10629
- notifyEnd: (info) => this.notifyEnd(info)
10630
- },
10631
- payload,
10632
- { turnId: opts.turnId ?? randomUUID4(), resumed: opts.resumed === true }
10633
- );
10634
- }
10635
- // Cancel the named turn. An older server omits turnId and cancels the pair.
10636
- cancel(conversationId, agentId, turnId) {
10637
- const key = runKey(conversationId, agentId);
10638
- const pair = this.aborts.get(key);
10639
- if (!pair || pair.size === 0) return false;
10640
- const controllers = turnId === void 0 ? [...pair.values()] : [pair.get(turnId)];
10641
- let aborted = false;
10642
- for (const ac of controllers) {
10643
- if (!ac) continue;
10644
- aborted = true;
10645
- try {
10646
- ac.abort();
10647
- } catch {
10648
- }
10649
- }
10650
- return aborted;
10651
- }
10652
- // Abort every run in flight in THIS process, whatever its conversation. For
10653
- // an agent taken off this device: nothing it is still running here is
10654
- // legitimate, and a cancel frame that arrives after it is dropped from the
10655
- // roster finds no dispatcher to reach. True if any was aborted.
10656
- cancelAll() {
10657
- let aborted = false;
10658
- for (const pair of this.aborts.values()) {
10659
- for (const ac of [...pair.values()]) {
10660
- try {
10661
- ac.abort();
10662
- aborted = true;
10663
- } catch {
10664
- }
10665
- }
10666
- }
10667
- return aborted;
10668
- }
10669
- };
10670
- }
10671
- });
10672
-
10673
- // src/outbox.ts
10674
- import {
10675
- existsSync as existsSync14,
10676
- mkdirSync as mkdirSync10,
10677
- readdirSync as readdirSync3,
10678
- readFileSync as readFileSync11,
10679
- renameSync as renameSync5,
10680
- rmSync as rmSync7,
10681
- writeFileSync as writeFileSync6
10682
- } from "fs";
10683
- import { join as join23 } from "path";
10684
- var MAX_ENTRIES, MAX_AGE_MS, Outbox;
10685
- var init_outbox = __esm({
10686
- "src/outbox.ts"() {
10687
- "use strict";
10688
- init_config();
10689
- MAX_ENTRIES = 2e3;
10690
- MAX_AGE_MS = 24 * 60 * 60 * 1e3;
10691
- Outbox = class {
10692
- constructor(workspaceId, log2) {
10693
- this.workspaceId = workspaceId;
10694
- this.log = log2;
10695
- }
10696
- workspaceId;
10697
- log;
10698
- // Resolved lazily (per call) so tests that swap HOME between
10699
- // cases route writes at the right tmpdir.
10700
- dir() {
10701
- return join23(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
10325
+ workspaceId;
10326
+ log;
10327
+ // Resolved lazily (per call) so tests that swap HOME between
10328
+ // cases route writes at the right tmpdir.
10329
+ dir() {
10330
+ return join22(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
10702
10331
  }
10703
10332
  fileFor(turnId, seq) {
10704
- return join23(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
10333
+ return join22(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
10705
10334
  }
10706
10335
  // Persist a commit for later draining. Atomic (temp file + rename) so a
10707
10336
  // concurrent `list()` never reads a half-written entry, then enforces the
@@ -10736,14 +10365,14 @@ var init_outbox = __esm({
10736
10365
  if (!existsSync14(dir2)) return [];
10737
10366
  let names;
10738
10367
  try {
10739
- names = readdirSync3(dir2);
10368
+ names = readdirSync2(dir2);
10740
10369
  } catch {
10741
10370
  return [];
10742
10371
  }
10743
10372
  const entries = [];
10744
10373
  for (const name of names) {
10745
10374
  if (!name.endsWith(".json")) continue;
10746
- const full = join23(dir2, name);
10375
+ const full = join22(dir2, name);
10747
10376
  try {
10748
10377
  const parsed = JSON.parse(readFileSync11(full, "utf8"));
10749
10378
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -10780,7 +10409,7 @@ var init_outbox = __esm({
10780
10409
  maxSeqForTurn(turnId) {
10781
10410
  const dir2 = this.dir();
10782
10411
  if (!existsSync14(dir2)) return 0;
10783
- const names = readdirSync3(dir2);
10412
+ const names = readdirSync2(dir2);
10784
10413
  const prefix = `${encodeURIComponent(turnId)}__`;
10785
10414
  let max = 0;
10786
10415
  for (const name of names) {
@@ -10801,7 +10430,7 @@ var init_outbox = __esm({
10801
10430
  const dir2 = this.dir();
10802
10431
  if (!existsSync14(dir2)) return 0;
10803
10432
  try {
10804
- return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
10433
+ return readdirSync2(dir2).filter((n) => n.endsWith(".json")).length;
10805
10434
  } catch {
10806
10435
  return 0;
10807
10436
  }
@@ -10850,7 +10479,7 @@ var init_outbox = __esm({
10850
10479
 
10851
10480
  // src/runner/exec-shim.ts
10852
10481
  import { chmodSync as chmodSync6, mkdirSync as mkdirSync18, mkdtempSync, rmSync as rmSync13, writeFileSync as writeFileSync11 } from "fs";
10853
- import { join as join33 } from "path";
10482
+ import { join as join32 } from "path";
10854
10483
  function shellQuote(value) {
10855
10484
  return `'${value.replace(/'/g, `'\\''`)}'`;
10856
10485
  }
@@ -10860,10 +10489,10 @@ function createShimDir() {
10860
10489
  return {
10861
10490
  write(argv) {
10862
10491
  if (!dir2) {
10863
- mkdirSync18(join33(cabaneDir(), "runner"), { recursive: true });
10864
- dir2 = mkdtempSync(join33(cabaneDir(), "runner", "turn-"));
10492
+ mkdirSync18(join32(cabaneDir(), "runner"), { recursive: true });
10493
+ dir2 = mkdtempSync(join32(cabaneDir(), "runner", "turn-"));
10865
10494
  }
10866
- const path = join33(dir2, `exec-${++count}`);
10495
+ const path = join32(dir2, `exec-${++count}`);
10867
10496
  writeFileSync11(path, `#!/bin/sh
10868
10497
  exec ${argv.map(shellQuote).join(" ")} "$@"
10869
10498
  `);
@@ -11030,13 +10659,13 @@ function runnerTurnOptions(input, log2, emit, extras = {}) {
11030
10659
  local: spec.local,
11031
10660
  // The place the runner stands in names its own root and transcript
11032
10661
  // directory when it has them (`PLACE_ENV`); the launcher's host-computed
11033
- // values are what a runner on the person's own machine gets. A box is one
11034
- // conversation × agent's, so the turn stands in its root itself.
10662
+ // values are what a runner on the person's own machine gets. A dedicated
10663
+ // root is one conversation × agent's, so the turn stands in it itself.
11035
10664
  workRoot: input.place?.workRoot ?? spec.workRoot,
11036
- ...input.place?.box ? { boxed: true } : {},
10665
+ ...input.place?.dedicatedRoot ? { dedicatedRoot: true } : {},
11037
10666
  runConfig: spec.runConfig,
11038
- // The launcher's copy of the device's secrets, when it sent one: a runner
11039
- // in a box has no `secrets.json` to read.
10667
+ // The launcher's copy of the device's secrets, when it sent one: a wrapped
10668
+ // runner may have no `secrets.json` to read.
11040
10669
  ...input.secrets ? { secretStore: makeStore(input.secrets) } : {},
11041
10670
  // The grants-file entries the launcher read for this dispatch's turn.
11042
10671
  ...input.grants ? { deviceGrants: input.grants } : {},
@@ -11900,7 +11529,7 @@ async function liveCompanionState() {
11900
11529
  // src/commands/setup.ts
11901
11530
  init_cabane_command();
11902
11531
  init_config();
11903
- import { hostname as hostname3 } from "os";
11532
+ import { hostname as hostname2 } from "os";
11904
11533
 
11905
11534
  // src/logger.ts
11906
11535
  init_config();
@@ -12126,8 +11755,8 @@ async function claimSetupCode(baseUrl, input) {
12126
11755
  ..."deviceLabel" in body && typeof body.deviceLabel === "string" ? { deviceLabel: body.deviceLabel } : {}
12127
11756
  };
12128
11757
  }
12129
- function deviceLabelFromHostname(hostname4) {
12130
- const trimmed = hostname4.trim().replace(/\.local$/i, "");
11758
+ function deviceLabelFromHostname(hostname3) {
11759
+ const trimmed = hostname3.trim().replace(/\.local$/i, "");
12131
11760
  return trimmed.length ? trimmed.slice(0, 120) : void 0;
12132
11761
  }
12133
11762
 
@@ -12672,7 +12301,7 @@ var emptyHeartbeatStatus = () => ({
12672
12301
 
12673
12302
  // src/supervisor.ts
12674
12303
  init_cabane_command();
12675
- import { hostname as hostname2 } from "os";
12304
+ import { hostname } from "os";
12676
12305
 
12677
12306
  // src/log-copy.ts
12678
12307
  init_failure();
@@ -12898,241 +12527,745 @@ function save(deviceId, map) {
12898
12527
  } catch {
12899
12528
  }
12900
12529
  }
12901
- const path = devicePath(deviceId);
12902
- const tmp = `${path}.${process.pid}.tmp`;
12903
- try {
12904
- writeFileSync3(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
12530
+ const path = devicePath(deviceId);
12531
+ const tmp = `${path}.${process.pid}.tmp`;
12532
+ try {
12533
+ writeFileSync3(tmp, JSON.stringify(map, null, 2) + "\n", { mode: 384 });
12534
+ try {
12535
+ chmodSync3(tmp, 384);
12536
+ } catch {
12537
+ }
12538
+ renameSync3(tmp, path);
12539
+ } catch (err) {
12540
+ try {
12541
+ rmSync4(tmp, { force: true });
12542
+ } catch {
12543
+ }
12544
+ throw err;
12545
+ }
12546
+ }
12547
+ function getCredential(deviceId, agentId) {
12548
+ return load(deviceId)[agentId] ?? null;
12549
+ }
12550
+ function setCredential(deviceId, agentId, pat) {
12551
+ if (read(devicePath(deviceId))?.[agentId] === pat) return;
12552
+ const map = load(deviceId);
12553
+ map[agentId] = pat;
12554
+ save(deviceId, map);
12555
+ }
12556
+ function pruneCredentials(deviceId, keepAgentIds) {
12557
+ const keep = new Set(keepAgentIds);
12558
+ if (keep.size === 0) return [];
12559
+ const own = read(devicePath(deviceId));
12560
+ const map = own ?? load(deviceId);
12561
+ const removed = Object.keys(map).filter((id) => !keep.has(id));
12562
+ for (const id of removed) delete map[id];
12563
+ if (removed.length > 0 || own === null) save(deviceId, map);
12564
+ return removed;
12565
+ }
12566
+ function clearCredentials() {
12567
+ rmSync4(credentialsDir(), { recursive: true, force: true });
12568
+ if (existsSync7(legacyPath())) writeFileSync3(legacyPath(), "", { mode: 384 });
12569
+ }
12570
+
12571
+ // src/dispatch-dedupe.ts
12572
+ init_config();
12573
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync4, renameSync as renameSync4, existsSync as existsSync8 } from "fs";
12574
+ import { join as join13 } from "path";
12575
+ var MAX_IDS = 256;
12576
+ function dir(log2) {
12577
+ return join13(cabaneDir(), log2);
12578
+ }
12579
+ function pathFor(log2, workspaceId) {
12580
+ return join13(dir(log2), encodeURIComponent(workspaceId));
12581
+ }
12582
+ function readIds(log2, workspaceId) {
12583
+ const path = pathFor(log2, workspaceId);
12584
+ if (!existsSync8(path)) return [];
12585
+ try {
12586
+ return readFileSync5(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
12587
+ } catch {
12588
+ return [];
12589
+ }
12590
+ }
12591
+ function has(log2, workspaceId, eventId) {
12592
+ return readIds(log2, workspaceId).includes(eventId);
12593
+ }
12594
+ function mark(log2, workspaceId, eventId) {
12595
+ const ids = readIds(log2, workspaceId);
12596
+ if (ids.includes(eventId)) return;
12597
+ ids.push(eventId);
12598
+ const trimmed = ids.length > MAX_IDS ? ids.slice(-MAX_IDS) : ids;
12599
+ mkdirSync6(dir(log2), { recursive: true });
12600
+ writeFileSync4(pathFor(log2, workspaceId), trimmed.join("\n") + "\n", "utf8");
12601
+ }
12602
+ function hasDispatched(workspaceId, eventId) {
12603
+ return has("dispatched", workspaceId, eventId);
12604
+ }
12605
+ function markDispatched(workspaceId, eventId) {
12606
+ mark("dispatched", workspaceId, eventId);
12607
+ }
12608
+ function hasCompleted(workspaceId, eventId) {
12609
+ return has("completed", workspaceId, eventId);
12610
+ }
12611
+ function markCompleted(workspaceId, eventId) {
12612
+ mark("completed", workspaceId, eventId);
12613
+ forgetTurnId(workspaceId, eventId);
12614
+ }
12615
+ var MAX_RESUME_ATTEMPTS = 3;
12616
+ function resumeDir() {
12617
+ return join13(cabaneDir(), "resume-attempts");
12618
+ }
12619
+ function resumePathFor(workspaceId) {
12620
+ return join13(resumeDir(), encodeURIComponent(workspaceId));
12621
+ }
12622
+ function readResumeCounts(workspaceId) {
12623
+ const out = /* @__PURE__ */ new Map();
12624
+ const path = resumePathFor(workspaceId);
12625
+ if (!existsSync8(path)) return out;
12626
+ try {
12627
+ for (const line of readFileSync5(path, "utf8").split("\n")) {
12628
+ const trimmed = line.trim();
12629
+ if (!trimmed) continue;
12630
+ const tab = trimmed.lastIndexOf(" ");
12631
+ if (tab <= 0) continue;
12632
+ const id = trimmed.slice(0, tab);
12633
+ const count = Number.parseInt(trimmed.slice(tab + 1), 10);
12634
+ if (id && Number.isFinite(count)) out.set(id, count);
12635
+ }
12636
+ } catch {
12637
+ return out;
12638
+ }
12639
+ return out;
12640
+ }
12641
+ function bumpResumeAttempt(workspaceId, eventId) {
12642
+ const counts = readResumeCounts(workspaceId);
12643
+ const next = (counts.get(eventId) ?? 0) + 1;
12644
+ counts.set(eventId, next);
12645
+ const entries = [...counts.entries()];
12646
+ const trimmed = entries.length > MAX_IDS ? entries.slice(-MAX_IDS) : entries;
12647
+ mkdirSync6(resumeDir(), { recursive: true });
12648
+ writeFileSync4(
12649
+ resumePathFor(workspaceId),
12650
+ trimmed.map(([id, c]) => `${id} ${c}`).join("\n") + "\n",
12651
+ "utf8"
12652
+ );
12653
+ return next;
12654
+ }
12655
+ function turnDir() {
12656
+ return join13(cabaneDir(), "turns");
12657
+ }
12658
+ function turnPathFor(workspaceId) {
12659
+ return join13(turnDir(), encodeURIComponent(workspaceId));
12660
+ }
12661
+ var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12662
+ function readTurnIds(workspaceId) {
12663
+ const out = /* @__PURE__ */ new Map();
12664
+ const path = turnPathFor(workspaceId);
12665
+ if (!existsSync8(path)) return out;
12666
+ try {
12667
+ for (const line of readFileSync5(path, "utf8").split("\n")) {
12668
+ const trimmed = line.trim();
12669
+ if (!trimmed) continue;
12670
+ const tab = trimmed.lastIndexOf(" ");
12671
+ if (tab <= 0) continue;
12672
+ const id = trimmed.slice(0, tab);
12673
+ const turnId = trimmed.slice(tab + 1);
12674
+ if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
12675
+ }
12676
+ } catch {
12677
+ return out;
12678
+ }
12679
+ return out;
12680
+ }
12681
+ function turnIdForEvent(workspaceId, eventId) {
12682
+ return readTurnIds(workspaceId).get(eventId) ?? null;
12683
+ }
12684
+ var TURN_ID_OVERFLOW_WARN = 8192;
12685
+ function writeTurnIds(workspaceId, turns) {
12686
+ const entries = [...turns.entries()];
12687
+ mkdirSync6(turnDir(), { recursive: true });
12688
+ const path = turnPathFor(workspaceId);
12689
+ const tmp = `${path}.${process.pid}.tmp`;
12690
+ writeFileSync4(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
12691
+ renameSync4(tmp, path);
12692
+ return entries.length;
12693
+ }
12694
+ function rememberTurnId(workspaceId, eventId, turnId) {
12695
+ const turns = readTurnIds(workspaceId);
12696
+ if (turns.get(eventId) === turnId) return turns.size;
12697
+ turns.set(eventId, turnId);
12698
+ return writeTurnIds(workspaceId, turns);
12699
+ }
12700
+ function forgetTurnId(workspaceId, eventId) {
12701
+ const turns = readTurnIds(workspaceId);
12702
+ if (!turns.delete(eventId)) return;
12703
+ writeTurnIds(workspaceId, turns);
12704
+ }
12705
+ function noResume() {
12706
+ return process.env.CABANE_COMPANION_NO_RESUME === "1";
12707
+ }
12708
+
12709
+ // src/supervisor.ts
12710
+ init_bundled_binaries();
12711
+ init_install_kind();
12712
+
12713
+ // src/connector-health.ts
12714
+ init_src();
12715
+ var ConnectorHealthStore = class {
12716
+ byRuntime = /* @__PURE__ */ new Map();
12717
+ // Record a settled turn's outcome for `runtime`. A success heals to `ok`; a
12718
+ // classified failure sets `limited`(+reset) / `auth_expired` with the reason as
12719
+ // `last_error`; anything else (a user Stop, a timeout, an unclassified error)
12720
+ // leaves the last-known status untouched — we only ever move state on real
12721
+ // evidence.
12722
+ recordSettle(runtime, outcome) {
12723
+ if (outcome.ok) {
12724
+ this.byRuntime.set(runtime, { quotaState: "ok", limitedUntil: null, lastError: null });
12725
+ return;
12726
+ }
12727
+ const failure = decodeFailureReason(outcome.errorReason);
12728
+ if (!failure) return;
12729
+ const quota = quotaForFailure(failure);
12730
+ if (!quota) return;
12731
+ this.byRuntime.set(runtime, {
12732
+ quotaState: quota.quotaState,
12733
+ limitedUntil: quota.limitedUntil,
12734
+ lastError: outcome.errorReason ?? null
12735
+ });
12736
+ }
12737
+ // The store's current opinion for one runtime, or undefined when it has
12738
+ // no observation. Read by the dispatcher at idle-timeout fire: an idle reap
12739
+ // while this runtime is definitively `limited` (a present, future `limitedUntil`
12740
+ // recorded by a sibling/earlier turn's classified cap settle) is the account cap
12741
+ // surfacing as silence, not a genuine hang — so the settle reclassifies to
12742
+ // `usage_capped` instead of `timeout_idle`. Returns the stored shape verbatim
12743
+ // (the dispatcher reads `quotaState` + `limitedUntil` and applies its own
12744
+ // future-instant gate); a read never mutates state.
12745
+ lookup(runtime) {
12746
+ return this.byRuntime.get(runtime);
12747
+ }
12748
+ // The per-connector reports to attach to a heartbeat — one entry per runtime the
12749
+ // companion has an observation for. Empty until the first classified failure/heal,
12750
+ // so a companion that has seen nothing sends no `connectors[]` and the server's
12751
+ // manifest synthesis (status-less rows) is unaffected.
12752
+ reports() {
12753
+ return [...this.byRuntime.entries()].map(([runtime, h]) => ({
12754
+ runtime,
12755
+ quotaState: h.quotaState,
12756
+ limitedUntil: h.limitedUntil,
12757
+ lastError: h.lastError
12758
+ }));
12759
+ }
12760
+ };
12761
+
12762
+ // src/dispatcher.ts
12763
+ import { randomUUID as randomUUID4 } from "crypto";
12764
+
12765
+ // src/runner/launch.ts
12766
+ init_src();
12767
+ init_opencode_lock();
12768
+ init_api_error_shape();
12769
+ init_errors();
12770
+ init_boot_id();
12771
+ init_turn_containment();
12772
+ init_protocol();
12773
+ import { createInterface } from "readline";
12774
+ function selfRunnerCommand() {
12775
+ const script = process.argv[1];
12776
+ if (!script) throw new Error("runner: cannot tell which script started this companion");
12777
+ return [process.execPath, ...process.execArgv, script, "run-turn"];
12778
+ }
12779
+ function wrappedRunnerCommand(prefix, own) {
12780
+ return [...prefix ?? [], ...own];
12781
+ }
12782
+ var WRAPPED_ENV_DROPPED = [
12783
+ "ANTHROPIC_API_KEY",
12784
+ "CLAUDE_CODE_OAUTH_TOKEN",
12785
+ "CODEX_API_KEY",
12786
+ "GH_TOKEN",
12787
+ "GITHUB_TOKEN",
12788
+ "DBUS_SESSION_BUS_ADDRESS",
12789
+ "XDG_RUNTIME_DIR"
12790
+ ];
12791
+ function runnerExecEnv(base, wrapped) {
12792
+ const env = {};
12793
+ for (const [k, v] of Object.entries(base)) {
12794
+ if (v === void 0) continue;
12795
+ if (wrapped && WRAPPED_ENV_DROPPED.includes(k)) continue;
12796
+ env[k] = v;
12797
+ }
12798
+ return env;
12799
+ }
12800
+ function healthSnapshot(store) {
12801
+ const out = {};
12802
+ for (const r of store?.reports() ?? []) {
12803
+ out[r.runtime] = {
12804
+ quotaState: r.quotaState ?? "unknown",
12805
+ limitedUntil: r.limitedUntil ?? null,
12806
+ lastError: r.lastError ?? null
12807
+ };
12808
+ }
12809
+ return out;
12810
+ }
12811
+ function runnerSpec(opts, launch, duties, runnerId) {
12812
+ return {
12813
+ runnerId,
12814
+ ...opts.deviceId ? { deviceId: opts.deviceId } : {},
12815
+ bootId,
12816
+ workspaceId: opts.workspaceId,
12817
+ workspaceSlug: opts.workspaceSlug,
12818
+ agentId: opts.agentId,
12819
+ agentUsername: opts.agentUsername,
12820
+ agentName: String(opts.log.bindings().agentName ?? opts.agentUsername),
12821
+ local: opts.local,
12822
+ runConfig: opts.runConfig,
12823
+ workRoot: opts.workRoot,
12824
+ ...opts.opencodeServerUrl ? { opencodeServerUrl: opts.opencodeServerUrl } : {},
12825
+ // Read now, per turn, the way adapter selection reads them.
12826
+ claudeCodeAvailable: opts.claudeCodeAvailable?.() ?? true,
12827
+ codexAvailable: opts.codexAvailable?.() ?? false,
12828
+ ...opts.idleTimeoutMs !== void 0 ? { idleTimeoutMs: opts.idleTimeoutMs } : {},
12829
+ ...opts.totalTimeoutMs !== void 0 ? { totalTimeoutMs: opts.totalTimeoutMs } : {},
12830
+ ...opts.leaseRenewalMs !== void 0 ? { leaseRenewalMs: opts.leaseRenewalMs } : {},
12831
+ ...opts.preparingRowDelayMs !== void 0 ? { preparingRowDelayMs: opts.preparingRowDelayMs } : {},
12832
+ transcripts: opts.transcriptDir !== void 0,
12833
+ logLevel: opts.log.level,
12834
+ connectorHealth: healthSnapshot(launch.connectorHealth),
12835
+ sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
12836
+ };
12837
+ }
12838
+ var PINO_LEVELS = {
12839
+ 10: "trace",
12840
+ 20: "debug",
12841
+ 30: "info",
12842
+ 40: "warn",
12843
+ 50: "error",
12844
+ 60: "fatal"
12845
+ };
12846
+ function relayRunnerLog(line, log2) {
12847
+ if (!line.trim()) return;
12848
+ let record = null;
12849
+ try {
12850
+ const parsed = JSON.parse(line);
12851
+ if (parsed && typeof parsed === "object") record = parsed;
12852
+ } catch {
12853
+ record = null;
12854
+ }
12855
+ if (!record || typeof record.level !== "number") {
12856
+ log2.debug({ runnerStderr: line }, "runner: output");
12857
+ return;
12858
+ }
12859
+ const {
12860
+ level,
12861
+ msg,
12862
+ time: _time,
12863
+ pid: _pid,
12864
+ hostname: _hostname,
12865
+ agentName: _agentName,
12866
+ ...fields
12867
+ } = record;
12868
+ const method = PINO_LEVELS[level] ?? "info";
12869
+ log2[method](fields, typeof msg === "string" ? msg : "");
12870
+ }
12871
+ function lines(stream, onLine) {
12872
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
12873
+ rl.on("line", onLine);
12874
+ return new Promise((resolve2) => rl.once("close", resolve2));
12875
+ }
12876
+ function giveBackLocks(locks, prefix, remote, launch, log2) {
12877
+ locks.withdraw(prefix);
12878
+ const stop2 = launch.stopRemoteSession ?? ((url, id) => stopRemoteOpencodeSession(url, id));
12879
+ for (const { key, owner } of locks.heldBy(prefix)) {
12880
+ const left = remote.filter((r) => opencodeLockKey(r.serverUrl) === key);
12881
+ if (!left.length) {
12882
+ locks.release(owner);
12883
+ continue;
12884
+ }
12885
+ void (async () => {
12886
+ for (let attempt = 1; ; attempt++) {
12887
+ const stopped = await Promise.all(left.map((r) => stop2(r.serverUrl, r.sessionId))).catch(
12888
+ () => [false]
12889
+ );
12890
+ if (stopped.every(Boolean)) break;
12891
+ if (attempt === 1)
12892
+ log2.warn(
12893
+ { key, sessions: left.map((r) => r.sessionId) },
12894
+ "runner: a dead runner left work on a shared server that could not be confirmed stopped; holding the server until it is"
12895
+ );
12896
+ await new Promise((resolve2) => setTimeout(resolve2, launch.reconcileRetryMs ?? 5e3));
12897
+ }
12898
+ locks.release(owner);
12899
+ })();
12900
+ }
12901
+ }
12902
+ async function mintForRunner(opts, payload, handleOpts, signal, log2) {
12903
+ try {
12904
+ const minted = await opts.api.mintRunnerCredential({
12905
+ conversationId: payload.conversationId,
12906
+ messageId: payload.messageId,
12907
+ ...payload.episode !== void 0 ? { episode: payload.episode } : {},
12908
+ // The one turn the credential will operate: this run's.
12909
+ turnId: handleOpts.turnId
12910
+ });
12911
+ return {
12912
+ ok: true,
12913
+ credential: minted.credential,
12914
+ runnerId: minted.runnerId,
12915
+ deviceGrants: minted.deviceGrants ?? []
12916
+ };
12917
+ } catch (err) {
12918
+ if (signal.aborted) return { ok: false, reason: "cancelled" };
12919
+ const status2 = err instanceof ApiError ? err.status : 0;
12920
+ if (status2 === 404) return { ok: false, reason: "runner_credential_not_found" };
12921
+ if (status2 === 409 && apiErrorCode(err) === "dispatch_not_admitted")
12922
+ return { ok: false, reason: "runner_credential_not_admitted" };
12923
+ log2.warn(apiErrorLogFields(err), "runner: couldn't get a credential for the turn's runner");
12924
+ const reason = `fetch_failed: ${describeError(err)}`;
12925
+ return { ok: false, reason, errorReason: reason };
12926
+ }
12927
+ }
12928
+ async function settleUnstarted(opts, payload, turnId, errorReason, log2) {
12929
+ try {
12930
+ await opts.api.setActiveRun(opts.workspaceId, payload.conversationId, payload.agentId, {
12931
+ activeRunStartedAt: null,
12932
+ turnId,
12933
+ outcome: "failed",
12934
+ ...errorReason ? { errorReason: errorReason.slice(0, ERROR_REASON_MAX) } : {},
12935
+ readThrough: payload.messageId
12936
+ });
12937
+ } catch (err) {
12938
+ log2.warn(
12939
+ { ...apiErrorLogFields(err), turnId },
12940
+ "Couldn't update the turn's status in Cabane. Check the conversation before trying again."
12941
+ );
12942
+ }
12943
+ }
12944
+ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
12945
+ const startedAt = Date.now();
12946
+ const { turnId } = handleOpts;
12947
+ const oomKilled = (id) => (launch.oomKilled ?? turnScopeOomKilled)(id).catch(() => false);
12948
+ const log2 = opts.log.child({
12949
+ workspaceId: opts.workspaceId,
12950
+ conversationId: payload.conversationId,
12951
+ agentId: payload.agentId,
12952
+ messageId: payload.messageId
12953
+ });
12954
+ const controller = new AbortController();
12955
+ duties.registerAbort(turnId, controller);
12956
+ const stopRunner = new AbortController();
12957
+ let ready = false;
12958
+ let child;
12959
+ let stopAnnounced = false;
12960
+ const announceStop = () => {
12961
+ if (stopAnnounced || !child?.stdin.writable) return;
12962
+ stopAnnounced = true;
12963
+ child.stdin.write(encodeLauncherMessage({ cabaneLauncher: "stop" }));
12964
+ };
12965
+ const forwardStop = () => {
12966
+ announceStop();
12967
+ if (ready) stopRunner.abort();
12968
+ };
12969
+ controller.signal.addEventListener("abort", forwardStop, { once: true });
12970
+ const attempt = {
12971
+ workspaceId: opts.workspaceId,
12972
+ conversationId: payload.conversationId,
12973
+ agentId: payload.agentId,
12974
+ turnId
12975
+ };
12976
+ const minted = await mintForRunner(opts, payload, handleOpts, controller.signal, log2);
12977
+ if (!minted.ok) {
12978
+ controller.signal.removeEventListener("abort", forwardStop);
12979
+ duties.releaseAbort(turnId, controller);
12980
+ const durationMs2 = Date.now() - startedAt;
12981
+ await settleUnstarted(opts, payload, turnId, minted.errorReason, log2);
12982
+ duties.notifyEnd({ id: payload.messageId, ok: false, durationMs: durationMs2, reason: minted.reason });
12983
+ return { ok: false, durationMs: durationMs2, reason: minted.reason };
12984
+ }
12985
+ let result;
12986
+ let thrown;
12987
+ let code;
12988
+ let handle;
12989
+ const groups = [];
12990
+ const remote = [];
12991
+ const lockOwner = (id) => `${turnId}#${id}`;
12992
+ const locks = launch.locks;
12993
+ try {
12994
+ handle = await launch.provider.acquire(attempt);
12995
+ } catch (err) {
12996
+ const reason2 = `runner_unavailable: ${describeError(err)}`;
12997
+ log2.warn(
12998
+ { turnId, provider: launch.provider.name, err: describeError(err) },
12999
+ "runner: no place to run the turn"
13000
+ );
13001
+ controller.signal.removeEventListener("abort", forwardStop);
13002
+ duties.releaseAbort(turnId, controller);
13003
+ await settleUnstarted(opts, payload, turnId, reason2, log2);
13004
+ await launch.reportRunnerGone?.({
13005
+ workspaceId: opts.workspaceId,
13006
+ conversationId: payload.conversationId,
13007
+ agentId: payload.agentId,
13008
+ messageId: payload.messageId,
13009
+ runnerId: minted.runnerId,
13010
+ reason: "provider_failed"
13011
+ });
13012
+ const durationMs2 = Date.now() - startedAt;
13013
+ duties.notifyEnd({ id: payload.messageId, ok: false, durationMs: durationMs2, reason: reason2 });
13014
+ return { ok: false, durationMs: durationMs2, reason: reason2 };
13015
+ }
13016
+ try {
13017
+ const wrapped = launch.wrapped === true;
13018
+ const env = runnerExecEnv(launch.env ?? process.env, wrapped);
13019
+ const grants = minted.deviceGrants.length > 0 ? launch.grants?.(minted.deviceGrants) ?? {} : {};
13020
+ const input = {
13021
+ baseUrl: opts.baseUrl,
13022
+ credential: minted.credential,
13023
+ ...wrapped ? { secrets: launch.secrets?.() ?? {} } : {},
13024
+ ...Object.keys(grants).length > 0 ? { grants } : {},
13025
+ dispatch: payload,
13026
+ turnId,
13027
+ resumed: handleOpts.resumed,
13028
+ spec: runnerSpec(opts, launch, duties, minted.runnerId)
13029
+ };
13030
+ Object.assign(env, runnerEnv(input));
13031
+ const running = launch.provider.exec(handle, launch.command, env, {
13032
+ signal: stopRunner.signal,
13033
+ stdin: runnerStdin(input)
13034
+ });
13035
+ child = running;
13036
+ if (controller.signal.aborted) announceStop();
13037
+ log2.debug(
13038
+ { turnId, runnerPid: running.pid, provider: launch.provider.name },
13039
+ "runner: started"
13040
+ );
13041
+ const stdoutDone = lines(running.stdout, (line) => {
13042
+ const event = decodeRunnerEvent(line);
13043
+ if (!event) {
13044
+ log2.debug({ runnerStdout: line }, "runner: output");
13045
+ return;
13046
+ }
13047
+ switch (event.cabaneRunner) {
13048
+ case "ready":
13049
+ ready = true;
13050
+ if (controller.signal.aborted) stopRunner.abort();
13051
+ return;
13052
+ case "start":
13053
+ duties.notifyStart({
13054
+ id: event.id,
13055
+ conversationId: event.conversationId,
13056
+ message: event.message
13057
+ });
13058
+ return;
13059
+ case "end": {
13060
+ const { cabaneRunner: _kind, ...info } = event;
13061
+ duties.notifyEnd(info);
13062
+ return;
13063
+ }
13064
+ case "session_write_notice":
13065
+ duties.markSessionWriteNotice();
13066
+ return;
13067
+ case "connector_health":
13068
+ launch.connectorHealth?.recordSettle(event.runtime, {
13069
+ ok: event.ok,
13070
+ errorReason: event.errorReason
13071
+ });
13072
+ return;
13073
+ case "result":
13074
+ result = event.result;
13075
+ return;
13076
+ case "error":
13077
+ thrown = event.message;
13078
+ return;
13079
+ case "lock":
13080
+ if (!locks) {
13081
+ log2.warn(
13082
+ { key: event.key },
13083
+ "runner: asked for a device lock this launcher has none of"
13084
+ );
13085
+ return;
13086
+ }
13087
+ locks.request(
13088
+ event.key,
13089
+ lockOwner(event.id),
13090
+ () => running.stdin.write(encodeLauncherMessage({ cabaneLauncher: "granted", id: event.id }))
13091
+ );
13092
+ return;
13093
+ case "unlock":
13094
+ locks?.release(lockOwner(event.id));
13095
+ return;
13096
+ case "process_group":
13097
+ if (!wrapped) groups.push(event.pgid);
13098
+ return;
13099
+ case "remote_session":
13100
+ remote.push({ serverUrl: event.serverUrl, sessionId: event.sessionId });
13101
+ return;
13102
+ case "terminated":
13103
+ void oomKilled(turnId).then((oom) => {
13104
+ if (running.stdin.writable) {
13105
+ running.stdin.write(
13106
+ encodeLauncherMessage({
13107
+ cabaneLauncher: "cause",
13108
+ cause: oom ? "out_of_memory" : null
13109
+ })
13110
+ );
13111
+ }
13112
+ });
13113
+ return;
13114
+ }
13115
+ });
13116
+ const stderrDone = lines(running.stderr, (line) => relayRunnerLog(line, opts.log));
13117
+ code = await running.exit;
13118
+ await Promise.all([stdoutDone, stderrDone]);
13119
+ } finally {
13120
+ if (locks) giveBackLocks(locks, `${turnId}#`, remote, launch, log2);
13121
+ child?.stdin.end();
13122
+ controller.signal.removeEventListener("abort", forwardStop);
13123
+ duties.releaseAbort(turnId, controller);
13124
+ await launch.provider.release(handle, { destroy: false }).catch(
13125
+ (err) => log2.warn({ err: describeError(err) }, "runner: couldn't give its place back")
13126
+ );
13127
+ }
13128
+ if (result) return result;
13129
+ const durationMs = Date.now() - startedAt;
13130
+ const stopped = controller.signal.aborted && thrown === void 0;
13131
+ const reason = stopped ? "cancelled" : thrown !== void 0 ? `runner_failed: ${thrown}` : await oomKilled(turnId) ? "out_of_memory" : `runner_exited: ${code}`;
13132
+ await child?.reap(groups);
13133
+ const reaped = await (launch.reapTurn ?? reapTurnScopes)(turnId).catch((err) => ({
13134
+ failures: [err instanceof Error ? err.message : String(err)]
13135
+ }));
13136
+ if (reaped.failures.length) {
13137
+ log2.warn(
13138
+ { turnId, failures: reaped.failures },
13139
+ "runner: couldn't stop what the dead runner's turn left running"
13140
+ );
13141
+ }
13142
+ try {
13143
+ await opts.api.setActiveRun(opts.workspaceId, payload.conversationId, payload.agentId, {
13144
+ activeRunStartedAt: null,
13145
+ turnId,
13146
+ ...stopped ? { outcome: "interrupted" } : { outcome: "failed", errorReason: reason.slice(0, ERROR_REASON_MAX) },
13147
+ readThrough: payload.messageId
13148
+ });
13149
+ } catch (err) {
13150
+ log2.warn(
13151
+ { ...apiErrorLogFields(err), turnId },
13152
+ "Couldn't update the turn's status in Cabane. Check the conversation before trying again."
13153
+ );
13154
+ }
13155
+ await launch.reportRunnerGone?.({
13156
+ workspaceId: opts.workspaceId,
13157
+ conversationId: payload.conversationId,
13158
+ agentId: payload.agentId,
13159
+ messageId: payload.messageId,
13160
+ runnerId: minted.runnerId,
13161
+ reason: "exited"
13162
+ });
13163
+ if (thrown !== void 0) throw new Error(thrown);
13164
+ if (stopped) log2.info({ turnId, code }, "runner: stopped before it could report");
13165
+ else log2.warn({ turnId, code }, "runner: exited without reporting a result");
13166
+ duties.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
13167
+ return { ok: false, durationMs, reason };
13168
+ }
13169
+
13170
+ // src/dispatcher.ts
13171
+ init_turn_execution();
13172
+ function runKey(conversationId, agentId) {
13173
+ return `${conversationId}|${agentId}`;
13174
+ }
13175
+ var Dispatcher = class {
13176
+ constructor(opts) {
13177
+ this.opts = opts;
13178
+ this.aborts = opts.aborts ?? /* @__PURE__ */ new Map();
13179
+ }
13180
+ opts;
13181
+ // (conversation|agent) → per-turn controllers. A delayed cancel for an old
13182
+ // turn must never reach its successor.
13183
+ aborts;
13184
+ // Pairs already told their session couldn't be saved: the notice is once per
13185
+ // pair, while the rejection repeats every turn.
13186
+ sessionWriteNotified = /* @__PURE__ */ new Set();
13187
+ notifyStart(info) {
12905
13188
  try {
12906
- chmodSync3(tmp, 384);
13189
+ this.opts.observer?.onStart(info);
12907
13190
  } catch {
12908
13191
  }
12909
- renameSync3(tmp, path);
12910
- } catch (err) {
13192
+ }
13193
+ notifyEnd(info) {
12911
13194
  try {
12912
- rmSync4(tmp, { force: true });
13195
+ this.opts.observer?.onEnd(info);
12913
13196
  } catch {
12914
13197
  }
12915
- throw err;
12916
- }
12917
- }
12918
- function getCredential(deviceId, agentId) {
12919
- return load(deviceId)[agentId] ?? null;
12920
- }
12921
- function setCredential(deviceId, agentId, pat) {
12922
- if (read(devicePath(deviceId))?.[agentId] === pat) return;
12923
- const map = load(deviceId);
12924
- map[agentId] = pat;
12925
- save(deviceId, map);
12926
- }
12927
- function pruneCredentials(deviceId, keepAgentIds) {
12928
- const keep = new Set(keepAgentIds);
12929
- if (keep.size === 0) return [];
12930
- const own = read(devicePath(deviceId));
12931
- const map = own ?? load(deviceId);
12932
- const removed = Object.keys(map).filter((id) => !keep.has(id));
12933
- for (const id of removed) delete map[id];
12934
- if (removed.length > 0 || own === null) save(deviceId, map);
12935
- return removed;
12936
- }
12937
- function clearCredentials() {
12938
- rmSync4(credentialsDir(), { recursive: true, force: true });
12939
- if (existsSync7(legacyPath())) writeFileSync3(legacyPath(), "", { mode: 384 });
12940
- }
12941
-
12942
- // src/dispatch-dedupe.ts
12943
- init_config();
12944
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync4, renameSync as renameSync4, existsSync as existsSync8 } from "fs";
12945
- import { join as join13 } from "path";
12946
- var MAX_IDS = 256;
12947
- function dir(log2) {
12948
- return join13(cabaneDir(), log2);
12949
- }
12950
- function pathFor(log2, workspaceId) {
12951
- return join13(dir(log2), encodeURIComponent(workspaceId));
12952
- }
12953
- function readIds(log2, workspaceId) {
12954
- const path = pathFor(log2, workspaceId);
12955
- if (!existsSync8(path)) return [];
12956
- try {
12957
- return readFileSync5(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
12958
- } catch {
12959
- return [];
12960
13198
  }
12961
- }
12962
- function has(log2, workspaceId, eventId) {
12963
- return readIds(log2, workspaceId).includes(eventId);
12964
- }
12965
- function mark(log2, workspaceId, eventId) {
12966
- const ids = readIds(log2, workspaceId);
12967
- if (ids.includes(eventId)) return;
12968
- ids.push(eventId);
12969
- const trimmed = ids.length > MAX_IDS ? ids.slice(-MAX_IDS) : ids;
12970
- mkdirSync6(dir(log2), { recursive: true });
12971
- writeFileSync4(pathFor(log2, workspaceId), trimmed.join("\n") + "\n", "utf8");
12972
- }
12973
- function hasDispatched(workspaceId, eventId) {
12974
- return has("dispatched", workspaceId, eventId);
12975
- }
12976
- function markDispatched(workspaceId, eventId) {
12977
- mark("dispatched", workspaceId, eventId);
12978
- }
12979
- function hasCompleted(workspaceId, eventId) {
12980
- return has("completed", workspaceId, eventId);
12981
- }
12982
- function markCompleted(workspaceId, eventId) {
12983
- mark("completed", workspaceId, eventId);
12984
- forgetTurnId(workspaceId, eventId);
12985
- }
12986
- var MAX_RESUME_ATTEMPTS = 3;
12987
- function resumeDir() {
12988
- return join13(cabaneDir(), "resume-attempts");
12989
- }
12990
- function resumePathFor(workspaceId) {
12991
- return join13(resumeDir(), encodeURIComponent(workspaceId));
12992
- }
12993
- function readResumeCounts(workspaceId) {
12994
- const out = /* @__PURE__ */ new Map();
12995
- const path = resumePathFor(workspaceId);
12996
- if (!existsSync8(path)) return out;
12997
- try {
12998
- for (const line of readFileSync5(path, "utf8").split("\n")) {
12999
- const trimmed = line.trim();
13000
- if (!trimmed) continue;
13001
- const tab = trimmed.lastIndexOf(" ");
13002
- if (tab <= 0) continue;
13003
- const id = trimmed.slice(0, tab);
13004
- const count = Number.parseInt(trimmed.slice(tab + 1), 10);
13005
- if (id && Number.isFinite(count)) out.set(id, count);
13006
- }
13007
- } catch {
13008
- return out;
13199
+ // The supervisor supplies a stable turnId on both fresh and resumed dispatch.
13200
+ // resumed is separate: only replay seeds sequence/speech recovery from the
13201
+ // prior span. A direct caller omitting turnId gets a fresh id.
13202
+ async handle(payload, opts = {}) {
13203
+ const key = runKey(payload.conversationId, payload.agentId);
13204
+ return runTurnInRunner(
13205
+ this.opts.turn,
13206
+ this.opts.runner,
13207
+ {
13208
+ registerAbort: (turnId, controller) => {
13209
+ let pairAborts = this.aborts.get(key);
13210
+ if (!pairAborts) {
13211
+ pairAborts = /* @__PURE__ */ new Map();
13212
+ this.aborts.set(key, pairAborts);
13213
+ }
13214
+ pairAborts.set(turnId, controller);
13215
+ },
13216
+ releaseAbort: (turnId, controller) => {
13217
+ const pair = this.aborts.get(key);
13218
+ if (pair?.get(turnId) !== controller) return;
13219
+ pair.delete(turnId);
13220
+ if (pair.size === 0) this.aborts.delete(key);
13221
+ },
13222
+ sessionWriteNoticeClaimed: () => this.sessionWriteNotified.has(key),
13223
+ markSessionWriteNotice: () => {
13224
+ this.sessionWriteNotified.add(key);
13225
+ },
13226
+ notifyStart: (info) => this.notifyStart(info),
13227
+ notifyEnd: (info) => this.notifyEnd(info)
13228
+ },
13229
+ payload,
13230
+ { turnId: opts.turnId ?? randomUUID4(), resumed: opts.resumed === true }
13231
+ );
13009
13232
  }
13010
- return out;
13011
- }
13012
- function bumpResumeAttempt(workspaceId, eventId) {
13013
- const counts = readResumeCounts(workspaceId);
13014
- const next = (counts.get(eventId) ?? 0) + 1;
13015
- counts.set(eventId, next);
13016
- const entries = [...counts.entries()];
13017
- const trimmed = entries.length > MAX_IDS ? entries.slice(-MAX_IDS) : entries;
13018
- mkdirSync6(resumeDir(), { recursive: true });
13019
- writeFileSync4(
13020
- resumePathFor(workspaceId),
13021
- trimmed.map(([id, c]) => `${id} ${c}`).join("\n") + "\n",
13022
- "utf8"
13023
- );
13024
- return next;
13025
- }
13026
- function turnDir() {
13027
- return join13(cabaneDir(), "turns");
13028
- }
13029
- function turnPathFor(workspaceId) {
13030
- return join13(turnDir(), encodeURIComponent(workspaceId));
13031
- }
13032
- var TURN_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13033
- function readTurnIds(workspaceId) {
13034
- const out = /* @__PURE__ */ new Map();
13035
- const path = turnPathFor(workspaceId);
13036
- if (!existsSync8(path)) return out;
13037
- try {
13038
- for (const line of readFileSync5(path, "utf8").split("\n")) {
13039
- const trimmed = line.trim();
13040
- if (!trimmed) continue;
13041
- const tab = trimmed.lastIndexOf(" ");
13042
- if (tab <= 0) continue;
13043
- const id = trimmed.slice(0, tab);
13044
- const turnId = trimmed.slice(tab + 1);
13045
- if (id && TURN_ID_RE.test(turnId)) out.set(id, turnId);
13233
+ // Cancel the named turn. An older server omits turnId and cancels the pair.
13234
+ cancel(conversationId, agentId, turnId) {
13235
+ const key = runKey(conversationId, agentId);
13236
+ const pair = this.aborts.get(key);
13237
+ if (!pair || pair.size === 0) return false;
13238
+ const controllers = turnId === void 0 ? [...pair.values()] : [pair.get(turnId)];
13239
+ let aborted = false;
13240
+ for (const ac of controllers) {
13241
+ if (!ac) continue;
13242
+ aborted = true;
13243
+ try {
13244
+ ac.abort();
13245
+ } catch {
13246
+ }
13046
13247
  }
13047
- } catch {
13048
- return out;
13049
- }
13050
- return out;
13051
- }
13052
- function turnIdForEvent(workspaceId, eventId) {
13053
- return readTurnIds(workspaceId).get(eventId) ?? null;
13054
- }
13055
- var TURN_ID_OVERFLOW_WARN = 8192;
13056
- function writeTurnIds(workspaceId, turns) {
13057
- const entries = [...turns.entries()];
13058
- mkdirSync6(turnDir(), { recursive: true });
13059
- const path = turnPathFor(workspaceId);
13060
- const tmp = `${path}.${process.pid}.tmp`;
13061
- writeFileSync4(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
13062
- renameSync4(tmp, path);
13063
- return entries.length;
13064
- }
13065
- function rememberTurnId(workspaceId, eventId, turnId) {
13066
- const turns = readTurnIds(workspaceId);
13067
- if (turns.get(eventId) === turnId) return turns.size;
13068
- turns.set(eventId, turnId);
13069
- return writeTurnIds(workspaceId, turns);
13070
- }
13071
- function forgetTurnId(workspaceId, eventId) {
13072
- const turns = readTurnIds(workspaceId);
13073
- if (!turns.delete(eventId)) return;
13074
- writeTurnIds(workspaceId, turns);
13075
- }
13076
- function noResume() {
13077
- return process.env.CABANE_COMPANION_NO_RESUME === "1";
13078
- }
13079
-
13080
- // src/supervisor.ts
13081
- init_bundled_binaries();
13082
- init_install_kind();
13083
-
13084
- // src/connector-health.ts
13085
- init_src();
13086
- var ConnectorHealthStore = class {
13087
- byRuntime = /* @__PURE__ */ new Map();
13088
- // Record a settled turn's outcome for `runtime`. A success heals to `ok`; a
13089
- // classified failure sets `limited`(+reset) / `auth_expired` with the reason as
13090
- // `last_error`; anything else (a user Stop, a timeout, an unclassified error)
13091
- // leaves the last-known status untouched — we only ever move state on real
13092
- // evidence.
13093
- recordSettle(runtime, outcome) {
13094
- if (outcome.ok) {
13095
- this.byRuntime.set(runtime, { quotaState: "ok", limitedUntil: null, lastError: null });
13096
- return;
13248
+ return aborted;
13249
+ }
13250
+ // Abort every run in flight in THIS process, whatever its conversation. For
13251
+ // an agent taken off this device: nothing it is still running here is
13252
+ // legitimate, and a cancel frame that arrives after it is dropped from the
13253
+ // roster finds no dispatcher to reach. True if any was aborted.
13254
+ cancelAll() {
13255
+ let aborted = false;
13256
+ for (const pair of this.aborts.values()) {
13257
+ for (const ac of [...pair.values()]) {
13258
+ try {
13259
+ ac.abort();
13260
+ aborted = true;
13261
+ } catch {
13262
+ }
13263
+ }
13097
13264
  }
13098
- const failure = decodeFailureReason(outcome.errorReason);
13099
- if (!failure) return;
13100
- const quota = quotaForFailure(failure);
13101
- if (!quota) return;
13102
- this.byRuntime.set(runtime, {
13103
- quotaState: quota.quotaState,
13104
- limitedUntil: quota.limitedUntil,
13105
- lastError: outcome.errorReason ?? null
13106
- });
13107
- }
13108
- // The store's current opinion for one runtime, or undefined when it has
13109
- // no observation. Read by the dispatcher at idle-timeout fire: an idle reap
13110
- // while this runtime is definitively `limited` (a present, future `limitedUntil`
13111
- // recorded by a sibling/earlier turn's classified cap settle) is the account cap
13112
- // surfacing as silence, not a genuine hang — so the settle reclassifies to
13113
- // `usage_capped` instead of `timeout_idle`. Returns the stored shape verbatim
13114
- // (the dispatcher reads `quotaState` + `limitedUntil` and applies its own
13115
- // future-instant gate); a read never mutates state.
13116
- lookup(runtime) {
13117
- return this.byRuntime.get(runtime);
13118
- }
13119
- // The per-connector reports to attach to a heartbeat — one entry per runtime the
13120
- // companion has an observation for. Empty until the first classified failure/heal,
13121
- // so a companion that has seen nothing sends no `connectors[]` and the server's
13122
- // manifest synthesis (status-less rows) is unaffected.
13123
- reports() {
13124
- return [...this.byRuntime.entries()].map(([runtime, h]) => ({
13125
- runtime,
13126
- quotaState: h.quotaState,
13127
- limitedUntil: h.limitedUntil,
13128
- lastError: h.lastError
13129
- }));
13265
+ return aborted;
13130
13266
  }
13131
13267
  };
13132
13268
 
13133
- // src/supervisor.ts
13134
- init_dispatcher();
13135
-
13136
13269
  // src/host-report.ts
13137
13270
  import { existsSync as existsSync13, readFileSync as readFileSync10 } from "fs";
13138
13271
  import { userInfo as userInfo2 } from "os";
@@ -13171,7 +13304,7 @@ function hostReport() {
13171
13304
  }
13172
13305
 
13173
13306
  // src/providers/local-process.ts
13174
- import { spawn as spawn5 } from "child_process";
13307
+ import { spawn as spawn4 } from "child_process";
13175
13308
  import { constants as constants2 } from "os";
13176
13309
  var DEFAULT_KILL_GRACE_MS = 3e4;
13177
13310
  var DEFAULT_REAP_GRACE_MS = 3e3;
@@ -13182,7 +13315,7 @@ function exitCode(code, signal) {
13182
13315
  }
13183
13316
  function createLocalProcessProvider(opts = {}) {
13184
13317
  const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
13185
- const spawnFn = opts.spawnFn ?? spawn5;
13318
+ const spawnFn = opts.spawnFn ?? spawn4;
13186
13319
  const reapGraceMs = opts.reapGraceMs ?? DEFAULT_REAP_GRACE_MS;
13187
13320
  const killFn = opts.killFn ?? ((pid, signal) => process.kill(pid, signal));
13188
13321
  const groupAlive = (pgid) => {
@@ -13316,9 +13449,6 @@ var DeviceLocks = class {
13316
13449
  }
13317
13450
  };
13318
13451
 
13319
- // src/supervisor.ts
13320
- init_launch();
13321
-
13322
13452
  // src/manifest.ts
13323
13453
  var DEVICE_MANIFEST = {
13324
13454
  runtimes: [{ name: "claude-code", version: null }],
@@ -13561,9 +13691,9 @@ var SseSubscriber = class {
13561
13691
  init_transcript_writer();
13562
13692
 
13563
13693
  // src/updater.ts
13564
- import { spawn as spawn6 } from "child_process";
13694
+ import { spawn as spawn5 } from "child_process";
13565
13695
  import { copyFileSync as copyFileSync2, existsSync as existsSync16, mkdirSync as mkdirSync14, renameSync as renameSync9, rmSync as rmSync10 } from "fs";
13566
- import { dirname as dirname11, join as join27 } from "path";
13696
+ import { dirname as dirname11, join as join26 } from "path";
13567
13697
 
13568
13698
  // src/boot-rule.ts
13569
13699
  import {
@@ -13575,7 +13705,7 @@ import {
13575
13705
  symlinkSync,
13576
13706
  writeFileSync as writeFileSync7
13577
13707
  } from "fs";
13578
- import { dirname as dirname8, join as join24 } from "path";
13708
+ import { dirname as dirname8, join as join23 } from "path";
13579
13709
  var UNCONFIRMED_WINDOW_MS = 10 * 6e4;
13580
13710
  var MAX_STAGE_ATTEMPTS = 3;
13581
13711
  function superseded(pending, ...running) {
@@ -13603,38 +13733,38 @@ import {
13603
13733
  lstatSync,
13604
13734
  mkdirSync as mkdirSync12,
13605
13735
  readFileSync as readFileSync13,
13606
- readdirSync as readdirSync4,
13736
+ readdirSync as readdirSync3,
13607
13737
  realpathSync as realpathSync5,
13608
13738
  renameSync as renameSync7,
13609
13739
  rmSync as rmSync9,
13610
13740
  symlinkSync as symlinkSync2
13611
13741
  } from "fs";
13612
- import { dirname as dirname9, join as join25 } from "path";
13742
+ import { dirname as dirname9, join as join24 } from "path";
13613
13743
  import { fileURLToPath as fileURLToPath5 } from "url";
13614
13744
  function versionsDir() {
13615
- return join25(cabaneDir(), "versions");
13745
+ return join24(cabaneDir(), "versions");
13616
13746
  }
13617
13747
  function currentLink() {
13618
- return join25(cabaneDir(), "current");
13748
+ return join24(cabaneDir(), "current");
13619
13749
  }
13620
13750
  function bootGuardPath() {
13621
- return join25(cabaneDir(), "boot-guard.mjs");
13751
+ return join24(cabaneDir(), "boot-guard.mjs");
13622
13752
  }
13623
13753
  function pinnedNodeDir() {
13624
- return join25(cabaneDir(), "node");
13754
+ return join24(cabaneDir(), "node");
13625
13755
  }
13626
13756
  function pinnedNodePath() {
13627
- return join25(pinnedNodeDir(), "bin", "node");
13757
+ return join24(pinnedNodeDir(), "bin", "node");
13628
13758
  }
13629
13759
  function versionPackageRoot(version) {
13630
- return join25(versionsDir(), version, "node_modules", "@cabane", "companion");
13760
+ return join24(versionsDir(), version, "node_modules", "@cabane", "companion");
13631
13761
  }
13632
13762
  function ownPackageRoot(moduleUrl = import.meta.url) {
13633
13763
  return dirname9(dirname9(fileURLToPath5(moduleUrl)));
13634
13764
  }
13635
13765
  function packageVersion(root) {
13636
13766
  try {
13637
- const pkg2 = JSON.parse(readFileSync13(join25(root, "package.json"), "utf8"));
13767
+ const pkg2 = JSON.parse(readFileSync13(join24(root, "package.json"), "utf8"));
13638
13768
  return typeof pkg2.version === "string" ? pkg2.version : null;
13639
13769
  } catch {
13640
13770
  return null;
@@ -13643,7 +13773,7 @@ function packageVersion(root) {
13643
13773
  function currentRoot() {
13644
13774
  try {
13645
13775
  const root = realpathSync5(currentLink());
13646
- return existsSync15(join25(root, "dist", "cli.js")) ? root : null;
13776
+ return existsSync15(join24(root, "dist", "cli.js")) ? root : null;
13647
13777
  } catch {
13648
13778
  return null;
13649
13779
  }
@@ -13664,9 +13794,9 @@ function pruneVersions(keep) {
13664
13794
  const dir2 = versionsDir();
13665
13795
  if (!existsSync15(dir2)) return [];
13666
13796
  const removed = [];
13667
- for (const name of readdirSync4(dir2)) {
13797
+ for (const name of readdirSync3(dir2)) {
13668
13798
  if (keep.includes(name)) continue;
13669
- rmSync9(join25(dir2, name), { recursive: true, force: true });
13799
+ rmSync9(join24(dir2, name), { recursive: true, force: true });
13670
13800
  removed.push(name);
13671
13801
  }
13672
13802
  return removed;
@@ -13686,7 +13816,7 @@ function compareVersions(a, b) {
13686
13816
  // src/update-state.ts
13687
13817
  init_config();
13688
13818
  import { mkdirSync as mkdirSync13, readFileSync as readFileSync14, renameSync as renameSync8, writeFileSync as writeFileSync8 } from "fs";
13689
- import { dirname as dirname10, join as join26 } from "path";
13819
+ import { dirname as dirname10, join as join25 } from "path";
13690
13820
  import { z as z18 } from "zod";
13691
13821
  var resultSchema = z18.object({
13692
13822
  kind: z18.enum(["updated", "rolled_back", "failed"]),
@@ -13710,7 +13840,7 @@ var stateSchema = z18.object({
13710
13840
  failed: z18.object({ version: z18.string(), attempts: z18.number().int(), at: z18.string() }).optional()
13711
13841
  });
13712
13842
  function updateStatePath(dir2 = cabaneDir()) {
13713
- return join26(dir2, "update.json");
13843
+ return join25(dir2, "update.json");
13714
13844
  }
13715
13845
  function readUpdateState(path = updateStatePath()) {
13716
13846
  try {
@@ -13759,7 +13889,7 @@ function rollBack(pending, reason, now = /* @__PURE__ */ new Date()) {
13759
13889
  return updateUpdateState((state) => rolledBackState(state, pending, reason, now));
13760
13890
  }
13761
13891
  function writeBootGuard(root = ownPackageRoot()) {
13762
- const built = join27(root, "dist", "boot-guard.js");
13892
+ const built = join26(root, "dist", "boot-guard.js");
13763
13893
  if (!existsSync16(built)) return false;
13764
13894
  const target = bootGuardPath();
13765
13895
  mkdirSync14(dirname11(target), { recursive: true });
@@ -13776,7 +13906,7 @@ var runProcess = (command, args, opts = {}) => new Promise((resolve2) => {
13776
13906
  let stderr = "";
13777
13907
  let child;
13778
13908
  try {
13779
- child = spawn6(command, [...args], {
13909
+ child = spawn5(command, [...args], {
13780
13910
  stdio: ["ignore", "pipe", "pipe"],
13781
13911
  env: opts.env ?? process.env
13782
13912
  });
@@ -13799,17 +13929,17 @@ var runProcess = (command, args, opts = {}) => new Promise((resolve2) => {
13799
13929
  async function stageVersion(version, harnesses, deps = {}) {
13800
13930
  const run = deps.run ?? runProcess;
13801
13931
  const node = deps.execPath ?? process.execPath;
13802
- const target = join27(versionsDir(), version);
13932
+ const target = join26(versionsDir(), version);
13803
13933
  const placed = versionPackageRoot(version);
13804
13934
  if (existsSync16(placed)) {
13805
13935
  if (!await verifyInstall(placed, version, harnesses, { run, execPath: node }))
13806
13936
  return { ok: true, root: placed };
13807
13937
  }
13808
- const npm = join27(dirname11(node), "npm");
13938
+ const npm = join26(dirname11(node), "npm");
13809
13939
  if (!existsSync16(npm))
13810
13940
  return { ok: false, reason: `there is no npm beside Node at ${dirname11(node)}` };
13811
13941
  mkdirSync14(versionsDir(), { recursive: true });
13812
- const staging = join27(versionsDir(), `.${version}.staging-${process.pid}`);
13942
+ const staging = join26(versionsDir(), `.${version}.staging-${process.pid}`);
13813
13943
  rmSync10(staging, { recursive: true, force: true });
13814
13944
  const installed = await run(
13815
13945
  npm,
@@ -13838,7 +13968,7 @@ async function stageVersion(version, harnesses, deps = {}) {
13838
13968
  return { ok: false, reason: `version ${version} isn't published yet`, unpublished: true };
13839
13969
  return { ok: false, reason: `npm couldn't install it: ${lastLine(installed.stderr)}` };
13840
13970
  }
13841
- const root = join27(staging, "node_modules", "@cabane", "companion");
13971
+ const root = join26(staging, "node_modules", "@cabane", "companion");
13842
13972
  const verdict = await verifyInstall(root, version, harnesses, { run, execPath: node });
13843
13973
  if (verdict) {
13844
13974
  rmSync10(staging, { recursive: true, force: true });
@@ -13850,7 +13980,7 @@ async function stageVersion(version, harnesses, deps = {}) {
13850
13980
  }
13851
13981
  async function verifyInstall(root, version, harnesses, deps) {
13852
13982
  if (packageVersion(root) !== version) return `the installed package isn't version ${version}`;
13853
- const answered = await deps.run(deps.execPath, [join27(root, "dist", "bin.js"), "--version"], {
13983
+ const answered = await deps.run(deps.execPath, [join26(root, "dist", "bin.js"), "--version"], {
13854
13984
  timeoutMs: 6e4,
13855
13985
  env: { ...process.env, CABANE_LAUNCH_SELF: "1" }
13856
13986
  });
@@ -13858,7 +13988,7 @@ async function verifyInstall(root, version, harnesses, deps) {
13858
13988
  return `it doesn't start: ${lastLine(answered.stderr) || "no version printed"}`;
13859
13989
  }
13860
13990
  if (harnesses.length === 0) return null;
13861
- const probed = await deps.run(deps.execPath, [join27(root, "dist", "postinstall.js"), "--json"], {
13991
+ const probed = await deps.run(deps.execPath, [join26(root, "dist", "postinstall.js"), "--json"], {
13862
13992
  timeoutMs: 6e4
13863
13993
  });
13864
13994
  let resolutions = {};
@@ -14276,13 +14406,13 @@ var CompanionSupervisor = class {
14276
14406
  if (this.stopped) return;
14277
14407
  this.log.info(
14278
14408
  { protocolVersion: TURN_PROTOCOL_VERSION, version: COMPANION_VERSION },
14279
- `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname2()}`
14409
+ `Companion ${COMPANION_VERSION} starting on ${this.config.deviceLabel ?? hostname()}`
14280
14410
  );
14281
14411
  const retired = retiredConfigKeysOnDisk();
14282
14412
  if (retired.length > 0) {
14283
14413
  this.log.warn(
14284
14414
  { keys: retired },
14285
- `config.json: ${retired.join(", ")} ${retired.length === 1 ? "is" : "are"} no longer read and can be removed. The companion does no sandboxing, and every turn runs in a child process of its own.`
14415
+ `config.json: ${retired.join(", ")} ${retired.length === 1 ? "is" : "are"} no longer read and can be removed.`
14286
14416
  );
14287
14417
  }
14288
14418
  if (this.config.deviceToken) {
@@ -14322,7 +14452,7 @@ var CompanionSupervisor = class {
14322
14452
  log: this.log,
14323
14453
  lastEventId: null,
14324
14454
  onOpen: () => {
14325
- this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname2()}`);
14455
+ this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname()}`);
14326
14456
  this.deviceConnected = true;
14327
14457
  for (const wr of this.workspaces.values()) {
14328
14458
  this.hub.setConnected(wr.workspaceId, true);
@@ -14868,9 +14998,9 @@ var CompanionSupervisor = class {
14868
14998
  this.runnerCommand ?? selfRunnerCommand()
14869
14999
  ),
14870
15000
  ...this.config.runner?.command ? {
14871
- boxed: true,
15001
+ wrapped: true,
14872
15002
  secrets: () => loadSecretValuesTolerant(
14873
- (m) => this.log.warn({ err: m }, "Could not read the secrets file for a boxed turn.")
15003
+ (m) => this.log.warn({ err: m }, "Could not read the secrets file for a wrapped turn.")
14874
15004
  )
14875
15005
  } : {},
14876
15006
  // Read per dispatch, for the secret variables its mint named and no others.
@@ -15549,9 +15679,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
15549
15679
  }
15550
15680
  function defaultReexec() {
15551
15681
  clearRuntimeState();
15552
- void import("child_process").then(({ spawn: spawn8 }) => {
15682
+ void import("child_process").then(({ spawn: spawn7 }) => {
15553
15683
  try {
15554
- const child = spawn8(process.execPath, process.argv.slice(1), {
15684
+ const child = spawn7(process.execPath, process.argv.slice(1), {
15555
15685
  stdio: "inherit",
15556
15686
  detached: false
15557
15687
  });
@@ -15623,9 +15753,9 @@ function handleUncaught(log2, err, origin) {
15623
15753
  // src/crash-marker.ts
15624
15754
  init_config();
15625
15755
  import { existsSync as existsSync17, mkdirSync as mkdirSync15, readFileSync as readFileSync15, rmSync as rmSync11, writeFileSync as writeFileSync9 } from "fs";
15626
- import { join as join28 } from "path";
15756
+ import { join as join27 } from "path";
15627
15757
  function crashMarkerPath() {
15628
- return join28(cabaneDir(), "last-error.json");
15758
+ return join27(cabaneDir(), "last-error.json");
15629
15759
  }
15630
15760
  function recordCrash(rec2) {
15631
15761
  try {
@@ -15646,7 +15776,7 @@ function clearCrash() {
15646
15776
  init_config();
15647
15777
  import { spawnSync } from "child_process";
15648
15778
  import { readFileSync as readFileSync16 } from "fs";
15649
- import { join as join29 } from "path";
15779
+ import { join as join28 } from "path";
15650
15780
  var COMPANION_ENTRY = /@cabane\/companion\/|cabane-companion|(^|[\s/])cabane\s|\.cabane\/(versions|current|boot-guard)/;
15651
15781
  var START = /\sstart(\s|$)/;
15652
15782
  function companionStarts(psOutput) {
@@ -15663,7 +15793,7 @@ function companionStarts(psOutput) {
15663
15793
  }
15664
15794
  function stateDirOf(env) {
15665
15795
  const home = env.CABANE_COMPANION_HOME || env.HOME;
15666
- return home ? join29(home, ".cabane") : null;
15796
+ return home ? join28(home, ".cabane") : null;
15667
15797
  }
15668
15798
  function envFromPsE(output, command) {
15669
15799
  const line = output.replace(/\n$/, "");
@@ -15938,7 +16068,7 @@ import {
15938
16068
  writeFileSync as writeFileSync10
15939
16069
  } from "fs";
15940
16070
  import { homedir as homedir9, userInfo as userInfo3 } from "os";
15941
- import { dirname as dirname12, join as join30, sep as sep3 } from "path";
16071
+ import { dirname as dirname12, join as join29, sep as sep3 } from "path";
15942
16072
  function servicePlatform(platform = process.platform) {
15943
16073
  if (platform === "linux") return "systemd";
15944
16074
  if (platform === "darwin") return "launchd";
@@ -15956,15 +16086,15 @@ function serviceNode(execPath = process.execPath) {
15956
16086
  return existsSync18(pinnedNodePath()) ? pinnedNodePath() : execPath;
15957
16087
  }
15958
16088
  function servicePaths(platform, env = process.env, home = homedir9(), node = serviceNode()) {
15959
- const unitFile = platform === "systemd" ? join30(
15960
- env.XDG_CONFIG_HOME || join30(home, ".config"),
16089
+ const unitFile = platform === "systemd" ? join29(
16090
+ env.XDG_CONFIG_HOME || join29(home, ".config"),
15961
16091
  "systemd",
15962
16092
  "user",
15963
16093
  `${serviceName(env)}.service`
15964
- ) : join30(home, "Library", "LaunchAgents", `${launchdLabel(env)}.plist`);
16094
+ ) : join29(home, "Library", "LaunchAgents", `${launchdLabel(env)}.plist`);
15965
16095
  return {
15966
16096
  unitFile,
15967
- envFile: join30(cabaneDir(), "service.env"),
16097
+ envFile: join29(cabaneDir(), "service.env"),
15968
16098
  node,
15969
16099
  entry: bootGuardPath()
15970
16100
  };
@@ -16031,7 +16161,7 @@ function renderLaunchdPlist(paths, env = process.env, servicePath) {
16031
16161
  " <key>ThrottleInterval</key><integer>5</integer>",
16032
16162
  // The companion writes its own log; the console copy would only grow a second one.
16033
16163
  " <key>StandardOutPath</key><string>/dev/null</string>",
16034
- ` <key>StandardErrorPath</key><string>${xml(join30(cabaneDir(), "service-error.log"))}</string>`,
16164
+ ` <key>StandardErrorPath</key><string>${xml(join29(cabaneDir(), "service-error.log"))}</string>`,
16035
16165
  "</dict>",
16036
16166
  "</plist>",
16037
16167
  ""
@@ -16171,15 +16301,15 @@ async function seedManagedInstall(deps) {
16171
16301
  }
16172
16302
  const target = versionPackageRoot(version);
16173
16303
  if (!existsSync18(target)) {
16174
- const staging = join30(versionsDir(), `.${version}.seed-${process.pid}`);
16304
+ const staging = join29(versionsDir(), `.${version}.seed-${process.pid}`);
16175
16305
  rmSync12(staging, { recursive: true, force: true });
16176
16306
  try {
16177
- cpSync(own, join30(staging, "node_modules", "@cabane", "companion"), {
16307
+ cpSync(own, join29(staging, "node_modules", "@cabane", "companion"), {
16178
16308
  recursive: true,
16179
16309
  verbatimSymlinks: true
16180
16310
  });
16181
- rmSync12(join30(versionsDir(), version), { recursive: true, force: true });
16182
- renameSync10(staging, join30(versionsDir(), version));
16311
+ rmSync12(join29(versionsDir(), version), { recursive: true, force: true });
16312
+ renameSync10(staging, join29(versionsDir(), version));
16183
16313
  } catch (err) {
16184
16314
  rmSync12(staging, { recursive: true, force: true });
16185
16315
  return {
@@ -16190,7 +16320,7 @@ async function seedManagedInstall(deps) {
16190
16320
  }
16191
16321
  const unrunnable = await verifyInstall(target, version, [], deps);
16192
16322
  if (unrunnable) {
16193
- rmSync12(join30(versionsDir(), version), { recursive: true, force: true });
16323
+ rmSync12(join29(versionsDir(), version), { recursive: true, force: true });
16194
16324
  return { ok: false, reason: `The copy of Cabane for the service can't run: ${unrunnable}.` };
16195
16325
  }
16196
16326
  pointCurrentAt(target);
@@ -16272,9 +16402,9 @@ function userName() {
16272
16402
  return process.env.USER ?? "";
16273
16403
  }
16274
16404
  }
16275
- function rewriteScriptWrappers(dir2 = join30(cabaneDir(), "bin")) {
16405
+ function rewriteScriptWrappers(dir2 = join29(cabaneDir(), "bin")) {
16276
16406
  for (const name of ["cabane", "cabane-companion"]) {
16277
- const path = join30(dir2, name);
16407
+ const path = join29(dir2, name);
16278
16408
  let text;
16279
16409
  try {
16280
16410
  text = readFileSync17(path, "utf8");
@@ -16324,7 +16454,7 @@ async function controlService(action, deps) {
16324
16454
  init_cabane_command();
16325
16455
  init_cli_entry();
16326
16456
  init_config();
16327
- import { spawn as spawn7 } from "child_process";
16457
+ import { spawn as spawn6 } from "child_process";
16328
16458
  import { closeSync as closeSync3, mkdirSync as mkdirSync17, openSync as openSync4 } from "fs";
16329
16459
  init_config();
16330
16460
  async function startDaemon(opts = {}, deps = {}) {
@@ -16406,7 +16536,7 @@ function defaultSpawnDetached(args) {
16406
16536
  mkdirSync17(cabaneDir(), { recursive: true });
16407
16537
  const logFd = openSync4(companionLogPath(), "a");
16408
16538
  try {
16409
- const child = spawn7(process.execPath, [cliPath, ...args], {
16539
+ const child = spawn6(process.execPath, [cliPath, ...args], {
16410
16540
  detached: true,
16411
16541
  stdio: ["ignore", logFd, logFd],
16412
16542
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -16832,7 +16962,7 @@ async function setupScript(opts, deps) {
16832
16962
  }
16833
16963
  device = await (deps.claim ?? claimSetupCode)(baseUrl, {
16834
16964
  userCode: opts.code,
16835
- label: deviceLabelFromHostname(hostname3()) ?? "Computer",
16965
+ label: deviceLabelFromHostname(hostname2()) ?? "Computer",
16836
16966
  ...paired && cfg?.deviceToken ? { deviceToken: cfg.deviceToken } : {}
16837
16967
  });
16838
16968
  writePairedConfig(device);
@@ -17031,9 +17161,9 @@ async function logs(opts = {}) {
17031
17161
  init_cabane_command();
17032
17162
  init_install_kind();
17033
17163
  init_cabane_command();
17034
- import { readdirSync as readdirSync5, realpathSync as realpathSync6, existsSync as existsSync19 } from "fs";
17164
+ import { readdirSync as readdirSync4, realpathSync as realpathSync6, existsSync as existsSync19 } from "fs";
17035
17165
  import { homedir as homedir10 } from "os";
17036
- import { dirname as dirname13, join as join31 } from "path";
17166
+ import { dirname as dirname13, join as join30 } from "path";
17037
17167
  init_config();
17038
17168
  init_secrets();
17039
17169
  init_term();
@@ -17319,7 +17449,7 @@ function versionLine() {
17319
17449
  const install = companionInstall();
17320
17450
  const own = installedCommandPath({ install });
17321
17451
  if (install === "script") {
17322
- return `Cabane ${COMPANION_VERSION} \xB7 installed by the install script (${tildePath(join31(homedir10(), ".cabane"))})`;
17452
+ return `Cabane ${COMPANION_VERSION} \xB7 installed by the install script (${tildePath(join30(homedir10(), ".cabane"))})`;
17323
17453
  }
17324
17454
  if (own)
17325
17455
  return `Cabane ${COMPANION_VERSION} \xB7 installed with npm (${tildePath(dirname13(dirname13(own)))})`;
@@ -17345,7 +17475,7 @@ function otherInstall(install, own) {
17345
17475
  }
17346
17476
  };
17347
17477
  if (install === "npm") {
17348
- const script = join31(
17478
+ const script = join30(
17349
17479
  homedir10(),
17350
17480
  ".cabane",
17351
17481
  "node",
@@ -17354,11 +17484,11 @@ function otherInstall(install, own) {
17354
17484
  "@cabane",
17355
17485
  "companion"
17356
17486
  );
17357
- return existsSync19(script) ? join31(homedir10(), ".cabane") : null;
17487
+ return existsSync19(script) ? join30(homedir10(), ".cabane") : null;
17358
17488
  }
17359
17489
  const found = firstOnPath("cabane-companion", process.env.PATH);
17360
17490
  if (!found) return null;
17361
- const ownAlias = own ? real(join31(dirname13(own), "cabane-companion")) : null;
17491
+ const ownAlias = own ? real(join30(dirname13(own), "cabane-companion")) : null;
17362
17492
  return real(found) === ownAlias ? null : dirname13(found);
17363
17493
  }
17364
17494
  function runningMode(running, service2) {
@@ -17396,7 +17526,7 @@ function transcriptsLine() {
17396
17526
  let count = 0;
17397
17527
  let newest;
17398
17528
  try {
17399
- const files = readdirSync5(dir2).filter((f) => f.endsWith(".jsonl")).sort();
17529
+ const files = readdirSync4(dir2).filter((f) => f.endsWith(".jsonl")).sort();
17400
17530
  count = files.length;
17401
17531
  newest = files[files.length - 1];
17402
17532
  } catch {
@@ -17564,8 +17694,8 @@ init_cabane_command();
17564
17694
  init_errors();
17565
17695
  init_src();
17566
17696
  init_transcript_writer();
17567
- import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync6 } from "fs";
17568
- import { basename as basename4, isAbsolute as isAbsolute2, join as join32 } from "path";
17697
+ import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync5 } from "fs";
17698
+ import { basename as basename4, isAbsolute as isAbsolute2, join as join31 } from "path";
17569
17699
  async function transcript(opts = {}) {
17570
17700
  const dir2 = transcriptsDir();
17571
17701
  if (opts.follow) {
@@ -17582,7 +17712,7 @@ async function transcript(opts = {}) {
17582
17712
  process.stdout.write(emptyMessage(dir2));
17583
17713
  return;
17584
17714
  }
17585
- process.stdout.write(renderFile(join32(dir2, newest)) + "\n");
17715
+ process.stdout.write(renderFile(join31(dir2, newest)) + "\n");
17586
17716
  return;
17587
17717
  }
17588
17718
  printList(dir2);
@@ -17665,7 +17795,7 @@ function isComplete(content) {
17665
17795
  async function followTranscripts(dir2) {
17666
17796
  const follower = new TranscriptFollower({
17667
17797
  listFiles: () => listFiles(dir2),
17668
- read: (f) => readFileSync18(join32(dir2, f), "utf8"),
17798
+ read: (f) => readFileSync18(join31(dir2, f), "utf8"),
17669
17799
  write: (s) => process.stdout.write(s),
17670
17800
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
17671
17801
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -17691,7 +17821,7 @@ async function followTranscripts(dir2) {
17691
17821
  function listTranscriptFiles(dir2) {
17692
17822
  let entries;
17693
17823
  try {
17694
- entries = readdirSync6(dir2, { withFileTypes: true });
17824
+ entries = readdirSync5(dir2, { withFileTypes: true });
17695
17825
  } catch {
17696
17826
  return [];
17697
17827
  }
@@ -17701,7 +17831,7 @@ function listTranscriptFiles(dir2) {
17701
17831
  if (!entry.isDirectory() || entry.name === "anomalies") continue;
17702
17832
  let inner;
17703
17833
  try {
17704
- inner = readdirSync6(join32(dir2, entry.name));
17834
+ inner = readdirSync5(join31(dir2, entry.name));
17705
17835
  } catch {
17706
17836
  continue;
17707
17837
  }
@@ -17720,7 +17850,7 @@ function printList(dir2) {
17720
17850
 
17721
17851
  `);
17722
17852
  for (const f of files.slice(0, 20)) {
17723
- const { meta, outcome } = peek(join32(dir2, f));
17853
+ const { meta, outcome } = peek(join31(dir2, f));
17724
17854
  const when = fmtTime(rec(meta)?.ts);
17725
17855
  const ws = str2(rec(meta)?.workspaceSlug);
17726
17856
  const o = rec(outcome);
@@ -17756,13 +17886,13 @@ function peek(path) {
17756
17886
  function resolveTarget(dir2, target) {
17757
17887
  if (isAbsolute2(target) || target.includes("/")) {
17758
17888
  if (existsSync20(target)) return target;
17759
- if (!isAbsolute2(target) && existsSync20(join32(dir2, target))) return join32(dir2, target);
17889
+ if (!isAbsolute2(target) && existsSync20(join31(dir2, target))) return join31(dir2, target);
17760
17890
  throw new CompanionError(`no transcript at ${target}.`);
17761
17891
  }
17762
- const exact = join32(dir2, target);
17892
+ const exact = join31(dir2, target);
17763
17893
  if (existsSync20(exact)) return exact;
17764
17894
  const matches = listFiles(dir2).filter((f) => f.includes(target));
17765
- if (matches.length === 1) return join32(dir2, matches[0]);
17895
+ if (matches.length === 1) return join31(dir2, matches[0]);
17766
17896
  if (matches.length === 0) {
17767
17897
  throw new CompanionError(
17768
17898
  `no transcript matching "${target}" in ${dir2}. Run \`${cabane("transcript")}\` to list them.`