@cabane/companion 0.6.120 → 0.6.122

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/runtime.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/runtime.ts
2
- import { randomUUID as randomUUID5 } from "crypto";
2
+ import { randomUUID as randomUUID6 } from "crypto";
3
3
 
4
4
  // src/config.ts
5
5
  import {
@@ -352,8 +352,24 @@ var companionConfigSchema = z2.object({
352
352
  // half-installed platform package could not start.
353
353
  codex: z2.object({
354
354
  enabled: z2.boolean().optional()
355
+ }).strict().optional(),
356
+ // CT1679: run each turn in a child runner process instead of in this one.
357
+ // Absent, or `enabled: false`, is the in-process path, untouched. `sandbox`
358
+ // wraps the runner in an OS sandbox where the host supports one; `'none'`
359
+ // (the default) runs it as a plain child beside this process, a supported
360
+ // configuration in its own right rather than a degraded one.
361
+ runners: z2.object({
362
+ enabled: z2.boolean().optional(),
363
+ provider: z2.literal("local-process").optional(),
364
+ sandbox: z2.enum(["os", "none"]).optional()
355
365
  }).strict().optional()
356
366
  });
367
+ function runnersEnabled(cfg) {
368
+ return cfg.runners?.enabled === true;
369
+ }
370
+ function runnerStateDir() {
371
+ return process.env.CABANE_RUNNER_STATE_DIR || cabaneDir();
372
+ }
357
373
  function isCodexEnabled(cfg) {
358
374
  return !!cfg.codex && cfg.codex.enabled !== false;
359
375
  }
@@ -1462,8 +1478,8 @@ function registerRoutes(app, deps) {
1462
1478
  return c.json({ dispatches: hub.recentDispatches(limit).map(dispatchToWire) });
1463
1479
  });
1464
1480
  app.get("/api/logs", async (c) => {
1465
- const lines = clampLimit(c.req.query("lines"), 200, 1e3);
1466
- return c.json({ lines: tailFile(companionLogPath(), lines) });
1481
+ const lines2 = clampLimit(c.req.query("lines"), 200, 1e3);
1482
+ return c.json({ lines: tailFile(companionLogPath(), lines2) });
1467
1483
  });
1468
1484
  app.post("/api/settings", async (c) => {
1469
1485
  const body = await readJson(c);
@@ -1582,7 +1598,7 @@ function clampLimit(raw, fallback, max = 200) {
1582
1598
  if (!Number.isFinite(n) || n <= 0) return fallback;
1583
1599
  return Math.min(Math.floor(n), max);
1584
1600
  }
1585
- function tailFile(path, lines) {
1601
+ function tailFile(path, lines2) {
1586
1602
  if (!existsSync2(path)) return [];
1587
1603
  const MAX_BYTES = 256 * 1024;
1588
1604
  let fd;
@@ -1597,7 +1613,7 @@ function tailFile(path, lines) {
1597
1613
  const text = buf.toString("utf8");
1598
1614
  const all = text.split("\n");
1599
1615
  if (start > 0 && all.length > 0) all.shift();
1600
- return all.filter((l) => l.length > 0).slice(-lines);
1616
+ return all.filter((l) => l.length > 0).slice(-lines2);
1601
1617
  } catch {
1602
1618
  return [];
1603
1619
  } finally {
@@ -2278,7 +2294,7 @@ import { randomUUID } from "crypto";
2278
2294
  var bootId = randomUUID();
2279
2295
 
2280
2296
  // src/supervisor.ts
2281
- import { randomUUID as randomUUID4 } from "crypto";
2297
+ import { randomUUID as randomUUID5 } from "crypto";
2282
2298
 
2283
2299
  // src/api.ts
2284
2300
  var DEVICE_HEADER = "x-cabane-device";
@@ -2446,27 +2462,6 @@ var CabaneApi = class {
2446
2462
  outboxSize() {
2447
2463
  return this.opts.outbox?.size() ?? 0;
2448
2464
  }
2449
- getConversation(workspaceId, conversationId) {
2450
- return this.request("GET", `/api/workspaces/${workspaceId}/conversations/${conversationId}`);
2451
- }
2452
- // SJ383: companion-only participant ops. All three authenticate with the
2453
- // workspace's agent-bound PAT (passed as `token` on this client) — never
2454
- // the user PAT.
2455
- // Recovery-path read: returns the companion's `agentSessionId` for this
2456
- // conversation so the dispatcher can pass it as `Options.resume`, plus
2457
- // the current `activeRunStartedAt` so a freshly-reconnected companion can
2458
- // see whether a prior run is still flagged in-flight.
2459
- // SJ383: recovery-path read for a participant row — the `agentSessionId` a
2460
- // freshly-reconnected companion resumes on. CT262: the per-turn piggybacks this
2461
- // fetch grew (the ABOUT charters / visionBlocks / channel / members / conversationContext)
2462
- // are gone — `getTurnContext` composes the whole turn now — so this is back to
2463
- // the plain recovery read, with no `messageId` param.
2464
- getParticipantAgent(workspaceId, conversationId, agentId) {
2465
- return this.request(
2466
- "GET",
2467
- `/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}`
2468
- );
2469
- }
2470
2465
  // CT262: the ONE turn-context fetch. Collapses the companion's old four-fetch
2471
2466
  // choreography (getConversation + getMessage + getAgentSelf + getParticipantAgent)
2472
2467
  // into a single call: the server composes the whole server portion of the
@@ -2746,34 +2741,6 @@ var CabaneApi = class {
2746
2741
  signal
2747
2742
  );
2748
2743
  }
2749
- // SJ493: fetch the agent's self-view — identity + operating context. The
2750
- // companion calls this on each dispatch to get its `systemPrompt` (composed
2751
- // server-side from the bundled default + the agent's charter) rather than
2752
- // baking a copy of the prompt into the download. Cabane is the control plane
2753
- // for the prompt, so changing it (or the per-agent charter) takes effect
2754
- // without shipping a new companion. Agent-PAT authed; the workspace is implied
2755
- // by the PAT, so no workspace arg.
2756
- // CT245: pass the triggering turn's `conversationId` so the server returns the
2757
- // run-config RESOLVED for this conversation (agent default + that
2758
- // conversation-agent's per-conversation override), computed with the same
2759
- // shared resolver the in-app path uses. Omitting it (older call sites) returns
2760
- // the agent default — graceful degradation, no version coupling.
2761
- getAgentSelf(conversationId) {
2762
- const path = conversationId ? `/api/agent/me?conversationId=${encodeURIComponent(conversationId)}` : `/api/agent/me`;
2763
- return this.request("GET", path);
2764
- }
2765
- // The companion fetches the triggering message body by listing the
2766
- // conversation's messages and finding the one with `id === messageId`.
2767
- // Cabane has no single-message GET endpoint; for v0 this is fine because
2768
- // the companion only reaches for the specific row immediately after the
2769
- // event fires (the thread is small at that point).
2770
- async getMessage(workspaceId, conversationId, messageId2) {
2771
- const res = await this.request(
2772
- "GET",
2773
- `/api/workspaces/${workspaceId}/conversations/${conversationId}/messages`
2774
- );
2775
- return res.messages.find((m) => m.id === messageId2) ?? null;
2776
- }
2777
2744
  };
2778
2745
  function isRetryable(err) {
2779
2746
  if (err instanceof ApiError) return err.status >= 500;
@@ -2967,78 +2934,21 @@ function pruneCredentials(keepAgentIds) {
2967
2934
  return removed;
2968
2935
  }
2969
2936
 
2970
- // src/cursor.ts
2971
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync6 } from "fs";
2972
- import { join as join9 } from "path";
2973
- function pathFor(workspaceId) {
2974
- return join9(cabaneDir(), "cursors", encodeURIComponent(workspaceId));
2975
- }
2976
- function readCursor(workspaceId) {
2977
- const path = pathFor(workspaceId);
2978
- if (!existsSync6(path)) return null;
2979
- const raw = readFileSync4(path, "utf8").trim();
2980
- return raw.length > 0 ? raw : null;
2981
- }
2982
- function writeCursor(workspaceId, eventId) {
2983
- const path = pathFor(workspaceId);
2984
- mkdirSync6(join9(cabaneDir(), "cursors"), { recursive: true });
2985
- writeFileSync4(path, eventId + "\n", "utf8");
2986
- }
2987
-
2988
- // src/cursor-tracker.ts
2989
- var CursorTracker = class {
2990
- constructor(workspaceId) {
2991
- this.workspaceId = workspaceId;
2992
- }
2993
- workspaceId;
2994
- // Event ids in arrival order that haven't yet flushed to the persisted
2995
- // cursor. Holds the in-flight (un-settled) dispatch events plus any later
2996
- // events that have settled but are blocked behind an earlier in-flight one.
2997
- pending = [];
2998
- // Ids observed as settled. An entry is dropped the moment it flushes, so the
2999
- // set only ever holds settled-but-blocked ids — bounded by concurrent
3000
- // in-flight turns and the traffic that arrived during them.
3001
- settled = /* @__PURE__ */ new Set();
3002
- // Record an event id in arrival order. Must be called synchronously, in
3003
- // stream order, for every event the supervisor will later `settle` — so the
3004
- // cumulative prefix reflects the true stream sequence. Idempotent: an id
3005
- // already tracked (an in-flight event, or a settled-but-blocked one) is
3006
- // ignored, so a process-restart replay can re-observe the same ids without
3007
- // double-counting.
3008
- observe(eventId) {
3009
- if (this.pending.includes(eventId) || this.settled.has(eventId)) return;
3010
- this.pending.push(eventId);
3011
- }
3012
- // Mark an event settled — for a dispatch turn, only once it has completed;
3013
- // for everything else, immediately after it's handled. Flushes the persisted
3014
- // cursor forward through the now-longer contiguous settled prefix.
3015
- settle(eventId) {
3016
- this.settled.add(eventId);
3017
- let advanced = null;
3018
- while (this.pending.length > 0 && this.settled.has(this.pending[0])) {
3019
- const id = this.pending.shift();
3020
- this.settled.delete(id);
3021
- advanced = id;
3022
- }
3023
- if (advanced) writeCursor(this.workspaceId, advanced);
3024
- }
3025
- };
3026
-
3027
2937
  // src/dispatch-dedupe.ts
3028
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync5, renameSync as renameSync3, existsSync as existsSync7 } from "fs";
3029
- import { join as join10 } from "path";
2938
+ import { mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync4, renameSync as renameSync3, existsSync as existsSync6 } from "fs";
2939
+ import { join as join9 } from "path";
3030
2940
  var MAX_IDS = 256;
3031
2941
  function dir(log) {
3032
- return join10(cabaneDir(), log);
2942
+ return join9(cabaneDir(), log);
3033
2943
  }
3034
- function pathFor2(log, workspaceId) {
3035
- return join10(dir(log), encodeURIComponent(workspaceId));
2944
+ function pathFor(log, workspaceId) {
2945
+ return join9(dir(log), encodeURIComponent(workspaceId));
3036
2946
  }
3037
2947
  function readIds(log, workspaceId) {
3038
- const path = pathFor2(log, workspaceId);
3039
- if (!existsSync7(path)) return [];
2948
+ const path = pathFor(log, workspaceId);
2949
+ if (!existsSync6(path)) return [];
3040
2950
  try {
3041
- return readFileSync5(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
2951
+ return readFileSync4(path, "utf8").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
3042
2952
  } catch {
3043
2953
  return [];
3044
2954
  }
@@ -3051,8 +2961,8 @@ function mark(log, workspaceId, eventId) {
3051
2961
  if (ids.includes(eventId)) return;
3052
2962
  ids.push(eventId);
3053
2963
  const trimmed = ids.length > MAX_IDS ? ids.slice(-MAX_IDS) : ids;
3054
- mkdirSync7(dir(log), { recursive: true });
3055
- writeFileSync5(pathFor2(log, workspaceId), trimmed.join("\n") + "\n", "utf8");
2964
+ mkdirSync6(dir(log), { recursive: true });
2965
+ writeFileSync4(pathFor(log, workspaceId), trimmed.join("\n") + "\n", "utf8");
3056
2966
  }
3057
2967
  function hasDispatched(workspaceId, eventId) {
3058
2968
  return has("dispatched", workspaceId, eventId);
@@ -3069,17 +2979,17 @@ function markCompleted(workspaceId, eventId) {
3069
2979
  }
3070
2980
  var MAX_RESUME_ATTEMPTS = 3;
3071
2981
  function resumeDir() {
3072
- return join10(cabaneDir(), "resume-attempts");
2982
+ return join9(cabaneDir(), "resume-attempts");
3073
2983
  }
3074
2984
  function resumePathFor(workspaceId) {
3075
- return join10(resumeDir(), encodeURIComponent(workspaceId));
2985
+ return join9(resumeDir(), encodeURIComponent(workspaceId));
3076
2986
  }
3077
2987
  function readResumeCounts(workspaceId) {
3078
2988
  const out = /* @__PURE__ */ new Map();
3079
2989
  const path = resumePathFor(workspaceId);
3080
- if (!existsSync7(path)) return out;
2990
+ if (!existsSync6(path)) return out;
3081
2991
  try {
3082
- for (const line of readFileSync5(path, "utf8").split("\n")) {
2992
+ for (const line of readFileSync4(path, "utf8").split("\n")) {
3083
2993
  const trimmed = line.trim();
3084
2994
  if (!trimmed) continue;
3085
2995
  const tab = trimmed.lastIndexOf(" ");
@@ -3099,8 +3009,8 @@ function bumpResumeAttempt(workspaceId, eventId) {
3099
3009
  counts.set(eventId, next);
3100
3010
  const entries = [...counts.entries()];
3101
3011
  const trimmed = entries.length > MAX_IDS ? entries.slice(-MAX_IDS) : entries;
3102
- mkdirSync7(resumeDir(), { recursive: true });
3103
- writeFileSync5(
3012
+ mkdirSync6(resumeDir(), { recursive: true });
3013
+ writeFileSync4(
3104
3014
  resumePathFor(workspaceId),
3105
3015
  trimmed.map(([id, c]) => `${id} ${c}`).join("\n") + "\n",
3106
3016
  "utf8"
@@ -3108,18 +3018,18 @@ function bumpResumeAttempt(workspaceId, eventId) {
3108
3018
  return next;
3109
3019
  }
3110
3020
  function turnDir() {
3111
- return join10(cabaneDir(), "turns");
3021
+ return join9(cabaneDir(), "turns");
3112
3022
  }
3113
3023
  function turnPathFor(workspaceId) {
3114
- return join10(turnDir(), encodeURIComponent(workspaceId));
3024
+ return join9(turnDir(), encodeURIComponent(workspaceId));
3115
3025
  }
3116
3026
  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;
3117
3027
  function readTurnIds(workspaceId) {
3118
3028
  const out = /* @__PURE__ */ new Map();
3119
3029
  const path = turnPathFor(workspaceId);
3120
- if (!existsSync7(path)) return out;
3030
+ if (!existsSync6(path)) return out;
3121
3031
  try {
3122
- for (const line of readFileSync5(path, "utf8").split("\n")) {
3032
+ for (const line of readFileSync4(path, "utf8").split("\n")) {
3123
3033
  const trimmed = line.trim();
3124
3034
  if (!trimmed) continue;
3125
3035
  const tab = trimmed.lastIndexOf(" ");
@@ -3139,10 +3049,10 @@ function turnIdForEvent(workspaceId, eventId) {
3139
3049
  var TURN_ID_OVERFLOW_WARN = 8192;
3140
3050
  function writeTurnIds(workspaceId, turns) {
3141
3051
  const entries = [...turns.entries()];
3142
- mkdirSync7(turnDir(), { recursive: true });
3052
+ mkdirSync6(turnDir(), { recursive: true });
3143
3053
  const path = turnPathFor(workspaceId);
3144
3054
  const tmp = `${path}.${process.pid}.tmp`;
3145
- writeFileSync5(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
3055
+ writeFileSync4(tmp, entries.map(([id, t]) => `${id} ${t}`).join("\n") + "\n", "utf8");
3146
3056
  renameSync3(tmp, path);
3147
3057
  return entries.length;
3148
3058
  }
@@ -7917,140 +7827,872 @@ var ConnectorHealthStore = class {
7917
7827
  };
7918
7828
 
7919
7829
  // src/dispatcher.ts
7920
- import { existsSync as existsSync11, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7921
- import { join as join15 } from "path";
7830
+ import { randomUUID as randomUUID4 } from "crypto";
7831
+ import { existsSync as existsSync10, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7832
+ import { join as join14 } from "path";
7922
7833
 
7923
- // src/term.ts
7834
+ // src/runner/launch.ts
7924
7835
  import { createInterface } from "readline";
7925
- import { homedir as homedir2 } from "os";
7926
- import pc from "picocolors";
7927
- function tildePath(path) {
7928
- const home = homedir2();
7929
- if (home && path.startsWith(home + "/")) return `~${path.slice(home.length)}`;
7930
- return path;
7931
- }
7932
7836
 
7933
- // src/turn-execution.ts
7934
- import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
7935
- import { existsSync as existsSync10 } from "fs";
7837
+ // src/runner/opencode-lock.ts
7838
+ function opencodeLockKey(serverUrl) {
7839
+ return `opencode:${serverUrl}`;
7840
+ }
7841
+ async function stopRemoteOpencodeSession(serverUrl, sessionId, opts = {}) {
7842
+ const doFetch = opts.fetchImpl ?? fetch;
7843
+ const base = serverUrl.replace(/\/+$/, "");
7844
+ const id = encodeURIComponent(sessionId);
7845
+ try {
7846
+ await doFetch(`${base}/session/${id}/abort`, {
7847
+ method: "POST",
7848
+ signal: AbortSignal.timeout(5e3)
7849
+ });
7850
+ } catch {
7851
+ }
7852
+ const attempts = opts.attempts ?? 20;
7853
+ for (let i = 0; i < attempts; i++) {
7854
+ try {
7855
+ const res = await doFetch(`${base}/session/status`, { signal: AbortSignal.timeout(5e3) });
7856
+ if (res.ok) {
7857
+ const status = await res.json();
7858
+ const state = status[sessionId];
7859
+ if (!state || state.type === "idle") return true;
7860
+ }
7861
+ } catch {
7862
+ }
7863
+ await new Promise((resolve2) => setTimeout(resolve2, opts.pollMs ?? 500));
7864
+ }
7865
+ return false;
7866
+ }
7936
7867
 
7937
- // src/turn-control-tools.ts
7938
- import { randomUUID as randomUUID2 } from "crypto";
7939
- import { z as z13 } from "zod";
7940
- var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
7941
- var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
7942
- var SKIP_TURN_TOOL = "skip_turn";
7943
- var ASK_TOOL = "ask";
7944
- var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
7945
- var WITHDRAW_ASK_TOOL = "withdraw_ask";
7946
- var WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
7947
- var WAKE_ME_TOOL = "wake_me";
7948
- var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
7949
- var CANCEL_WAKE_TOOL = "cancel_wake";
7950
- var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
7951
- var TURN_ACT_TIMEOUT_MS = 45e3;
7952
- function createSkipState() {
7953
- return { skipped: false, reason: null };
7868
+ // src/turn-containment.ts
7869
+ import { execFile, execFileSync, spawn as spawn4 } from "child_process";
7870
+ import { readFileSync as readFileSync5 } from "fs";
7871
+ import { promisify } from "util";
7872
+ var execFileAsync = promisify(execFile);
7873
+ var asContained = (child) => child;
7874
+ var CONTAINMENT_STOP_GRACE_SEC = 5;
7875
+ var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
7876
+ var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
7877
+ function turnScopeUnit(turnId, attempt) {
7878
+ const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
7879
+ return `cabane-turn-${safe3}-${attempt}`;
7954
7880
  }
7955
- function toolError(text) {
7956
- return { isError: true, content: [{ type: "text", text }] };
7881
+ async function reapTurnScopes(turnId) {
7882
+ if (process.platform !== "linux") return { failures: [] };
7883
+ const pattern = `${turnScopeUnit(turnId, 0).replace(/-0$/, "")}-*.scope`;
7884
+ try {
7885
+ await execFileAsync("systemctl", ["--user", "stop", pattern], {
7886
+ timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
7887
+ });
7888
+ return { failures: [] };
7889
+ } catch (err) {
7890
+ const message = err instanceof Error ? err.message : String(err);
7891
+ return classifyStopError(message) === "absent" ? { failures: [] } : { failures: [message.slice(0, 200)] };
7892
+ }
7957
7893
  }
7958
- async function performAct(acts, act) {
7959
- let result;
7894
+ function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
7895
+ if (process.platform !== "linux") return false;
7960
7896
  try {
7961
- result = await acts.api.turnAct(
7962
- acts.workspaceId,
7963
- acts.turnId,
7964
- act,
7965
- AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
7966
- );
7897
+ execFileSync("systemd-run", ["--user", "--scope", "--quiet", "--collect", "--", "/bin/true"], {
7898
+ env,
7899
+ timeout: timeoutMs,
7900
+ stdio: "ignore"
7901
+ });
7902
+ return true;
7903
+ } catch {
7904
+ return false;
7905
+ }
7906
+ }
7907
+ function classifyStopError(message) {
7908
+ if (/failed to connect to bus|failed to get d-?bus connection|connection refused|refusing to operate|spawn systemctl|systemctl.*ENOENT|ENOENT.*systemctl/i.test(
7909
+ message
7910
+ )) {
7911
+ return "failure";
7912
+ }
7913
+ if (/not loaded|could not be found|no such unit|unit .* not found/i.test(message))
7914
+ return "absent";
7915
+ return "failure";
7916
+ }
7917
+ function currentSystemdUnit(cgroupText) {
7918
+ for (const line of cgroupText.split("\n")) {
7919
+ const path = line.trim().split(":").pop();
7920
+ if (!path?.startsWith("/")) continue;
7921
+ const leaf = path.split("/").filter(Boolean).pop();
7922
+ if (leaf && (leaf.endsWith(".service") || leaf.endsWith(".scope"))) return leaf;
7923
+ }
7924
+ return null;
7925
+ }
7926
+ function readCurrentSystemdUnit() {
7927
+ try {
7928
+ return currentSystemdUnit(readFileSync5("/proc/self/cgroup", "utf8"));
7929
+ } catch {
7930
+ return null;
7931
+ }
7932
+ }
7933
+ function processGroupMembers(pgid) {
7934
+ try {
7935
+ const out = execFileSync("ps", ["-eo", "pid=,pgid="], { timeout: 5e3 }).toString();
7936
+ return {
7937
+ ok: true,
7938
+ members: out.split("\n").map((line) => /^\s*(\d+)\s+(\d+)\s*$/.exec(line)).filter((m) => m !== null).filter((m) => Number(m[2]) === pgid && Number(m[1]) !== process.pid).map((m) => Number(m[1]))
7939
+ };
7967
7940
  } catch (err) {
7968
- if (err instanceof ApiError && err.status >= 500) {
7969
- return uncertainOutcome(`${err.status}: ${apiErrorMessage(err)}`);
7941
+ return { ok: false, error: (err instanceof Error ? err.message : String(err)).slice(0, 150) };
7942
+ }
7943
+ }
7944
+ var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
7945
+ var MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
7946
+ var managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
7947
+ function createTurnContainment(turnId, deps = {}) {
7948
+ const probe = deps.capabilityProbe ?? scopeCapabilityUsable;
7949
+ const fallbackGraceMs = deps.fallbackGraceMs ?? FALLBACK_GRACE_MS;
7950
+ const ownUnit = (deps.ownSystemdUnit ?? readCurrentSystemdUnit)();
7951
+ const scopeUnits = [];
7952
+ const groups = [];
7953
+ let kind = null;
7954
+ let probedEnvKey = null;
7955
+ let attempt = 0;
7956
+ function scopeArgs(unit) {
7957
+ return [
7958
+ "--user",
7959
+ "--scope",
7960
+ "--quiet",
7961
+ "--collect",
7962
+ `--unit=${unit}`,
7963
+ `--property=TimeoutStopSec=${CONTAINMENT_STOP_GRACE_SEC}s`,
7964
+ // Bind the scope's life to the companion's own unit, so stopping or
7965
+ // restarting the companion still takes its in-flight turns with it —
7966
+ // which is what happens today, and what the scope would otherwise undo.
7967
+ // Omitted outside a unit, where there is nothing to bind to.
7968
+ ...ownUnit ? [`--property=PartOf=${ownUnit}`] : [],
7969
+ "--"
7970
+ ];
7971
+ }
7972
+ function resolveKind(env) {
7973
+ const envKey = managerEnvKey(env);
7974
+ if (kind === null || envKey !== probedEnvKey) {
7975
+ kind = probe(env) ? "systemd-scope" : "process-group";
7976
+ probedEnvKey = envKey;
7977
+ deps.log?.("containment: resolved strategy for this turn", {
7978
+ kind,
7979
+ platform: process.platform
7980
+ });
7970
7981
  }
7971
- if (err instanceof ApiError) {
7972
- if (err.status === 409 && apiErrorCode(err) === "no_active_turn") {
7973
- return toolError(
7974
- "No active turn: this turn has already settled, so the act was not performed."
7982
+ return kind;
7983
+ }
7984
+ function spawnInScope(options) {
7985
+ const unit = turnScopeUnit(turnId, attempt++);
7986
+ scopeUnits.push({ unit, env: options.env });
7987
+ deps.log?.("containment: spawning the turn CLI into a systemd scope", {
7988
+ unit,
7989
+ partOf: ownUnit,
7990
+ command: options.command
7991
+ });
7992
+ return asContained(
7993
+ spawn4("systemd-run", [...scopeArgs(unit), options.command, ...options.args], {
7994
+ ...options.cwd ? { cwd: options.cwd } : {},
7995
+ env: options.env,
7996
+ stdio: ["pipe", "pipe", "pipe"]
7997
+ })
7998
+ );
7999
+ }
8000
+ function spawnInGroup(options) {
8001
+ const child = spawn4(options.command, options.args, {
8002
+ ...options.cwd ? { cwd: options.cwd } : {},
8003
+ env: options.env,
8004
+ stdio: ["pipe", "pipe", "pipe"],
8005
+ detached: process.platform !== "win32"
8006
+ });
8007
+ if (child.pid !== void 0) {
8008
+ groups.push({ pgid: child.pid, child });
8009
+ deps.onProcessGroup?.(child.pid);
8010
+ }
8011
+ deps.log?.("containment: no host-owned containment here \u2014 best effort only", {
8012
+ platform: process.platform,
8013
+ pid: child.pid
8014
+ });
8015
+ return asContained(child);
8016
+ }
8017
+ async function reapScopes(result) {
8018
+ for (const { unit, env } of scopeUnits) {
8019
+ try {
8020
+ await execFileAsync("systemctl", ["--user", "stop", `${unit}.scope`], {
8021
+ env,
8022
+ timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
8023
+ });
8024
+ } catch (err) {
8025
+ const message = err instanceof Error ? err.message : String(err);
8026
+ if (classifyStopError(message) === "failure") {
8027
+ result.failures.push({ target: unit, error: message.slice(0, 200) });
8028
+ continue;
8029
+ }
8030
+ result.reaped.push(unit);
8031
+ continue;
8032
+ }
8033
+ try {
8034
+ const { stdout } = await execFileAsync(
8035
+ "systemctl",
8036
+ ["--user", "show", `${unit}.scope`, "--property=ActiveState", "--value"],
8037
+ { env, timeout: 1e4 }
7975
8038
  );
8039
+ const state = stdout.trim();
8040
+ if (state === "" || state === "inactive" || state === "failed") result.reaped.push(unit);
8041
+ else result.failures.push({ target: unit, error: `scope still ${state} after stop` });
8042
+ } catch (err) {
8043
+ result.failures.push({
8044
+ target: unit,
8045
+ error: `could not confirm the scope stopped: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`
8046
+ });
7976
8047
  }
7977
- return toolError(`The act was refused (${err.status}): ${apiErrorMessage(err)}`);
7978
8048
  }
7979
- return uncertainOutcome(err instanceof Error ? err.message : String(err));
7980
8049
  }
7981
- if (!result.ok) return toolError(JSON.stringify(result));
7982
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
7983
- }
7984
- function uncertainOutcome(cause) {
7985
- return toolError(
7986
- `The outcome of this act could not be confirmed (${cause}). It may already have been performed, with only the response lost. Do NOT simply repeat the call: a repeat is a second, separate act, not a retry. Carry on, and say in your reply that you could not confirm it.`
7987
- );
7988
- }
7989
- function createTurnControlMcpServer(acts, skipState) {
7990
- const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
7991
- const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
7992
- const RECORDER_TOOL = { readOnlyHint: true, openWorldHint: false };
7993
- return createSdkMcpServer({
7994
- name: COMPANION_LOCAL_MCP_SERVER,
7995
- version: "0.0.0",
7996
- tools: [
7997
- ...skipState ? [
7998
- tool(
7999
- SKIP_TURN_TOOL,
8000
- TURN_CONTROL_TOOL_DESCRIPTIONS.skip_turn,
8001
- {
8002
- reason: z13.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
8003
- },
8004
- async (args) => {
8005
- skipState.skipped = true;
8006
- skipState.reason = args.reason;
8007
- return {
8008
- content: [{ type: "text", text: JSON.stringify({ skipped: true }) }]
8009
- };
8010
- },
8011
- { annotations: RECORDER_TOOL, alwaysLoad: true }
8012
- )
8013
- ] : [],
8014
- tool(
8015
- ASK_TOOL,
8016
- TURN_CONTROL_TOOL_DESCRIPTIONS.ask,
8017
- {
8018
- targetUserId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
8019
- question: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
8020
- headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
8021
- options: z13.array(
8022
- z13.union([
8023
- z13.string().min(1).max(MAX_ASK_OPTION_BYTES),
8024
- z13.object({
8025
- label: z13.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
8026
- move: z13.object({
8027
- task: z13.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
8028
- status: z13.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
8050
+ async function reapGroups(result) {
8051
+ for (const { pgid, child } of groups) {
8052
+ const target = String(pgid);
8053
+ if (process.platform === "win32") {
8054
+ try {
8055
+ if (child.exitCode === null) child.kill();
8056
+ result.unverified.push(target);
8057
+ } catch (err) {
8058
+ result.failures.push({
8059
+ target,
8060
+ error: (err instanceof Error ? err.message : String(err)).slice(0, 200)
8061
+ });
8062
+ }
8063
+ continue;
8064
+ }
8065
+ const first = processGroupMembers(pgid);
8066
+ if (!first.ok) {
8067
+ result.failures.push({
8068
+ target,
8069
+ error: `could not enumerate the process group: ${first.error}`
8070
+ });
8071
+ continue;
8072
+ }
8073
+ if (first.members.length === 0) {
8074
+ result.reaped.push(target);
8075
+ continue;
8076
+ }
8077
+ for (const pid of first.members) {
8078
+ try {
8079
+ process.kill(pid, "SIGTERM");
8080
+ } catch {
8081
+ }
8082
+ }
8083
+ await sleep2(fallbackGraceMs);
8084
+ const afterTerm = processGroupMembers(pgid);
8085
+ if (!afterTerm.ok) {
8086
+ result.failures.push({
8087
+ target,
8088
+ error: `could not re-enumerate the process group after SIGTERM: ${afterTerm.error}`
8089
+ });
8090
+ continue;
8091
+ }
8092
+ for (const pid of afterTerm.members) {
8093
+ try {
8094
+ process.kill(pid, "SIGKILL");
8095
+ } catch {
8096
+ }
8097
+ }
8098
+ const afterKill = processGroupMembers(pgid);
8099
+ if (!afterKill.ok) {
8100
+ result.failures.push({
8101
+ target,
8102
+ error: `could not confirm the process group is empty after SIGKILL: ${afterKill.error}`
8103
+ });
8104
+ continue;
8105
+ }
8106
+ if (afterKill.members.length)
8107
+ result.failures.push({
8108
+ target,
8109
+ error: `${afterKill.members.length} process(es) still in the group after SIGKILL`
8110
+ });
8111
+ else result.reaped.push(target);
8112
+ }
8113
+ }
8114
+ return {
8115
+ get kind() {
8116
+ return kind;
8117
+ },
8118
+ spawn(options) {
8119
+ return resolveKind(options.env) === "systemd-scope" ? spawnInScope(options) : spawnInGroup(options);
8120
+ },
8121
+ scopePrefix(env) {
8122
+ if (resolveKind(env) !== "systemd-scope") return null;
8123
+ const unit = turnScopeUnit(turnId, attempt++);
8124
+ scopeUnits.push({ unit, env });
8125
+ deps.log?.("containment: a harness will start in a systemd scope", { unit });
8126
+ return ["systemd-run", ...scopeArgs(unit)];
8127
+ },
8128
+ async reap() {
8129
+ const result = {
8130
+ kind: kind ?? "unused",
8131
+ reaped: [],
8132
+ failures: [],
8133
+ unverified: []
8134
+ };
8135
+ await reapScopes(result);
8136
+ await reapGroups(result);
8137
+ deps.log?.("containment: reap complete", {
8138
+ kind: result.kind,
8139
+ reaped: result.reaped,
8140
+ failures: result.failures
8141
+ });
8142
+ return result;
8143
+ }
8144
+ };
8145
+ }
8146
+
8147
+ // src/runner/protocol.ts
8148
+ import { z as z14 } from "zod";
8149
+
8150
+ // src/run-config.ts
8151
+ import { z as z13 } from "zod";
8152
+ var mcpStdioServerSchema = z13.object({
8153
+ type: z13.literal("stdio").optional(),
8154
+ command: z13.string().min(1),
8155
+ args: z13.array(z13.string()).optional(),
8156
+ env: z13.record(z13.string(), z13.string()).optional()
8157
+ });
8158
+ var mcpHttpServerSchema = z13.object({
8159
+ type: z13.literal("http"),
8160
+ url: z13.string().url(),
8161
+ headers: z13.record(z13.string(), z13.string()).optional()
8162
+ });
8163
+ var mcpSseServerSchema = z13.object({
8164
+ type: z13.literal("sse"),
8165
+ url: z13.string().url(),
8166
+ headers: z13.record(z13.string(), z13.string()).optional()
8167
+ });
8168
+ var mcpServerDefSchema = z13.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
8169
+ var thinkingConfigSchema = z13.discriminatedUnion("type", [
8170
+ z13.object({ type: z13.literal("adaptive") }),
8171
+ z13.object({ type: z13.literal("enabled"), budgetTokens: z13.number().int().positive().optional() }),
8172
+ z13.object({ type: z13.literal("disabled") })
8173
+ ]);
8174
+ var effortSchema = z13.enum(["low", "medium", "high", "xhigh", "max"]);
8175
+ var runConfigSchema = z13.object({
8176
+ // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
8177
+ // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
8178
+ // is the locked surface. Kept in lockstep with `@cabane/shared`'s
8179
+ // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
8180
+ // stripped, so an older companion riding a newer server never rejects the config).
8181
+ hostAccess: z13.boolean().optional(),
8182
+ mcpServers: z13.record(z13.string(), mcpServerDefSchema).optional(),
8183
+ model: z13.string().min(1).optional(),
8184
+ thinking: thinkingConfigSchema.optional(),
8185
+ effort: effortSchema.optional()
8186
+ });
8187
+ var EMPTY_RUN_CONFIG = {};
8188
+ function parseRunConfig(raw, onWarn) {
8189
+ const result = runConfigSchema.safeParse(raw ?? {});
8190
+ if (result.success) return result.data;
8191
+ onWarn?.(
8192
+ `run-config from Cabane didn't parse (${result.error.issues.map((i) => i.path.join(".") || "(root)").join(", ")}) \u2014 falling back to the locked assistant default`
8193
+ );
8194
+ return EMPTY_RUN_CONFIG;
8195
+ }
8196
+
8197
+ // src/runner/protocol.ts
8198
+ var RUNNER_ENV = {
8199
+ // The wire the runner speaks.
8200
+ url: "CABANE_URL",
8201
+ // The dispatch as the device stream delivered it.
8202
+ dispatch: "CABANE_DISPATCH",
8203
+ // The turn id the launcher resolved, and whether this is a replay of a turn
8204
+ // an earlier process started. The seq floor for a replay is derived inside
8205
+ // the turn from the outbox and the server, as it always was.
8206
+ turnId: "CABANE_TURN_ID",
8207
+ resumed: "CABANE_RESUMED",
8208
+ // Everything else the turn is constructed from, as JSON: see `RunnerSpec`.
8209
+ spec: "CABANE_RUNNER_SPEC"
8210
+ };
8211
+ var runtimeHealthSchema = z14.object({
8212
+ quotaState: z14.enum(["ok", "limited", "auth_expired", "unknown"]),
8213
+ limitedUntil: z14.string().nullable(),
8214
+ lastError: z14.string().nullable()
8215
+ });
8216
+ var runnerSpecSchema = z14.object({
8217
+ deviceId: z14.string().optional(),
8218
+ // The launcher's boot id: the one its heartbeat carries, so a runner's lease
8219
+ // matches the device that holds it.
8220
+ bootId: z14.string(),
8221
+ workspaceId: z14.string(),
8222
+ workspaceSlug: z14.string(),
8223
+ agentId: z14.string(),
8224
+ agentUsername: z14.string(),
8225
+ agentName: z14.string(),
8226
+ local: localAgentConfigSchema,
8227
+ runConfig: runConfigSchema,
8228
+ opencodeServerUrl: z14.string().optional(),
8229
+ claudeCodeAvailable: z14.boolean(),
8230
+ codexAvailable: z14.boolean(),
8231
+ idleTimeoutMs: z14.number().int().positive().optional(),
8232
+ totalTimeoutMs: z14.number().int().positive().optional(),
8233
+ leaseRenewalMs: z14.number().int().positive().optional(),
8234
+ preparingRowDelayMs: z14.number().int().nonnegative().optional(),
8235
+ // Whether the turn writes a transcript. The directory is the runner's own
8236
+ // (`transcriptsDir()`), so `CABANE_RUNNER_STATE_DIR` moves it with the outbox.
8237
+ transcripts: z14.boolean(),
8238
+ // Whether the harness starts under the OS sandbox. The launcher sends 'os'
8239
+ // only when this host can make one.
8240
+ sandbox: z14.enum(["os", "none"]),
8241
+ logLevel: z14.string(),
8242
+ // The launcher's connector-health observations, by runtime, as of the start.
8243
+ connectorHealth: z14.record(z14.string(), runtimeHealthSchema),
8244
+ // True when this (conversation, agent) pair was already told its session
8245
+ // could not be saved: the notice is once per pair, not once per process.
8246
+ sessionWriteNoticeClaimed: z14.boolean()
8247
+ });
8248
+ var dispatchSchema = z14.object({
8249
+ type: z14.literal("device:dispatch_requested"),
8250
+ agentId: z14.string(),
8251
+ deviceId: z14.string(),
8252
+ conversationId: z14.string(),
8253
+ messageId: z14.string(),
8254
+ episode: z14.number().int().optional()
8255
+ });
8256
+ var credentialsSchema = z14.object({
8257
+ agentToken: z14.string().min(1),
8258
+ deviceToken: z14.string().min(1).optional()
8259
+ });
8260
+ function runnerEnv(input) {
8261
+ return {
8262
+ [RUNNER_ENV.url]: input.baseUrl,
8263
+ [RUNNER_ENV.dispatch]: JSON.stringify(input.dispatch),
8264
+ [RUNNER_ENV.turnId]: input.turnId,
8265
+ ...input.resumed ? { [RUNNER_ENV.resumed]: "1" } : {},
8266
+ [RUNNER_ENV.spec]: JSON.stringify(input.spec)
8267
+ };
8268
+ }
8269
+ function runnerStdin(input) {
8270
+ return `${JSON.stringify({
8271
+ // Phase 1 (CT1680) replaces the agent PAT with a runner credential bound to
8272
+ // this one dispatch; until then the child holds what the in-process turn
8273
+ // held, on the same machine.
8274
+ agentToken: input.agentToken,
8275
+ ...input.deviceToken ? { deviceToken: input.deviceToken } : {}
8276
+ })}
8277
+ `;
8278
+ }
8279
+ function encodeLauncherMessage(message) {
8280
+ return `${JSON.stringify(message)}
8281
+ `;
8282
+ }
8283
+ function decodeRunnerEvent(line) {
8284
+ if (!line.startsWith("{")) return null;
8285
+ let value;
8286
+ try {
8287
+ value = JSON.parse(line);
8288
+ } catch {
8289
+ return null;
8290
+ }
8291
+ if (!value || typeof value !== "object" || !("cabaneRunner" in value)) return null;
8292
+ return value;
8293
+ }
8294
+
8295
+ // src/runner/launch.ts
8296
+ function selfRunnerCommand() {
8297
+ const script = process.argv[1];
8298
+ if (!script) throw new Error("runners: cannot tell which script started this companion");
8299
+ return [process.execPath, ...process.execArgv, script, "run-turn"];
8300
+ }
8301
+ function healthSnapshot(store) {
8302
+ const out = {};
8303
+ for (const r of store?.reports() ?? []) {
8304
+ out[r.runtime] = {
8305
+ quotaState: r.quotaState ?? "unknown",
8306
+ limitedUntil: r.limitedUntil ?? null,
8307
+ lastError: r.lastError ?? null
8308
+ };
8309
+ }
8310
+ return out;
8311
+ }
8312
+ function runnerSpec(opts, launch, duties) {
8313
+ return {
8314
+ ...opts.deviceId ? { deviceId: opts.deviceId } : {},
8315
+ bootId,
8316
+ workspaceId: opts.workspaceId,
8317
+ workspaceSlug: opts.workspaceSlug,
8318
+ agentId: opts.agentId,
8319
+ agentUsername: opts.agentUsername,
8320
+ agentName: String(opts.log.bindings().agentName ?? opts.agentUsername),
8321
+ local: opts.local,
8322
+ runConfig: opts.runConfig,
8323
+ ...opts.opencodeServerUrl ? { opencodeServerUrl: opts.opencodeServerUrl } : {},
8324
+ // Read now, per turn, as the in-process path reads them at adapter selection.
8325
+ claudeCodeAvailable: opts.claudeCodeAvailable?.() ?? true,
8326
+ codexAvailable: opts.codexAvailable?.() ?? false,
8327
+ ...opts.idleTimeoutMs !== void 0 ? { idleTimeoutMs: opts.idleTimeoutMs } : {},
8328
+ ...opts.totalTimeoutMs !== void 0 ? { totalTimeoutMs: opts.totalTimeoutMs } : {},
8329
+ ...opts.leaseRenewalMs !== void 0 ? { leaseRenewalMs: opts.leaseRenewalMs } : {},
8330
+ ...opts.preparingRowDelayMs !== void 0 ? { preparingRowDelayMs: opts.preparingRowDelayMs } : {},
8331
+ transcripts: opts.transcriptDir !== void 0,
8332
+ sandbox: launch.sandbox ?? "none",
8333
+ logLevel: opts.log.level,
8334
+ connectorHealth: healthSnapshot(launch.connectorHealth),
8335
+ sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
8336
+ };
8337
+ }
8338
+ var PINO_LEVELS = {
8339
+ 10: "trace",
8340
+ 20: "debug",
8341
+ 30: "info",
8342
+ 40: "warn",
8343
+ 50: "error",
8344
+ 60: "fatal"
8345
+ };
8346
+ function relayRunnerLog(line, log) {
8347
+ if (!line.trim()) return;
8348
+ let record = null;
8349
+ try {
8350
+ const parsed = JSON.parse(line);
8351
+ if (parsed && typeof parsed === "object") record = parsed;
8352
+ } catch {
8353
+ record = null;
8354
+ }
8355
+ if (!record || typeof record.level !== "number") {
8356
+ log.debug({ runnerStderr: line }, "runner: output");
8357
+ return;
8358
+ }
8359
+ const {
8360
+ level,
8361
+ msg,
8362
+ time: _time,
8363
+ pid: _pid,
8364
+ hostname: _hostname,
8365
+ agentName: _agentName,
8366
+ ...fields
8367
+ } = record;
8368
+ const method = PINO_LEVELS[level] ?? "info";
8369
+ log[method](fields, typeof msg === "string" ? msg : "");
8370
+ }
8371
+ function lines(stream, onLine) {
8372
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
8373
+ rl.on("line", onLine);
8374
+ return new Promise((resolve2) => rl.once("close", resolve2));
8375
+ }
8376
+ function giveBackLocks(locks, prefix, remote, launch, log) {
8377
+ locks.withdraw(prefix);
8378
+ const stop = launch.stopRemoteSession ?? ((url, id) => stopRemoteOpencodeSession(url, id));
8379
+ for (const { key, owner } of locks.heldBy(prefix)) {
8380
+ const left = remote.filter((r) => opencodeLockKey(r.serverUrl) === key);
8381
+ if (!left.length) {
8382
+ locks.release(owner);
8383
+ continue;
8384
+ }
8385
+ void (async () => {
8386
+ for (let attempt = 1; ; attempt++) {
8387
+ const stopped = await Promise.all(left.map((r) => stop(r.serverUrl, r.sessionId))).catch(
8388
+ () => [false]
8389
+ );
8390
+ if (stopped.every(Boolean)) break;
8391
+ if (attempt === 1)
8392
+ log.warn(
8393
+ { key, sessions: left.map((r) => r.sessionId) },
8394
+ "runner: a dead runner left work on a shared server that could not be confirmed stopped; holding the server until it is"
8395
+ );
8396
+ await new Promise((resolve2) => setTimeout(resolve2, launch.reconcileRetryMs ?? 5e3));
8397
+ }
8398
+ locks.release(owner);
8399
+ })();
8400
+ }
8401
+ }
8402
+ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8403
+ const startedAt = Date.now();
8404
+ const { turnId } = handleOpts;
8405
+ const log = opts.log.child({
8406
+ workspaceId: opts.workspaceId,
8407
+ conversationId: payload.conversationId,
8408
+ agentId: payload.agentId,
8409
+ messageId: payload.messageId
8410
+ });
8411
+ const controller = new AbortController();
8412
+ duties.registerAbort(turnId, controller);
8413
+ const stopRunner = new AbortController();
8414
+ let ready = false;
8415
+ const forwardStop = () => {
8416
+ if (ready) stopRunner.abort();
8417
+ };
8418
+ controller.signal.addEventListener("abort", forwardStop, { once: true });
8419
+ const attempt = {
8420
+ workspaceId: opts.workspaceId,
8421
+ conversationId: payload.conversationId,
8422
+ agentId: payload.agentId,
8423
+ turnId
8424
+ };
8425
+ let result;
8426
+ let thrown;
8427
+ let code;
8428
+ let handle;
8429
+ let child;
8430
+ const groups = [];
8431
+ const remote = [];
8432
+ const lockOwner = (id) => `${turnId}#${id}`;
8433
+ const locks = launch.locks;
8434
+ try {
8435
+ handle = await launch.provider.acquire(attempt);
8436
+ const env = {};
8437
+ for (const [k, v] of Object.entries(launch.env ?? process.env)) {
8438
+ if (v !== void 0) env[k] = v;
8439
+ }
8440
+ const input = {
8441
+ baseUrl: opts.baseUrl,
8442
+ agentToken: opts.credential,
8443
+ ...launch.deviceToken ? { deviceToken: launch.deviceToken } : {},
8444
+ dispatch: payload,
8445
+ turnId,
8446
+ resumed: handleOpts.resumed,
8447
+ spec: runnerSpec(opts, launch, duties)
8448
+ };
8449
+ Object.assign(env, runnerEnv(input));
8450
+ const running = launch.provider.exec(handle, launch.command, env, {
8451
+ signal: stopRunner.signal,
8452
+ stdin: runnerStdin(input)
8453
+ });
8454
+ child = running;
8455
+ log.debug(
8456
+ { turnId, runnerPid: running.pid, provider: launch.provider.name },
8457
+ "runner: started"
8458
+ );
8459
+ const stdoutDone = lines(running.stdout, (line) => {
8460
+ const event = decodeRunnerEvent(line);
8461
+ if (!event) {
8462
+ log.debug({ runnerStdout: line }, "runner: output");
8463
+ return;
8464
+ }
8465
+ switch (event.cabaneRunner) {
8466
+ case "ready":
8467
+ ready = true;
8468
+ if (controller.signal.aborted) stopRunner.abort();
8469
+ return;
8470
+ case "start":
8471
+ duties.notifyStart({
8472
+ id: event.id,
8473
+ conversationId: event.conversationId,
8474
+ message: event.message
8475
+ });
8476
+ return;
8477
+ case "end": {
8478
+ const { cabaneRunner: _kind, ...info } = event;
8479
+ duties.notifyEnd(info);
8480
+ return;
8481
+ }
8482
+ case "session_write_notice":
8483
+ duties.markSessionWriteNotice();
8484
+ return;
8485
+ case "connector_health":
8486
+ launch.connectorHealth?.recordSettle(event.runtime, {
8487
+ ok: event.ok,
8488
+ errorReason: event.errorReason
8489
+ });
8490
+ return;
8491
+ case "result":
8492
+ result = event.result;
8493
+ return;
8494
+ case "error":
8495
+ thrown = event.message;
8496
+ return;
8497
+ case "lock":
8498
+ if (!locks) {
8499
+ log.warn(
8500
+ { key: event.key },
8501
+ "runner: asked for a device lock this launcher has none of"
8502
+ );
8503
+ return;
8504
+ }
8505
+ locks.request(
8506
+ event.key,
8507
+ lockOwner(event.id),
8508
+ () => running.stdin.write(encodeLauncherMessage({ cabaneLauncher: "granted", id: event.id }))
8509
+ );
8510
+ return;
8511
+ case "unlock":
8512
+ locks?.release(lockOwner(event.id));
8513
+ return;
8514
+ case "process_group":
8515
+ groups.push(event.pgid);
8516
+ return;
8517
+ case "remote_session":
8518
+ remote.push({ serverUrl: event.serverUrl, sessionId: event.sessionId });
8519
+ return;
8520
+ }
8521
+ });
8522
+ const stderrDone = lines(running.stderr, (line) => relayRunnerLog(line, opts.log));
8523
+ code = await running.exit;
8524
+ await Promise.all([stdoutDone, stderrDone]);
8525
+ } finally {
8526
+ if (locks) giveBackLocks(locks, `${turnId}#`, remote, launch, log);
8527
+ child?.stdin.end();
8528
+ controller.signal.removeEventListener("abort", forwardStop);
8529
+ duties.releaseAbort(turnId, controller);
8530
+ if (handle) await launch.provider.release(handle, { destroy: false });
8531
+ }
8532
+ if (result) return result;
8533
+ const durationMs = Date.now() - startedAt;
8534
+ const reason = thrown !== void 0 ? `runner_failed: ${thrown}` : `runner_exited: ${code}`;
8535
+ await child?.reap(groups);
8536
+ const reaped = await (launch.reapTurn ?? reapTurnScopes)(turnId).catch((err) => ({
8537
+ failures: [err instanceof Error ? err.message : String(err)]
8538
+ }));
8539
+ if (reaped.failures.length) {
8540
+ log.warn(
8541
+ { turnId, failures: reaped.failures },
8542
+ "runner: couldn't stop what the dead runner's turn left running"
8543
+ );
8544
+ }
8545
+ try {
8546
+ await opts.api.setActiveRun(opts.workspaceId, payload.conversationId, payload.agentId, {
8547
+ activeRunStartedAt: null,
8548
+ turnId,
8549
+ outcome: "failed",
8550
+ errorReason: reason.slice(0, 200),
8551
+ readThrough: payload.messageId
8552
+ });
8553
+ } catch (err) {
8554
+ log.warn(
8555
+ { ...apiErrorLogFields(err), turnId },
8556
+ "Couldn't update the turn's status in Cabane. Check the conversation before trying again."
8557
+ );
8558
+ }
8559
+ if (thrown !== void 0) throw new Error(thrown);
8560
+ log.warn({ turnId, code }, "runner: exited without reporting a result");
8561
+ duties.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
8562
+ return { ok: false, durationMs, reason };
8563
+ }
8564
+
8565
+ // src/term.ts
8566
+ import { createInterface as createInterface2 } from "readline";
8567
+ import { homedir as homedir2 } from "os";
8568
+ import pc from "picocolors";
8569
+ function tildePath(path) {
8570
+ const home = homedir2();
8571
+ if (home && path.startsWith(home + "/")) return `~${path.slice(home.length)}`;
8572
+ return path;
8573
+ }
8574
+
8575
+ // src/turn-execution.ts
8576
+ import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
8577
+ import { existsSync as existsSync9 } from "fs";
8578
+
8579
+ // src/turn-control-tools.ts
8580
+ import { randomUUID as randomUUID2 } from "crypto";
8581
+ import { z as z15 } from "zod";
8582
+ var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
8583
+ var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
8584
+ var SKIP_TURN_TOOL = "skip_turn";
8585
+ var ASK_TOOL = "ask";
8586
+ var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
8587
+ var WITHDRAW_ASK_TOOL = "withdraw_ask";
8588
+ var WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
8589
+ var WAKE_ME_TOOL = "wake_me";
8590
+ var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
8591
+ var CANCEL_WAKE_TOOL = "cancel_wake";
8592
+ var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
8593
+ var TURN_ACT_TIMEOUT_MS = 45e3;
8594
+ function createSkipState() {
8595
+ return { skipped: false, reason: null };
8596
+ }
8597
+ function toolError(text) {
8598
+ return { isError: true, content: [{ type: "text", text }] };
8599
+ }
8600
+ async function performAct(acts, act) {
8601
+ let result;
8602
+ try {
8603
+ result = await acts.api.turnAct(
8604
+ acts.workspaceId,
8605
+ acts.turnId,
8606
+ act,
8607
+ AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
8608
+ );
8609
+ } catch (err) {
8610
+ if (err instanceof ApiError && err.status >= 500) {
8611
+ return uncertainOutcome(`${err.status}: ${apiErrorMessage(err)}`);
8612
+ }
8613
+ if (err instanceof ApiError) {
8614
+ if (err.status === 409 && apiErrorCode(err) === "no_active_turn") {
8615
+ return toolError(
8616
+ "No active turn: this turn has already settled, so the act was not performed."
8617
+ );
8618
+ }
8619
+ return toolError(`The act was refused (${err.status}): ${apiErrorMessage(err)}`);
8620
+ }
8621
+ return uncertainOutcome(err instanceof Error ? err.message : String(err));
8622
+ }
8623
+ if (!result.ok) return toolError(JSON.stringify(result));
8624
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
8625
+ }
8626
+ function uncertainOutcome(cause) {
8627
+ return toolError(
8628
+ `The outcome of this act could not be confirmed (${cause}). It may already have been performed, with only the response lost. Do NOT simply repeat the call: a repeat is a second, separate act, not a retry. Carry on, and say in your reply that you could not confirm it.`
8629
+ );
8630
+ }
8631
+ function createTurnControlMcpServer(acts, skipState) {
8632
+ const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
8633
+ const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
8634
+ const RECORDER_TOOL = { readOnlyHint: true, openWorldHint: false };
8635
+ return createSdkMcpServer({
8636
+ name: COMPANION_LOCAL_MCP_SERVER,
8637
+ version: "0.0.0",
8638
+ tools: [
8639
+ ...skipState ? [
8640
+ tool(
8641
+ SKIP_TURN_TOOL,
8642
+ TURN_CONTROL_TOOL_DESCRIPTIONS.skip_turn,
8643
+ {
8644
+ reason: z15.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
8645
+ },
8646
+ async (args) => {
8647
+ skipState.skipped = true;
8648
+ skipState.reason = args.reason;
8649
+ return {
8650
+ content: [{ type: "text", text: JSON.stringify({ skipped: true }) }]
8651
+ };
8652
+ },
8653
+ { annotations: RECORDER_TOOL, alwaysLoad: true }
8654
+ )
8655
+ ] : [],
8656
+ tool(
8657
+ ASK_TOOL,
8658
+ TURN_CONTROL_TOOL_DESCRIPTIONS.ask,
8659
+ {
8660
+ targetUserId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
8661
+ question: z15.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
8662
+ headline: z15.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
8663
+ options: z15.array(
8664
+ z15.union([
8665
+ z15.string().min(1).max(MAX_ASK_OPTION_BYTES),
8666
+ z15.object({
8667
+ label: z15.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
8668
+ move: z15.object({
8669
+ task: z15.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
8670
+ status: z15.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
8029
8671
  }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
8030
8672
  })
8031
8673
  ])
8032
8674
  ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
8033
- questions: z13.array(
8034
- z13.object({
8035
- headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
8036
- body: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
8037
- options: z13.array(
8038
- z13.union([
8039
- z13.string().min(1).max(MAX_ASK_OPTION_BYTES),
8040
- z13.object({
8041
- label: z13.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
8042
- move: z13.object({
8043
- task: z13.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
8044
- status: z13.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
8675
+ questions: z15.array(
8676
+ z15.object({
8677
+ headline: z15.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
8678
+ body: z15.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
8679
+ options: z15.array(
8680
+ z15.union([
8681
+ z15.string().min(1).max(MAX_ASK_OPTION_BYTES),
8682
+ z15.object({
8683
+ label: z15.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
8684
+ move: z15.object({
8685
+ task: z15.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
8686
+ status: z15.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
8045
8687
  }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
8046
8688
  })
8047
8689
  ])
8048
8690
  ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
8049
8691
  })
8050
8692
  ).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
8051
- replaces: z13.object({
8052
- askId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
8053
- reason: z13.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
8693
+ replaces: z15.object({
8694
+ askId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
8695
+ reason: z15.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
8054
8696
  }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.replaces)
8055
8697
  },
8056
8698
  async (args) => {
@@ -8091,9 +8733,9 @@ function createTurnControlMcpServer(acts, skipState) {
8091
8733
  WAKE_ME_TOOL,
8092
8734
  TURN_CONTROL_TOOL_DESCRIPTIONS.wake_me,
8093
8735
  {
8094
- afterSeconds: z13.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
8095
- at: z13.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
8096
- note: z13.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
8736
+ afterSeconds: z15.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
8737
+ at: z15.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
8738
+ note: z15.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
8097
8739
  },
8098
8740
  async (args) => {
8099
8741
  const hasAfter = args.afterSeconds !== void 0;
@@ -8127,8 +8769,8 @@ function createTurnControlMcpServer(acts, skipState) {
8127
8769
  WITHDRAW_ASK_TOOL,
8128
8770
  TURN_CONTROL_TOOL_DESCRIPTIONS.withdraw_ask,
8129
8771
  {
8130
- askId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
8131
- reason: z13.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
8772
+ askId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
8773
+ reason: z15.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
8132
8774
  },
8133
8775
  async (args) => performAct(acts, {
8134
8776
  kind: "withdraw_ask",
@@ -8168,24 +8810,10 @@ function buildCompanionTurnRequest(params) {
8168
8810
  session: t.session,
8169
8811
  cabane: {
8170
8812
  mcpUrl: cabaneMcpUrl(params.baseUrl),
8171
- // CT306: prefer the per-turn OBO credential; fall back to the companion PAT
8172
- // when the API didn't mint one (older API / unresolvable delegation).
8173
- bearer: params.turnToken ?? params.agentPat,
8813
+ bearer: params.turnToken,
8174
8814
  activeConversationId: params.activeConversationId,
8175
8815
  workspaceId: params.workspaceId,
8176
- // CT714: mount the turn-control surface ONLY when a real turn token backs
8177
- // this turn — the surface admits `turn_token` auth exclusively, so a
8178
- // PAT-fallback bearer would be rejected there. Absent it, external adapters
8179
- // simply don't mount it that turn.
8180
- //
8181
- // CT1074: that fallback is now an OLD-API case only. The server mints a token
8182
- // for every turn it dispatched, so "no token" no longer means "no human behind
8183
- // the turn" — it means the API predates this change. The condition is right as
8184
- // it stands and stays where the failure is honest (a rejected bearer is worse
8185
- // than an unmounted server); what was wrong was upstream, where an undelegated
8186
- // turn minted nothing and this silently dropped all five verbs on Codex and
8187
- // opencode with no refusal anyone could see.
8188
- ...params.turnToken ? { turnControlUrl: turnControlMcpUrl(params.baseUrl) } : {}
8816
+ turnControlUrl: turnControlMcpUrl(params.baseUrl)
8189
8817
  },
8190
8818
  local: {
8191
8819
  ...params.cwd ? { cwd: params.cwd } : {},
@@ -8209,11 +8837,11 @@ function trimSlash2(s) {
8209
8837
  // src/codex-instructions.ts
8210
8838
  import { mkdtemp, rm, writeFile } from "fs/promises";
8211
8839
  import { tmpdir } from "os";
8212
- import { join as join11 } from "path";
8840
+ import { join as join10 } from "path";
8213
8841
  var PREFIX = "cabane-codex-instructions-";
8214
8842
  async function writeCodexInstructionsFile(contents) {
8215
- const dir2 = await mkdtemp(join11(tmpdir(), PREFIX));
8216
- const path = join11(dir2, "instructions.md");
8843
+ const dir2 = await mkdtemp(join10(tmpdir(), PREFIX));
8844
+ const path = join10(dir2, "instructions.md");
8217
8845
  await writeFile(path, contents, { encoding: "utf8", mode: 384 });
8218
8846
  return {
8219
8847
  path,
@@ -8257,7 +8885,7 @@ function readOutboxFloor(read, turnId, log) {
8257
8885
  }
8258
8886
 
8259
8887
  // src/prepare-failure.ts
8260
- import { spawn as spawn4 } from "child_process";
8888
+ import { spawn as spawn5 } from "child_process";
8261
8889
  import { hostname } from "os";
8262
8890
  function prepareFailureCause(error) {
8263
8891
  const message = error instanceof Error ? error.message : String(error);
@@ -8295,7 +8923,7 @@ async function runPrepareWithReporting(runner, hook, input, context, reportError
8295
8923
  }
8296
8924
  function reportFailure(config, body) {
8297
8925
  return new Promise((resolve2, reject) => {
8298
- const child = spawn4(config.command, config.args ?? [], {
8926
+ const child = spawn5(config.command, config.args ?? [], {
8299
8927
  stdio: ["pipe", "ignore", "ignore"],
8300
8928
  env: { ...process.env, ...config.env }
8301
8929
  });
@@ -8319,488 +8947,232 @@ function reportFailure(config, body) {
8319
8947
  }
8320
8948
 
8321
8949
  // src/prepared.ts
8322
- import { mkdirSync as mkdirSync8, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
8323
- import { join as join12 } from "path";
8950
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
8951
+ import { join as join11 } from "path";
8324
8952
  function dirFor(workspaceId) {
8325
- return join12(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
8326
- }
8327
- function conversationDir(workspaceId, conversationId) {
8328
- return join12(dirFor(workspaceId), encodeURIComponent(conversationId));
8329
- }
8330
- function pathFor3(workspaceId, conversationId, agentId) {
8331
- return join12(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
8332
- }
8333
- function readPrepared(workspaceId, conversationId, agentId) {
8334
- const path = pathFor3(workspaceId, conversationId, agentId);
8335
- if (!existsSync8(path)) return null;
8336
- try {
8337
- const parsed = JSON.parse(readFileSync6(path, "utf8"));
8338
- if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
8339
- return {
8340
- cwd: parsed.cwd,
8341
- ...parsed.env && typeof parsed.env === "object" ? { env: parsed.env } : {}
8342
- };
8343
- }
8344
- return null;
8345
- } catch {
8346
- return null;
8347
- }
8348
- }
8349
- function writePrepared(workspaceId, conversationId, agentId, result) {
8350
- mkdirSync8(conversationDir(workspaceId, conversationId), { recursive: true });
8351
- writeFileSync6(
8352
- pathFor3(workspaceId, conversationId, agentId),
8353
- JSON.stringify(result) + "\n",
8354
- "utf8"
8355
- );
8356
- }
8357
- function clearPrepared(workspaceId, conversationId, agentId) {
8358
- rmSync5(pathFor3(workspaceId, conversationId, agentId), { force: true });
8359
- }
8360
-
8361
- // src/secrets.ts
8362
- import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
8363
- import { join as join13 } from "path";
8364
- import { z as z14 } from "zod";
8365
- var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8366
- function secretsPath() {
8367
- return join13(cabaneDir(), "secrets.json");
8368
- }
8369
- var secretStoreSchema = z14.record(z14.string(), z14.string());
8370
- function loadSecretStore() {
8371
- const path = secretsPath();
8372
- if (!existsSync9(path)) return makeStore({});
8373
- let raw;
8374
- try {
8375
- raw = readFileSync7(path, "utf8");
8376
- } catch (err) {
8377
- throw new ConfigError(
8378
- `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
8379
- );
8380
- }
8381
- if (raw.trim().length === 0) return makeStore({});
8382
- let parsed;
8383
- try {
8384
- parsed = JSON.parse(raw);
8385
- } catch (err) {
8386
- throw new ConfigError(
8387
- `${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}. It must be a flat object of "NAME": "value" secret pairs.`
8388
- );
8389
- }
8390
- const result = secretStoreSchema.safeParse(parsed);
8391
- if (!result.success) {
8392
- throw new ConfigError(
8393
- `${path} must be a flat object mapping secret names to string values (e.g. { "GITHUB_TOKEN": "ghp_\u2026" }).`
8394
- );
8395
- }
8396
- return makeStore(result.data);
8397
- }
8398
- function loadSecretStoreTolerant(onWarn) {
8399
- try {
8400
- return loadSecretStore();
8401
- } catch (err) {
8402
- onWarn?.(err instanceof Error ? err.message : String(err));
8403
- return makeStore({});
8404
- }
8405
- }
8406
- function makeStore(map) {
8407
- return {
8408
- get: (name) => map[name],
8409
- names: () => Object.keys(map).sort()
8410
- };
8411
- }
8412
- function requiredSecretNames(mcpServers) {
8413
- const names = /* @__PURE__ */ new Set();
8414
- for (const server of Object.values(mcpServers ?? {})) {
8415
- const bags = [
8416
- "env" in server ? server.env : void 0,
8417
- "headers" in server ? server.headers : void 0
8418
- ];
8419
- for (const bag of bags) {
8420
- for (const value of Object.values(bag ?? {})) {
8421
- for (const [, name] of value.matchAll(PLACEHOLDER_RE)) names.add(name);
8422
- }
8423
- }
8424
- }
8425
- return [...names].sort();
8426
- }
8427
- function resolveMcpSecrets(mcpServers, store) {
8428
- const missing = /* @__PURE__ */ new Set();
8429
- const interpolate = (value) => value.replace(PLACEHOLDER_RE, (_match, name) => {
8430
- const resolved = store.get(name);
8431
- if (resolved === void 0) {
8432
- missing.add(name);
8433
- return _match;
8434
- }
8435
- return resolved;
8436
- });
8437
- const mapBag = (bag) => {
8438
- const out = {};
8439
- for (const [k, v] of Object.entries(bag)) out[k] = interpolate(v);
8440
- return out;
8441
- };
8442
- const servers = {};
8443
- for (const [name, server] of Object.entries(mcpServers)) {
8444
- const next = { ...server };
8445
- if ("env" in next && next.env) next.env = mapBag(next.env);
8446
- if ("headers" in next && next.headers) next.headers = mapBag(next.headers);
8447
- servers[name] = next;
8448
- }
8449
- return { servers, missing: [...missing].sort() };
8450
- }
8451
-
8452
- // src/transcript-writer.ts
8453
- import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync9, readdirSync, rmSync as rmSync6 } from "fs";
8454
- import { basename, dirname as dirname6, join as join14 } from "path";
8455
- function transcriptsDir() {
8456
- return join14(cabaneDir(), "transcripts");
8457
- }
8458
- var RETAIN = 200;
8459
- var ANOMALY_RETAIN = 50;
8460
- var TranscriptWriter = class {
8461
- path;
8462
- broken = false;
8463
- onWarn;
8464
- constructor(dir2, meta, onWarn) {
8465
- this.onWarn = onWarn;
8466
- this.path = join14(dir2, fileName(meta));
8467
- try {
8468
- mkdirSync9(dir2, { recursive: true });
8469
- try {
8470
- chmodSync4(dir2, 448);
8471
- } catch {
8472
- }
8473
- pruneOld(dir2, RETAIN);
8474
- } catch (err) {
8475
- this.fail(err);
8476
- }
8477
- this.line({ type: "_meta", ...meta });
8478
- }
8479
- // Append a raw SDK message verbatim.
8480
- write(msg) {
8481
- this.line(msg);
8482
- }
8483
- // Append the outcome footer.
8484
- close(outcome) {
8485
- this.line({ type: "_outcome", ...outcome });
8486
- }
8487
- // CT1145: keep an independent copy of an empty successful envelope after the
8488
- // footer has landed. The ordinary 200-file FIFO keeps rotating as before;
8489
- // this bucket retains the newest 50 anomalies regardless of ordinary traffic.
8490
- // Best-effort like every other transcript operation: observability can never
8491
- // fail the turn.
8492
- preserveAnomaly() {
8493
- if (this.broken) return;
8494
- try {
8495
- const dir2 = join14(dirname6(this.path), "anomalies");
8496
- mkdirSync9(dir2, { recursive: true, mode: 448 });
8497
- chmodSync4(dir2, 448);
8498
- const target = join14(dir2, basename(this.path));
8499
- copyFileSync(this.path, target);
8500
- chmodSync4(target, 384);
8501
- pruneOld(dir2, ANOMALY_RETAIN);
8502
- } catch (err) {
8503
- this.fail(err);
8504
- }
8505
- }
8506
- line(obj) {
8507
- if (this.broken) return;
8508
- try {
8509
- appendFileSync(this.path, JSON.stringify(obj) + "\n", { mode: 384 });
8510
- } catch (err) {
8511
- this.fail(err);
8512
- }
8513
- }
8514
- fail(err) {
8515
- if (this.broken) return;
8516
- this.broken = true;
8517
- this.onWarn?.(`transcript write failed (${err instanceof Error ? err.message : String(err)})`);
8518
- }
8519
- };
8520
- function fileName(meta) {
8521
- const d = new Date(meta.ts);
8522
- const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
8523
- const slug = safe2(meta.workspaceSlug) || "ws";
8524
- const short = meta.dispatchId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "turn";
8525
- return `${stamp}-${slug}-${short}.jsonl`;
8953
+ return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
8526
8954
  }
8527
- function pad(n) {
8528
- return String(n).padStart(2, "0");
8955
+ function conversationDir(workspaceId, conversationId) {
8956
+ return join11(dirFor(workspaceId), encodeURIComponent(conversationId));
8529
8957
  }
8530
- function safe2(s) {
8531
- return s.replace(/[^a-zA-Z0-9_-]/g, "-");
8958
+ function pathFor2(workspaceId, conversationId, agentId) {
8959
+ return join11(conversationDir(workspaceId, conversationId), `${encodeURIComponent(agentId)}.json`);
8532
8960
  }
8533
- function pruneOld(dir2, retain) {
8534
- let files;
8961
+ function readPrepared(workspaceId, conversationId, agentId) {
8962
+ const path = pathFor2(workspaceId, conversationId, agentId);
8963
+ if (!existsSync7(path)) return null;
8535
8964
  try {
8536
- files = readdirSync(dir2).filter((f) => f.endsWith(".jsonl"));
8537
- } catch {
8538
- return;
8539
- }
8540
- if (files.length <= retain) return;
8541
- const drop = files.sort().slice(0, files.length - retain);
8542
- for (const f of drop) {
8543
- try {
8544
- rmSync6(join14(dir2, f), { force: true });
8545
- } catch {
8965
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
8966
+ if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
8967
+ return {
8968
+ cwd: parsed.cwd,
8969
+ ...parsed.env && typeof parsed.env === "object" ? { env: parsed.env } : {}
8970
+ };
8546
8971
  }
8547
- }
8548
- }
8549
-
8550
- // src/turn-containment.ts
8551
- import { execFile, execFileSync, spawn as spawn5 } from "child_process";
8552
- import { readFileSync as readFileSync8 } from "fs";
8553
- import { promisify } from "util";
8554
- var execFileAsync = promisify(execFile);
8555
- var asContained = (child) => child;
8556
- var CONTAINMENT_STOP_GRACE_SEC = 5;
8557
- var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
8558
- var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
8559
- function turnScopeUnit(turnId, attempt) {
8560
- const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
8561
- return `cabane-turn-${safe3}-${attempt}`;
8562
- }
8563
- function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
8564
- if (process.platform !== "linux") return false;
8565
- try {
8566
- execFileSync("systemd-run", ["--user", "--scope", "--quiet", "--collect", "--", "/bin/true"], {
8567
- env,
8568
- timeout: timeoutMs,
8569
- stdio: "ignore"
8570
- });
8571
- return true;
8972
+ return null;
8572
8973
  } catch {
8573
- return false;
8974
+ return null;
8574
8975
  }
8575
8976
  }
8576
- function classifyStopError(message) {
8577
- if (/failed to connect to bus|failed to get d-?bus connection|connection refused|refusing to operate|spawn systemctl|systemctl.*ENOENT|ENOENT.*systemctl/i.test(
8578
- message
8579
- )) {
8580
- return "failure";
8581
- }
8582
- if (/not loaded|could not be found|no such unit|unit .* not found/i.test(message))
8583
- return "absent";
8584
- return "failure";
8977
+ function writePrepared(workspaceId, conversationId, agentId, result) {
8978
+ mkdirSync7(conversationDir(workspaceId, conversationId), { recursive: true });
8979
+ writeFileSync5(
8980
+ pathFor2(workspaceId, conversationId, agentId),
8981
+ JSON.stringify(result) + "\n",
8982
+ "utf8"
8983
+ );
8585
8984
  }
8586
- function currentSystemdUnit(cgroupText) {
8587
- for (const line of cgroupText.split("\n")) {
8588
- const path = line.trim().split(":").pop();
8589
- if (!path?.startsWith("/")) continue;
8590
- const leaf = path.split("/").filter(Boolean).pop();
8591
- if (leaf && (leaf.endsWith(".service") || leaf.endsWith(".scope"))) return leaf;
8592
- }
8593
- return null;
8985
+ function clearPrepared(workspaceId, conversationId, agentId) {
8986
+ rmSync5(pathFor2(workspaceId, conversationId, agentId), { force: true });
8594
8987
  }
8595
- function readCurrentSystemdUnit() {
8596
- try {
8597
- return currentSystemdUnit(readFileSync8("/proc/self/cgroup", "utf8"));
8598
- } catch {
8599
- return null;
8600
- }
8988
+
8989
+ // src/secrets.ts
8990
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
8991
+ import { join as join12 } from "path";
8992
+ import { z as z16 } from "zod";
8993
+ var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8994
+ function secretsPath() {
8995
+ return join12(cabaneDir(), "secrets.json");
8601
8996
  }
8602
- function processGroupMembers(pgid) {
8997
+ var secretStoreSchema = z16.record(z16.string(), z16.string());
8998
+ function loadSecretStore() {
8999
+ const path = secretsPath();
9000
+ if (!existsSync8(path)) return makeStore({});
9001
+ let raw;
8603
9002
  try {
8604
- const out = execFileSync("ps", ["-eo", "pid=,pgid="], { timeout: 5e3 }).toString();
8605
- return {
8606
- ok: true,
8607
- members: out.split("\n").map((line) => /^\s*(\d+)\s+(\d+)\s*$/.exec(line)).filter((m) => m !== null).filter((m) => Number(m[2]) === pgid && Number(m[1]) !== process.pid).map((m) => Number(m[1]))
8608
- };
9003
+ raw = readFileSync7(path, "utf8");
8609
9004
  } catch (err) {
8610
- return { ok: false, error: (err instanceof Error ? err.message : String(err)).slice(0, 150) };
9005
+ throw new ConfigError(
9006
+ `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
9007
+ );
8611
9008
  }
8612
- }
8613
- var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
8614
- var MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
8615
- var managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
8616
- function createTurnContainment(turnId, deps = {}) {
8617
- const probe = deps.capabilityProbe ?? scopeCapabilityUsable;
8618
- const fallbackGraceMs = deps.fallbackGraceMs ?? FALLBACK_GRACE_MS;
8619
- const ownUnit = (deps.ownSystemdUnit ?? readCurrentSystemdUnit)();
8620
- const scopeUnits = [];
8621
- const groups = [];
8622
- let kind = null;
8623
- let probedEnvKey = null;
8624
- let attempt = 0;
8625
- function spawnInScope(options) {
8626
- const unit = turnScopeUnit(turnId, attempt++);
8627
- scopeUnits.push({ unit, env: options.env });
8628
- deps.log?.("containment: spawning the turn CLI into a systemd scope", {
8629
- unit,
8630
- partOf: ownUnit,
8631
- command: options.command
8632
- });
8633
- return asContained(
8634
- spawn5(
8635
- "systemd-run",
8636
- [
8637
- "--user",
8638
- "--scope",
8639
- "--quiet",
8640
- "--collect",
8641
- `--unit=${unit}`,
8642
- `--property=TimeoutStopSec=${CONTAINMENT_STOP_GRACE_SEC}s`,
8643
- // Bind the scope's life to the companion's own unit, so stopping or
8644
- // restarting the companion still takes its in-flight turns with it —
8645
- // which is what happens today, and what the scope would otherwise undo.
8646
- // Omitted outside a unit, where there is nothing to bind to.
8647
- ...ownUnit ? [`--property=PartOf=${ownUnit}`] : [],
8648
- "--",
8649
- options.command,
8650
- ...options.args
8651
- ],
8652
- {
8653
- ...options.cwd ? { cwd: options.cwd } : {},
8654
- env: options.env,
8655
- stdio: ["pipe", "pipe", "pipe"]
8656
- }
8657
- )
9009
+ if (raw.trim().length === 0) return makeStore({});
9010
+ let parsed;
9011
+ try {
9012
+ parsed = JSON.parse(raw);
9013
+ } catch (err) {
9014
+ throw new ConfigError(
9015
+ `${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}. It must be a flat object of "NAME": "value" secret pairs.`
8658
9016
  );
8659
9017
  }
8660
- function spawnInGroup(options) {
8661
- const child = spawn5(options.command, options.args, {
8662
- ...options.cwd ? { cwd: options.cwd } : {},
8663
- env: options.env,
8664
- stdio: ["pipe", "pipe", "pipe"],
8665
- detached: process.platform !== "win32"
8666
- });
8667
- if (child.pid !== void 0) groups.push({ pgid: child.pid, child });
8668
- deps.log?.("containment: no host-owned containment here \u2014 best effort only", {
8669
- platform: process.platform,
8670
- pid: child.pid
8671
- });
8672
- return asContained(child);
9018
+ const result = secretStoreSchema.safeParse(parsed);
9019
+ if (!result.success) {
9020
+ throw new ConfigError(
9021
+ `${path} must be a flat object mapping secret names to string values (e.g. { "GITHUB_TOKEN": "ghp_\u2026" }).`
9022
+ );
8673
9023
  }
8674
- async function reapScopes(result) {
8675
- for (const { unit, env } of scopeUnits) {
8676
- try {
8677
- await execFileAsync("systemctl", ["--user", "stop", `${unit}.scope`], {
8678
- env,
8679
- timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
8680
- });
8681
- } catch (err) {
8682
- const message = err instanceof Error ? err.message : String(err);
8683
- if (classifyStopError(message) === "failure") {
8684
- result.failures.push({ target: unit, error: message.slice(0, 200) });
8685
- continue;
8686
- }
8687
- result.reaped.push(unit);
8688
- continue;
8689
- }
8690
- try {
8691
- const { stdout } = await execFileAsync(
8692
- "systemctl",
8693
- ["--user", "show", `${unit}.scope`, "--property=ActiveState", "--value"],
8694
- { env, timeout: 1e4 }
8695
- );
8696
- const state = stdout.trim();
8697
- if (state === "" || state === "inactive" || state === "failed") result.reaped.push(unit);
8698
- else result.failures.push({ target: unit, error: `scope still ${state} after stop` });
8699
- } catch (err) {
8700
- result.failures.push({
8701
- target: unit,
8702
- error: `could not confirm the scope stopped: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`
8703
- });
8704
- }
8705
- }
9024
+ return makeStore(result.data);
9025
+ }
9026
+ function loadSecretStoreTolerant(onWarn) {
9027
+ try {
9028
+ return loadSecretStore();
9029
+ } catch (err) {
9030
+ onWarn?.(err instanceof Error ? err.message : String(err));
9031
+ return makeStore({});
8706
9032
  }
8707
- async function reapGroups(result) {
8708
- for (const { pgid, child } of groups) {
8709
- const target = String(pgid);
8710
- if (process.platform === "win32") {
8711
- try {
8712
- if (child.exitCode === null) child.kill();
8713
- result.unverified.push(target);
8714
- } catch (err) {
8715
- result.failures.push({
8716
- target,
8717
- error: (err instanceof Error ? err.message : String(err)).slice(0, 200)
8718
- });
8719
- }
8720
- continue;
8721
- }
8722
- const first = processGroupMembers(pgid);
8723
- if (!first.ok) {
8724
- result.failures.push({
8725
- target,
8726
- error: `could not enumerate the process group: ${first.error}`
8727
- });
8728
- continue;
8729
- }
8730
- if (first.members.length === 0) {
8731
- result.reaped.push(target);
8732
- continue;
8733
- }
8734
- for (const pid of first.members) {
8735
- try {
8736
- process.kill(pid, "SIGTERM");
8737
- } catch {
8738
- }
8739
- }
8740
- await sleep2(fallbackGraceMs);
8741
- const afterTerm = processGroupMembers(pgid);
8742
- if (!afterTerm.ok) {
8743
- result.failures.push({
8744
- target,
8745
- error: `could not re-enumerate the process group after SIGTERM: ${afterTerm.error}`
8746
- });
8747
- continue;
8748
- }
8749
- for (const pid of afterTerm.members) {
8750
- try {
8751
- process.kill(pid, "SIGKILL");
8752
- } catch {
8753
- }
8754
- }
8755
- const afterKill = processGroupMembers(pgid);
8756
- if (!afterKill.ok) {
8757
- result.failures.push({
8758
- target,
8759
- error: `could not confirm the process group is empty after SIGKILL: ${afterKill.error}`
8760
- });
8761
- continue;
9033
+ }
9034
+ function makeStore(map) {
9035
+ return {
9036
+ get: (name) => map[name],
9037
+ names: () => Object.keys(map).sort()
9038
+ };
9039
+ }
9040
+ function requiredSecretNames(mcpServers) {
9041
+ const names = /* @__PURE__ */ new Set();
9042
+ for (const server of Object.values(mcpServers ?? {})) {
9043
+ const bags = [
9044
+ "env" in server ? server.env : void 0,
9045
+ "headers" in server ? server.headers : void 0
9046
+ ];
9047
+ for (const bag of bags) {
9048
+ for (const value of Object.values(bag ?? {})) {
9049
+ for (const [, name] of value.matchAll(PLACEHOLDER_RE)) names.add(name);
8762
9050
  }
8763
- if (afterKill.members.length)
8764
- result.failures.push({
8765
- target,
8766
- error: `${afterKill.members.length} process(es) still in the group after SIGKILL`
8767
- });
8768
- else result.reaped.push(target);
8769
9051
  }
8770
9052
  }
8771
- return {
8772
- get kind() {
8773
- return kind;
8774
- },
8775
- spawn(options) {
8776
- const envKey = managerEnvKey(options.env);
8777
- if (kind === null || envKey !== probedEnvKey) {
8778
- kind = probe(options.env) ? "systemd-scope" : "process-group";
8779
- probedEnvKey = envKey;
8780
- deps.log?.("containment: resolved strategy for this turn", {
8781
- kind,
8782
- platform: process.platform
8783
- });
8784
- }
8785
- return kind === "systemd-scope" ? spawnInScope(options) : spawnInGroup(options);
8786
- },
8787
- async reap() {
8788
- const result = {
8789
- kind: kind ?? "unused",
8790
- reaped: [],
8791
- failures: [],
8792
- unverified: []
8793
- };
8794
- await reapScopes(result);
8795
- await reapGroups(result);
8796
- deps.log?.("containment: reap complete", {
8797
- kind: result.kind,
8798
- reaped: result.reaped,
8799
- failures: result.failures
8800
- });
8801
- return result;
9053
+ return [...names].sort();
9054
+ }
9055
+ function resolveMcpSecrets(mcpServers, store) {
9056
+ const missing = /* @__PURE__ */ new Set();
9057
+ const interpolate = (value) => value.replace(PLACEHOLDER_RE, (_match, name) => {
9058
+ const resolved = store.get(name);
9059
+ if (resolved === void 0) {
9060
+ missing.add(name);
9061
+ return _match;
8802
9062
  }
9063
+ return resolved;
9064
+ });
9065
+ const mapBag = (bag) => {
9066
+ const out = {};
9067
+ for (const [k, v] of Object.entries(bag)) out[k] = interpolate(v);
9068
+ return out;
8803
9069
  };
9070
+ const servers = {};
9071
+ for (const [name, server] of Object.entries(mcpServers)) {
9072
+ const next = { ...server };
9073
+ if ("env" in next && next.env) next.env = mapBag(next.env);
9074
+ if ("headers" in next && next.headers) next.headers = mapBag(next.headers);
9075
+ servers[name] = next;
9076
+ }
9077
+ return { servers, missing: [...missing].sort() };
9078
+ }
9079
+
9080
+ // src/transcript-writer.ts
9081
+ import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync6 } from "fs";
9082
+ import { basename, dirname as dirname6, join as join13 } from "path";
9083
+ function transcriptsDir() {
9084
+ return join13(runnerStateDir(), "transcripts");
9085
+ }
9086
+ var RETAIN = 200;
9087
+ var ANOMALY_RETAIN = 50;
9088
+ var TranscriptWriter = class {
9089
+ path;
9090
+ broken = false;
9091
+ onWarn;
9092
+ constructor(dir2, meta, onWarn) {
9093
+ this.onWarn = onWarn;
9094
+ this.path = join13(dir2, fileName(meta));
9095
+ try {
9096
+ mkdirSync8(dir2, { recursive: true });
9097
+ try {
9098
+ chmodSync4(dir2, 448);
9099
+ } catch {
9100
+ }
9101
+ pruneOld(dir2, RETAIN);
9102
+ } catch (err) {
9103
+ this.fail(err);
9104
+ }
9105
+ this.line({ type: "_meta", ...meta });
9106
+ }
9107
+ // Append a raw SDK message verbatim.
9108
+ write(msg) {
9109
+ this.line(msg);
9110
+ }
9111
+ // Append the outcome footer.
9112
+ close(outcome) {
9113
+ this.line({ type: "_outcome", ...outcome });
9114
+ }
9115
+ // CT1145: keep an independent copy of an empty successful envelope after the
9116
+ // footer has landed. The ordinary 200-file FIFO keeps rotating as before;
9117
+ // this bucket retains the newest 50 anomalies regardless of ordinary traffic.
9118
+ // Best-effort like every other transcript operation: observability can never
9119
+ // fail the turn.
9120
+ preserveAnomaly() {
9121
+ if (this.broken) return;
9122
+ try {
9123
+ const dir2 = join13(dirname6(this.path), "anomalies");
9124
+ mkdirSync8(dir2, { recursive: true, mode: 448 });
9125
+ chmodSync4(dir2, 448);
9126
+ const target = join13(dir2, basename(this.path));
9127
+ copyFileSync(this.path, target);
9128
+ chmodSync4(target, 384);
9129
+ pruneOld(dir2, ANOMALY_RETAIN);
9130
+ } catch (err) {
9131
+ this.fail(err);
9132
+ }
9133
+ }
9134
+ line(obj) {
9135
+ if (this.broken) return;
9136
+ try {
9137
+ appendFileSync(this.path, JSON.stringify(obj) + "\n", { mode: 384 });
9138
+ } catch (err) {
9139
+ this.fail(err);
9140
+ }
9141
+ }
9142
+ fail(err) {
9143
+ if (this.broken) return;
9144
+ this.broken = true;
9145
+ this.onWarn?.(`transcript write failed (${err instanceof Error ? err.message : String(err)})`);
9146
+ }
9147
+ };
9148
+ function fileName(meta) {
9149
+ const d = new Date(meta.ts);
9150
+ const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
9151
+ const slug = safe2(meta.workspaceSlug) || "ws";
9152
+ const short = meta.dispatchId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "turn";
9153
+ return `${stamp}-${slug}-${short}.jsonl`;
9154
+ }
9155
+ function pad(n) {
9156
+ return String(n).padStart(2, "0");
9157
+ }
9158
+ function safe2(s) {
9159
+ return s.replace(/[^a-zA-Z0-9_-]/g, "-");
9160
+ }
9161
+ function pruneOld(dir2, retain) {
9162
+ let files;
9163
+ try {
9164
+ files = readdirSync(dir2).filter((f) => f.endsWith(".jsonl"));
9165
+ } catch {
9166
+ return;
9167
+ }
9168
+ if (files.length <= retain) return;
9169
+ const drop = files.sort().slice(0, files.length - retain);
9170
+ for (const f of drop) {
9171
+ try {
9172
+ rmSync6(join13(dir2, f), { force: true });
9173
+ } catch {
9174
+ }
9175
+ }
8804
9176
  }
8805
9177
 
8806
9178
  // src/turn-committer.ts
@@ -9020,6 +9392,7 @@ function missingSecretReason(missing) {
9020
9392
  return `${prefix}+${missing.length}`;
9021
9393
  }
9022
9394
  var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
9395
+ var NO_TURN_TOKEN_REASON = "no_turn_token: the server issued no turn credential for this turn";
9023
9396
  var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
9024
9397
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
9025
9398
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 15 * 6e4;
@@ -9299,6 +9672,10 @@ var TurnExecution = class {
9299
9672
  const fetchReason = `fetch_failed: ${describeError(err)}`;
9300
9673
  throw this.concluded(fetchReason, fetchReason);
9301
9674
  }
9675
+ if (!turnContext.turnToken) {
9676
+ turnLog.warn({ turnId }, "dispatcher: turn context carried no turn token");
9677
+ throw this.concluded(NO_TURN_TOKEN_REASON, NO_TURN_TOKEN_REASON);
9678
+ }
9302
9679
  this.turnContext = turnContext;
9303
9680
  if (this.resumed) {
9304
9681
  const sources = { serverFloor: turnContext.turnSeqFloor, outboxFloor };
@@ -9346,7 +9723,7 @@ var TurnExecution = class {
9346
9723
  const prepareHook = this.opts.local.prepareHook;
9347
9724
  const cabaneCwd = this.turnContext.cwd;
9348
9725
  let effectiveCwd = localCwd ?? cabaneCwd;
9349
- if (effectiveCwd && !existsSync10(effectiveCwd)) {
9726
+ if (effectiveCwd && !existsSync9(effectiveCwd)) {
9350
9727
  turnLog.warn(
9351
9728
  { cwd: effectiveCwd },
9352
9729
  "The working directory is missing; using the companion's directory instead. Check this agent's working directory setting."
@@ -9486,7 +9863,9 @@ var TurnExecution = class {
9486
9863
  try {
9487
9864
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
9488
9865
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
9489
- bootId,
9866
+ // CT1679: a runner leases under its launcher's boot id — the one the
9867
+ // device heartbeats — or the lost-run sweep reads it as a restart.
9868
+ bootId: this.opts.inRunner?.bootId ?? bootId,
9490
9869
  // CT1380: the durable record that this turn resumed, and from where.
9491
9870
  ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
9492
9871
  // CT1454: the issuance the context THIS attempt runs on came from. Read
@@ -9550,10 +9929,8 @@ var TurnExecution = class {
9550
9929
  this.request = buildCompanionTurnRequest({
9551
9930
  turnContext: this.turnContext,
9552
9931
  baseUrl: this.opts.baseUrl,
9553
- agentPat: this.opts.credential,
9554
- // CT306: the per-turn OBO credential when the API minted one; falls back to
9555
- // the companion PAT (`agentPat`) inside `buildCompanionTurnRequest` otherwise.
9556
- ...this.turnContext.turnToken ? { turnToken: this.turnContext.turnToken } : {},
9932
+ // ACC8: always present; `fetchTurnContext` refused a context without one.
9933
+ turnToken: this.turnContext.turnToken,
9557
9934
  // SJ524: the hook-resolved cwd overrides the static local cwd.
9558
9935
  ...this.effectiveCwd ? { cwd: this.effectiveCwd } : {},
9559
9936
  // CT1103: the prepare-hook env plus the authoritative host-access token.
@@ -9585,10 +9962,14 @@ var TurnExecution = class {
9585
9962
  const { payload, workspaceId, turnId, turnLog } = this;
9586
9963
  await guardBundledBinary(this.integrityContext(), (r, e) => this.concluded(r, e), this.outcome);
9587
9964
  const onWarn = (msg, meta) => turnLog.debug(meta ?? {}, msg);
9965
+ const sandbox = this.opts.harnessSandbox ? await this.opts.harnessSandbox({ cwd: this.effectiveCwd, log: turnLog }) : null;
9588
9966
  const adapters = [];
9589
9967
  if (this.opts.claudeCodeAvailable?.() ?? true) {
9590
9968
  this.containment = createTurnContainment(this.turnId, {
9591
- log: (msg, meta) => turnLog.debug(meta ?? {}, msg)
9969
+ log: (msg, meta) => turnLog.debug(meta ?? {}, msg),
9970
+ // CT1679: a group outside the runner's own is reported, so a launcher
9971
+ // can stop it if the runner dies without reaping it.
9972
+ ...this.opts.inRunner?.onProcessGroup ? { onProcessGroup: this.opts.inRunner.onProcessGroup } : {}
9592
9973
  });
9593
9974
  const containment = this.containment;
9594
9975
  adapters.push(
@@ -9597,20 +9978,43 @@ var TurnExecution = class {
9597
9978
  onWarn,
9598
9979
  augmentOptions: (options) => ({
9599
9980
  ...options,
9600
- spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(spawnOptions)
9981
+ spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(
9982
+ sandbox ? { ...spawnOptions, ...sandbox.wrap(spawnOptions.command, spawnOptions.args) } : spawnOptions
9983
+ )
9601
9984
  })
9602
9985
  })
9603
9986
  );
9604
9987
  }
9605
9988
  if (this.opts.opencodeServerUrl) {
9606
- adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
9989
+ const serverUrl = this.opts.opencodeServerUrl;
9990
+ adapters.push(
9991
+ createOpencodeAdapter(
9992
+ // CT1679: the transport's per-server turn mutex is per process; in a
9993
+ // runner the turn also holds the device-wide lock every runner shares.
9994
+ this.opts.inRunner ? {
9995
+ transport: this.opts.inRunner.opencodeTransport(serverUrl),
9996
+ onWarn
9997
+ } : { serverUrl, onWarn }
9998
+ )
9999
+ );
9607
10000
  }
9608
10001
  if (this.opts.codexAvailable?.() ?? false) {
10002
+ const inRunner = this.opts.inRunner;
10003
+ const codexBinary = sandbox || inRunner ? (this.opts.resolveBundled ?? resolveBundledBinary)("codex").path : null;
10004
+ let codexPath = codexBinary && sandbox ? sandbox.executableFor(codexBinary) : codexBinary;
10005
+ if (codexPath && inRunner) {
10006
+ this.containment ??= createTurnContainment(this.turnId, {
10007
+ log: (msg, meta) => turnLog.debug(meta ?? {}, msg),
10008
+ ...this.opts.inRunner?.onProcessGroup ? { onProcessGroup: this.opts.inRunner.onProcessGroup } : {}
10009
+ });
10010
+ codexPath = inRunner.codexExecutable(codexPath, this.containment);
10011
+ }
9609
10012
  adapters.push(
9610
10013
  createCodexAdapter({
9611
10014
  enabled: true,
9612
10015
  onWarn,
9613
- writeInstructionsFile: writeCodexInstructionsFile
10016
+ writeInstructionsFile: writeCodexInstructionsFile,
10017
+ ...codexPath ? { transport: createSdkCodexTransport({ codexPathOverride: codexPath }) } : {}
9614
10018
  })
9615
10019
  );
9616
10020
  }
@@ -10065,7 +10469,7 @@ function fingerprintSessionState(state) {
10065
10469
  // src/dispatcher.ts
10066
10470
  function checkoutState(cwd) {
10067
10471
  if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
10068
- if (!existsSync11(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
10472
+ if (!existsSync10(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
10069
10473
  let entries;
10070
10474
  try {
10071
10475
  entries = readdirSync2(cwd);
@@ -10075,15 +10479,15 @@ function checkoutState(cwd) {
10075
10479
  if (entries.length === 0) {
10076
10480
  return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
10077
10481
  }
10078
- const gitPath = join15(cwd, ".git");
10079
- if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
10482
+ const gitPath = join14(cwd, ".git");
10483
+ if (!existsSync10(gitPath)) return { ok: true, reason: "usable" };
10080
10484
  let stat;
10081
10485
  try {
10082
10486
  stat = statSync2(gitPath);
10083
10487
  } catch (error) {
10084
10488
  return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
10085
10489
  }
10086
- if (stat.isDirectory() && !existsSync11(join15(gitPath, "HEAD")))
10490
+ if (stat.isDirectory() && !existsSync10(join14(gitPath, "HEAD")))
10087
10491
  return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
10088
10492
  return { ok: true, reason: "usable" };
10089
10493
  }
@@ -10147,6 +10551,24 @@ var Dispatcher = class {
10147
10551
  notifyStart: (info) => this.notifyStart(info),
10148
10552
  notifyEnd: (info) => this.notifyEnd(info)
10149
10553
  };
10554
+ if (this.opts.runner) {
10555
+ return runTurnInRunner(
10556
+ this.opts,
10557
+ this.opts.runner,
10558
+ {
10559
+ registerAbort: supervisor.registerAbort,
10560
+ releaseAbort: supervisor.releaseAbort,
10561
+ sessionWriteNoticeClaimed: () => this.sessionWriteNotified.has(key),
10562
+ markSessionWriteNotice: () => {
10563
+ this.sessionWriteNotified.add(key);
10564
+ },
10565
+ notifyStart: supervisor.notifyStart,
10566
+ notifyEnd: supervisor.notifyEnd
10567
+ },
10568
+ payload,
10569
+ { turnId: opts.turnId ?? randomUUID4(), resumed: opts.resumed === true }
10570
+ );
10571
+ }
10150
10572
  return new TurnExecution(this.opts, supervisor, payload, opts).run();
10151
10573
  }
10152
10574
  // SJ383: cancel a specific (conversation, agent) run if one is in flight in
@@ -10165,6 +10587,176 @@ var Dispatcher = class {
10165
10587
  }
10166
10588
  };
10167
10589
 
10590
+ // src/providers/local-process.ts
10591
+ import { spawn as spawn6 } from "child_process";
10592
+ import { constants } from "os";
10593
+ var DEFAULT_KILL_GRACE_MS = 3e4;
10594
+ var DEFAULT_REAP_GRACE_MS = 3e3;
10595
+ function exitCode(code, signal) {
10596
+ if (code !== null) return code;
10597
+ const signum = signal ? constants.signals[signal] : void 0;
10598
+ return signum !== void 0 ? 128 + signum : 1;
10599
+ }
10600
+ function createLocalProcessProvider(opts = {}) {
10601
+ const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
10602
+ const spawnFn = opts.spawnFn ?? spawn6;
10603
+ const reapGraceMs = opts.reapGraceMs ?? DEFAULT_REAP_GRACE_MS;
10604
+ const killFn = opts.killFn ?? ((pid, signal) => process.kill(pid, signal));
10605
+ const groupAlive = (pgid) => {
10606
+ try {
10607
+ killFn(-pgid, 0);
10608
+ return true;
10609
+ } catch {
10610
+ return false;
10611
+ }
10612
+ };
10613
+ const signalGroup = (pgid, signal) => {
10614
+ try {
10615
+ killFn(-pgid, signal);
10616
+ } catch {
10617
+ }
10618
+ };
10619
+ return {
10620
+ name: "local-process",
10621
+ async acquire(attempt) {
10622
+ return { provider: "local-process", id: attempt.turnId };
10623
+ },
10624
+ exec(_handle, argv, env, { signal, stdin }) {
10625
+ const [command, ...args] = argv;
10626
+ if (!command) throw new Error("local-process: exec needs a command");
10627
+ const child = spawnFn(command, args, {
10628
+ env: { ...env },
10629
+ stdio: ["pipe", "pipe", "pipe"],
10630
+ detached: process.platform !== "win32"
10631
+ });
10632
+ child.stdin?.on("error", () => {
10633
+ });
10634
+ if (stdin) child.stdin?.write(stdin);
10635
+ const exit = new Promise((resolve2) => {
10636
+ let settled = false;
10637
+ const done = (code) => {
10638
+ if (settled) return;
10639
+ settled = true;
10640
+ signal.removeEventListener("abort", stop);
10641
+ if (forceTimer) clearTimeout(forceTimer);
10642
+ resolve2(code);
10643
+ };
10644
+ let forceTimer;
10645
+ const stop = () => {
10646
+ child.kill("SIGTERM");
10647
+ forceTimer = setTimeout(() => child.kill("SIGKILL"), killGraceMs);
10648
+ forceTimer.unref?.();
10649
+ };
10650
+ child.once("error", () => done(1));
10651
+ child.once("close", (code, sig) => done(exitCode(code, sig)));
10652
+ if (signal.aborted) stop();
10653
+ else signal.addEventListener("abort", stop, { once: true });
10654
+ });
10655
+ if (!child.stdout || !child.stderr || !child.stdin)
10656
+ throw new Error("local-process: runner stdio missing");
10657
+ const pid = child.pid;
10658
+ return {
10659
+ exit,
10660
+ ...pid !== void 0 ? { pid } : {},
10661
+ stdout: child.stdout,
10662
+ stderr: child.stderr,
10663
+ stdin: child.stdin,
10664
+ async reap(groups) {
10665
+ if (process.platform === "win32") return;
10666
+ const targets = [.../* @__PURE__ */ new Set([...pid !== void 0 ? [pid] : [], ...groups])].filter(
10667
+ groupAlive
10668
+ );
10669
+ if (!targets.length) return;
10670
+ for (const pgid of targets) signalGroup(pgid, "SIGTERM");
10671
+ await new Promise((resolve2) => setTimeout(resolve2, reapGraceMs));
10672
+ for (const pgid of targets.filter(groupAlive)) signalGroup(pgid, "SIGKILL");
10673
+ }
10674
+ };
10675
+ },
10676
+ async release() {
10677
+ }
10678
+ };
10679
+ }
10680
+
10681
+ // src/runner/device-locks.ts
10682
+ var DeviceLocks = class {
10683
+ holders = /* @__PURE__ */ new Map();
10684
+ queues = /* @__PURE__ */ new Map();
10685
+ // `owner` is unique across the device; `grant` is called once, when it holds.
10686
+ request(key, owner, grant) {
10687
+ if (!this.holders.has(key)) {
10688
+ this.holders.set(key, owner);
10689
+ grant();
10690
+ return;
10691
+ }
10692
+ const queue = this.queues.get(key) ?? [];
10693
+ queue.push({ owner, grant });
10694
+ this.queues.set(key, queue);
10695
+ }
10696
+ // Gives back what `owner` holds and withdraws what it is waiting for.
10697
+ release(owner) {
10698
+ for (const [key, queue] of this.queues) {
10699
+ const rest = queue.filter((w) => w.owner !== owner);
10700
+ if (rest.length) this.queues.set(key, rest);
10701
+ else this.queues.delete(key);
10702
+ }
10703
+ for (const [key, holder] of [...this.holders]) {
10704
+ if (holder !== owner) continue;
10705
+ this.holders.delete(key);
10706
+ this.handOn(key);
10707
+ }
10708
+ }
10709
+ // What owners starting with `prefix` hold, by key — one runner's, when it
10710
+ // exits, so the launcher can decide which it may give back at once.
10711
+ heldBy(prefix) {
10712
+ return [...this.holders].filter(([, owner]) => owner.startsWith(prefix)).map(([key, owner]) => ({ key, owner }));
10713
+ }
10714
+ // Withdraws every request still waiting from owners starting with `prefix`,
10715
+ // leaving what they hold.
10716
+ withdraw(prefix) {
10717
+ for (const [key, queue] of this.queues) {
10718
+ const rest = queue.filter((w) => !w.owner.startsWith(prefix));
10719
+ if (rest.length) this.queues.set(key, rest);
10720
+ else this.queues.delete(key);
10721
+ }
10722
+ }
10723
+ holder(key) {
10724
+ return this.holders.get(key);
10725
+ }
10726
+ handOn(key) {
10727
+ const queue = this.queues.get(key);
10728
+ const next = queue?.shift();
10729
+ if (!queue?.length) this.queues.delete(key);
10730
+ if (!next) return;
10731
+ this.holders.set(key, next.owner);
10732
+ next.grant();
10733
+ }
10734
+ };
10735
+
10736
+ // src/runner/harness-sandbox.ts
10737
+ import { chmodSync as chmodSync5, mkdirSync as mkdirSync9, mkdtempSync, rmSync as rmSync7, writeFileSync as writeFileSync6 } from "fs";
10738
+ import { homedir as homedir3, tmpdir as tmpdir2, userInfo as userInfo2 } from "os";
10739
+ import { join as join15 } from "path";
10740
+ import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
10741
+ function osSandboxUsable(log) {
10742
+ if (!SandboxManager.isSupportedPlatform()) {
10743
+ log.warn(
10744
+ { platform: process.platform },
10745
+ 'runners: sandbox "os" is not available on this platform; turns run unsandboxed'
10746
+ );
10747
+ return false;
10748
+ }
10749
+ const deps = SandboxManager.checkDependencies();
10750
+ if (deps.errors.length) {
10751
+ log.warn(
10752
+ { errors: deps.errors },
10753
+ 'runners: sandbox "os" is missing what it needs on this host; turns run unsandboxed'
10754
+ );
10755
+ return false;
10756
+ }
10757
+ return true;
10758
+ }
10759
+
10168
10760
  // src/opencode-models.ts
10169
10761
  var OPENCODE_RUNTIME = "opencode";
10170
10762
  function mapOpencodeProviders(json) {
@@ -10243,12 +10835,12 @@ var DispatchClaims = class {
10243
10835
 
10244
10836
  // src/outbox.ts
10245
10837
  import {
10246
- existsSync as existsSync12,
10838
+ existsSync as existsSync11,
10247
10839
  mkdirSync as mkdirSync10,
10248
10840
  readdirSync as readdirSync3,
10249
- readFileSync as readFileSync9,
10841
+ readFileSync as readFileSync8,
10250
10842
  renameSync as renameSync4,
10251
- rmSync as rmSync7,
10843
+ rmSync as rmSync8,
10252
10844
  writeFileSync as writeFileSync7
10253
10845
  } from "fs";
10254
10846
  import { join as join16 } from "path";
@@ -10264,7 +10856,7 @@ var Outbox = class {
10264
10856
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
10265
10857
  // cases route writes at the right tmpdir.
10266
10858
  dir() {
10267
- return join16(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
10859
+ return join16(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
10268
10860
  }
10269
10861
  fileFor(turnId, seq) {
10270
10862
  return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
@@ -10282,7 +10874,7 @@ var Outbox = class {
10282
10874
  renameSync4(tmp, target);
10283
10875
  } catch (err) {
10284
10876
  try {
10285
- rmSync7(tmp, { force: true });
10877
+ rmSync8(tmp, { force: true });
10286
10878
  } catch {
10287
10879
  }
10288
10880
  this.log?.warn(
@@ -10299,7 +10891,7 @@ var Outbox = class {
10299
10891
  // wedging the drain.
10300
10892
  list() {
10301
10893
  const dir2 = this.dir();
10302
- if (!existsSync12(dir2)) return [];
10894
+ if (!existsSync11(dir2)) return [];
10303
10895
  let names;
10304
10896
  try {
10305
10897
  names = readdirSync3(dir2);
@@ -10311,7 +10903,7 @@ var Outbox = class {
10311
10903
  if (!name.endsWith(".json")) continue;
10312
10904
  const full = join16(dir2, name);
10313
10905
  try {
10314
- const parsed = JSON.parse(readFileSync9(full, "utf8"));
10906
+ const parsed = JSON.parse(readFileSync8(full, "utf8"));
10315
10907
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
10316
10908
  entries.push(parsed);
10317
10909
  } else {
@@ -10345,7 +10937,7 @@ var Outbox = class {
10345
10937
  // different: it is positive evidence that nothing was ever queued here.
10346
10938
  maxSeqForTurn(turnId) {
10347
10939
  const dir2 = this.dir();
10348
- if (!existsSync12(dir2)) return 0;
10940
+ if (!existsSync11(dir2)) return 0;
10349
10941
  const names = readdirSync3(dir2);
10350
10942
  const prefix = `${encodeURIComponent(turnId)}__`;
10351
10943
  let max = 0;
@@ -10359,13 +10951,13 @@ var Outbox = class {
10359
10951
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
10360
10952
  remove(turnId, seq) {
10361
10953
  try {
10362
- rmSync7(this.fileFor(turnId, seq), { force: true });
10954
+ rmSync8(this.fileFor(turnId, seq), { force: true });
10363
10955
  } catch {
10364
10956
  }
10365
10957
  }
10366
10958
  size() {
10367
10959
  const dir2 = this.dir();
10368
- if (!existsSync12(dir2)) return 0;
10960
+ if (!existsSync11(dir2)) return 0;
10369
10961
  try {
10370
10962
  return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
10371
10963
  } catch {
@@ -10378,7 +10970,7 @@ var Outbox = class {
10378
10970
  "A saved update is unreadable and couldn't be delivered. Check the conversation for missing replies."
10379
10971
  );
10380
10972
  try {
10381
- rmSync7(full, { force: true });
10973
+ rmSync8(full, { force: true });
10382
10974
  } catch {
10383
10975
  }
10384
10976
  }
@@ -10412,53 +11004,6 @@ var Outbox = class {
10412
11004
  }
10413
11005
  };
10414
11006
 
10415
- // src/run-config.ts
10416
- import { z as z15 } from "zod";
10417
- var mcpStdioServerSchema = z15.object({
10418
- type: z15.literal("stdio").optional(),
10419
- command: z15.string().min(1),
10420
- args: z15.array(z15.string()).optional(),
10421
- env: z15.record(z15.string(), z15.string()).optional()
10422
- });
10423
- var mcpHttpServerSchema = z15.object({
10424
- type: z15.literal("http"),
10425
- url: z15.string().url(),
10426
- headers: z15.record(z15.string(), z15.string()).optional()
10427
- });
10428
- var mcpSseServerSchema = z15.object({
10429
- type: z15.literal("sse"),
10430
- url: z15.string().url(),
10431
- headers: z15.record(z15.string(), z15.string()).optional()
10432
- });
10433
- var mcpServerDefSchema = z15.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
10434
- var thinkingConfigSchema = z15.discriminatedUnion("type", [
10435
- z15.object({ type: z15.literal("adaptive") }),
10436
- z15.object({ type: z15.literal("enabled"), budgetTokens: z15.number().int().positive().optional() }),
10437
- z15.object({ type: z15.literal("disabled") })
10438
- ]);
10439
- var effortSchema = z15.enum(["low", "medium", "high", "xhigh", "max"]);
10440
- var runConfigSchema = z15.object({
10441
- // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
10442
- // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
10443
- // is the locked surface. Kept in lockstep with `@cabane/shared`'s
10444
- // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
10445
- // stripped, so an older companion riding a newer server never rejects the config).
10446
- hostAccess: z15.boolean().optional(),
10447
- mcpServers: z15.record(z15.string(), mcpServerDefSchema).optional(),
10448
- model: z15.string().min(1).optional(),
10449
- thinking: thinkingConfigSchema.optional(),
10450
- effort: effortSchema.optional()
10451
- });
10452
- var EMPTY_RUN_CONFIG = {};
10453
- function parseRunConfig(raw, onWarn) {
10454
- const result = runConfigSchema.safeParse(raw ?? {});
10455
- if (result.success) return result.data;
10456
- onWarn?.(
10457
- `run-config from Cabane didn't parse (${result.error.issues.map((i) => i.path.join(".") || "(root)").join(", ")}) \u2014 falling back to the locked assistant default`
10458
- );
10459
- return EMPTY_RUN_CONFIG;
10460
- }
10461
-
10462
11007
  // src/sse.ts
10463
11008
  import { createParser } from "eventsource-parser";
10464
11009
  var MAX_BACKOFF_MS = 3e4;
@@ -10519,7 +11064,7 @@ var SseSubscriber = class {
10519
11064
  }
10520
11065
  async connect() {
10521
11066
  const base = this.opts.baseUrl.endsWith("/") ? this.opts.baseUrl.slice(0, -1) : this.opts.baseUrl;
10522
- const url = this.opts.path ? `${base}${this.opts.path}` : `${base}/api/workspaces/${this.opts.workspaceId}/events`;
11067
+ const url = `${base}${this.opts.path}`;
10523
11068
  this.controller = new AbortController();
10524
11069
  const headers = {
10525
11070
  Authorization: `Bearer ${this.opts.token}`,
@@ -10623,9 +11168,14 @@ var CompanionSupervisor = class {
10623
11168
  exitFn;
10624
11169
  reexecFn;
10625
11170
  dispatcherFactory;
11171
+ runnerCommand;
11172
+ providerForRunners;
10626
11173
  unrunnableRetryDelaysMs;
10627
11174
  deviceApi = null;
10628
11175
  deviceSub = null;
11176
+ // ACC8: whether the device stream is open, which is what every workspace's
11177
+ // link on the dashboard reports.
11178
+ deviceConnected = false;
10629
11179
  recoveryChecked = false;
10630
11180
  recovering = false;
10631
11181
  heartbeated = false;
@@ -10664,6 +11214,8 @@ var CompanionSupervisor = class {
10664
11214
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
10665
11215
  this.reexecFn = opts.reexec ?? defaultReexec;
10666
11216
  this.dispatcherFactory = opts.dispatcherFactory;
11217
+ if (opts.runnerCommand) this.runnerCommand = opts.runnerCommand;
11218
+ if (opts.runnerProvider) this.providerForRunners = opts.runnerProvider;
10667
11219
  this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
10668
11220
  this.latchBootBundledState();
10669
11221
  }
@@ -10692,7 +11244,18 @@ var CompanionSupervisor = class {
10692
11244
  token: this.config.deviceToken,
10693
11245
  log: this.log,
10694
11246
  lastEventId: null,
10695
- onOpen: () => this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname2()}`),
11247
+ onOpen: () => {
11248
+ this.log.info(`Connected to Cabane as ${this.hub.statusJson().device_label ?? hostname2()}`);
11249
+ this.deviceConnected = true;
11250
+ for (const wr of this.workspaces.values()) {
11251
+ this.hub.setConnected(wr.workspaceId, true);
11252
+ for (const a of wr.agents.values()) a.kickDrain();
11253
+ }
11254
+ },
11255
+ onClose: () => {
11256
+ this.deviceConnected = false;
11257
+ for (const wr of this.workspaces.values()) this.hub.setConnected(wr.workspaceId, false);
11258
+ },
10696
11259
  onMessage: async (ev) => {
10697
11260
  if (ev.event === "assignments_changed") {
10698
11261
  if (this.refreshing && this.inFlightRefresh) await this.inFlightRefresh;
@@ -10724,10 +11287,7 @@ var CompanionSupervisor = class {
10724
11287
  // at least the loops keeping the event loop alive; the SSE promises let a
10725
11288
  // clean teardown resolve.
10726
11289
  finishedPromises() {
10727
- return [
10728
- ...this.deviceSub ? [this.deviceSub.finished] : [],
10729
- ...[...this.workspaces.values()].flatMap((w) => w.sub ? [w.sub.finished] : [])
10730
- ];
11290
+ return [...this.deviceSub ? [this.deviceSub.finished] : []];
10731
11291
  }
10732
11292
  // ---- device-level loops ----
10733
11293
  kickHeartbeat() {
@@ -10875,11 +11435,8 @@ var CompanionSupervisor = class {
10875
11435
  workspaceId,
10876
11436
  slug: first.workspaceSlug,
10877
11437
  name: first.workspaceName,
10878
- sub: null,
10879
- sseAgentId: null,
10880
11438
  agents: /* @__PURE__ */ new Map(),
10881
- chains: /* @__PURE__ */ new Map(),
10882
- cursor: new CursorTracker(workspaceId)
11439
+ chains: /* @__PURE__ */ new Map()
10883
11440
  };
10884
11441
  this.workspaces.set(workspaceId, wr);
10885
11442
  this.hub.registerWorkspace({
@@ -10887,6 +11444,7 @@ var CompanionSupervisor = class {
10887
11444
  slug: first.workspaceSlug,
10888
11445
  name: first.workspaceName
10889
11446
  });
11447
+ this.hub.setConnected(workspaceId, this.deviceConnected);
10890
11448
  }
10891
11449
  const store = loadSecretStoreTolerant();
10892
11450
  const exposed = new Set(store.names());
@@ -10946,7 +11504,7 @@ var CompanionSupervisor = class {
10946
11504
  );
10947
11505
  }
10948
11506
  }
10949
- this.ensureWorkspaceSse(wr);
11507
+ if (wr.agents.size === 0) void this.removeWorkspace(wr.workspaceId);
10950
11508
  }
10951
11509
  addAgent(wr, it, credential, runConfig) {
10952
11510
  const agentLog = this.log.child({ agentName: it.agentDisplayName });
@@ -11103,6 +11661,21 @@ var CompanionSupervisor = class {
11103
11661
  );
11104
11662
  }
11105
11663
  }
11664
+ // CT1679: the locks every runner on this device shares, held here.
11665
+ runnerLocks = new DeviceLocks();
11666
+ // CT1679: `runners.sandbox`, checked against this host once. A host that
11667
+ // can't make the sandbox says so once and runs its turns unwrapped.
11668
+ sandboxUsable;
11669
+ runnerSandbox() {
11670
+ if (this.config.runners?.sandbox !== "os") return "none";
11671
+ this.sandboxUsable ??= osSandboxUsable(this.log);
11672
+ return this.sandboxUsable ? "os" : "none";
11673
+ }
11674
+ // CT1679: one provider for the device, built on first use.
11675
+ runnerProvider() {
11676
+ this.providerForRunners ??= createLocalProcessProvider();
11677
+ return this.providerForRunners;
11678
+ }
11106
11679
  buildDispatcher(ctx) {
11107
11680
  const local = localAgentConfig(this.config, {
11108
11681
  agentId: ctx.agentId,
@@ -11153,74 +11726,31 @@ var CompanionSupervisor = class {
11153
11726
  observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
11154
11727
  transcriptDir: transcriptsDir(),
11155
11728
  // CT566: the shared connector-health memory the turn records into on settle.
11156
- connectorHealth: this.connectorHealth
11157
- });
11158
- }
11159
- // Stand up (or re-point) the workspace's single shared SSE subscription. It is
11160
- // authed with one assigned agent's PAT; if that agent is unassigned, re-pick
11161
- // another. With no agents left the workspace runner is torn down.
11162
- ensureWorkspaceSse(wr) {
11163
- if (wr.agents.size === 0) {
11164
- void this.removeWorkspace(wr.workspaceId);
11165
- return;
11166
- }
11167
- if (wr.sub && wr.sseAgentId && wr.agents.has(wr.sseAgentId)) return;
11168
- const sseAgent = wr.agents.get(wr.sseAgentId ?? "") ?? [...wr.agents.values()][0];
11169
- if (wr.sub) wr.sub.stop();
11170
- wr.sseAgentId = sseAgent.agentId;
11171
- wr.sub = new SseSubscriber({
11172
- baseUrl: this.config.baseUrl,
11173
- workspaceId: wr.workspaceId,
11174
- token: sseAgent.credential,
11175
- log: this.log,
11176
- lastEventId: readCursor(wr.workspaceId),
11177
- onOpen: () => {
11178
- this.hub.setConnected(wr.workspaceId, true);
11179
- for (const a of wr.agents.values()) a.kickDrain();
11180
- },
11181
- onClose: () => this.hub.setConnected(wr.workspaceId, false),
11182
- onMessage: (ev) => this.handleWorkspaceMessage(wr, ev),
11183
- onAuthFailure: (status) => {
11184
- this.hub.setAuthFailed(wr.workspaceId);
11185
- this.log.debug(
11186
- { workspaceId: wr.workspaceId, status, sseAgentId: wr.sseAgentId },
11187
- "companion: workspace stream auth failed \u2014 re-pulling assignments"
11188
- );
11189
- wr.sseAgentId = null;
11190
- void this.refreshAssignments();
11191
- }
11729
+ connectorHealth: this.connectorHealth,
11730
+ ...runnersEnabled(this.config) ? {
11731
+ runner: {
11732
+ provider: this.runnerProvider(),
11733
+ command: this.runnerCommand ?? selfRunnerCommand(),
11734
+ ...this.config.deviceToken ? { deviceToken: this.config.deviceToken } : {},
11735
+ connectorHealth: this.connectorHealth,
11736
+ locks: this.runnerLocks,
11737
+ sandbox: this.runnerSandbox()
11738
+ }
11739
+ } : {}
11192
11740
  });
11193
- wr.sub.start();
11194
- this.log.debug({ workspaceId: wr.workspaceId }, "companion: subscribed");
11195
11741
  }
11196
11742
  async removeWorkspace(workspaceId) {
11197
11743
  const wr = this.workspaces.get(workspaceId);
11198
11744
  if (!wr) return;
11199
11745
  for (const agentId of [...wr.agents.keys()]) this.removeAgent(wr, agentId);
11200
- if (wr.sub) {
11201
- wr.sub.stop();
11202
- await wr.sub.finished;
11203
- }
11204
11746
  this.workspaces.delete(workspaceId);
11205
11747
  this.hub.removeWorkspace(workspaceId);
11206
11748
  }
11207
11749
  // ---- SSE event routing (per workspace, BY agentId) ----
11208
- // Fire-and-forget per event (the subscriber doesn't await us). Cancel events
11209
- // route to the matching agent immediately; dispatch events serialize per
11210
- // (conversation, agent); everything else just advances the cursor.
11211
- async handleWorkspaceMessage(wr, ev) {
11212
- if (ev.event !== "message") return;
11213
- this.hub.recordEvent(wr.workspaceId);
11214
- const wire = this.parseFrame(ev);
11215
- if (!wire) return;
11216
- if (ev.id) wr.cursor.observe(ev.id);
11217
- await this.deliverFrame(wr, ev.id, wire);
11218
- }
11219
11750
  // ACC10: the device stream's frames — dispatch and cancel, addressed to this
11220
- // device and authenticated as it. Same frames and the same routing as above;
11221
- // what differs is that this stream keeps no cursor (it replays what is still
11222
- // offered on every connect instead of resuming from a `Last-Event-ID`), so
11223
- // nothing here observes or settles one.
11751
+ // device and authenticated as it. ACC8: the only stream this companion opens.
11752
+ // It keeps no cursor: it replays what is still offered on every connect
11753
+ // instead of resuming from a `Last-Event-ID`.
11224
11754
  async handleDeviceMessage(ev) {
11225
11755
  const wire = this.parseFrame(ev);
11226
11756
  if (!wire) return;
@@ -11243,7 +11773,7 @@ var CompanionSupervisor = class {
11243
11773
  }
11244
11774
  }
11245
11775
  this.hub.recordEvent(wr.workspaceId);
11246
- await this.deliverFrame(wr, "", wire);
11776
+ await this.deliverFrame(wr, wire);
11247
11777
  }
11248
11778
  parseFrame(ev) {
11249
11779
  try {
@@ -11256,30 +11786,27 @@ var CompanionSupervisor = class {
11256
11786
  return null;
11257
11787
  }
11258
11788
  }
11259
- // ACC10: the dedupe gate, and the only place that exists because there are two
11260
- // streams. Both carry the same frame with the same dispatch id; the first copy
11261
- // to arrive runs the turn, and the second waits for it and then settles its own
11262
- // stream's cursor. Settling a duplicate earlier than that would advance the
11263
- // low-water mark past an in-flight turn, which is exactly the CT282 bug.
11789
+ // ACC10: the dedupe gate. A reconnect replays every dispatch still offered,
11790
+ // including one whose turn is already running here; the first copy runs the
11791
+ // turn and a later one waits for it and then does nothing.
11264
11792
  //
11265
11793
  // A cancel needs no gate: `dispatcher.cancel` on an already-aborted run is a
11266
11794
  // no-op that reports `false`, so a second copy is inert.
11267
- async deliverFrame(wr, cursorId, wire) {
11795
+ async deliverFrame(wr, wire) {
11268
11796
  const claim = wire.type === "device:dispatch_requested" && wire.id ? this.claims.claim(wire.id) : null;
11269
11797
  if (claim && !claim.first) {
11270
11798
  await claim.done;
11271
- if (cursorId) wr.cursor.settle(cursorId);
11272
11799
  return;
11273
11800
  }
11274
11801
  try {
11275
- await this.routeFrame(wr, cursorId, wire);
11802
+ await this.routeFrame(wr, wire);
11276
11803
  } finally {
11277
11804
  if (claim) this.claims.release(wire.id);
11278
11805
  }
11279
11806
  }
11280
11807
  // Everything below here is the routing as it was: it takes a frame and knows
11281
11808
  // nothing about which stream carried it.
11282
- async routeFrame(wr, cursorId, wire) {
11809
+ async routeFrame(wr, wire) {
11283
11810
  if (wire.type === "device:cancel_requested") {
11284
11811
  const payload2 = {
11285
11812
  type: "device:cancel_requested",
@@ -11299,12 +11826,10 @@ var CompanionSupervisor = class {
11299
11826
  );
11300
11827
  }
11301
11828
  }
11302
- if (cursorId) wr.cursor.settle(cursorId);
11303
11829
  return;
11304
11830
  }
11305
11831
  const payload = dispatchPayload(wire);
11306
11832
  if (!payload) {
11307
- if (cursorId) wr.cursor.settle(cursorId);
11308
11833
  return;
11309
11834
  }
11310
11835
  if (this.deviceId && payload.deviceId && payload.deviceId !== this.deviceId) {
@@ -11317,7 +11842,6 @@ var CompanionSupervisor = class {
11317
11842
  },
11318
11843
  "companion: dispatch addressed to another device; ignoring"
11319
11844
  );
11320
- if (cursorId) wr.cursor.settle(cursorId);
11321
11845
  return;
11322
11846
  }
11323
11847
  let agent = wr.agents.get(payload.agentId);
@@ -11325,16 +11849,14 @@ var CompanionSupervisor = class {
11325
11849
  agent = await this.recoverRacedAgent(wr, payload);
11326
11850
  if (!agent) {
11327
11851
  if (this.deviceId && payload.deviceId === this.deviceId) {
11328
- const reported = await this.reportUnrunnableDispatch(wr.workspaceId, payload);
11329
- if (!reported) return;
11852
+ await this.reportUnrunnableDispatch(wr.workspaceId, payload);
11330
11853
  }
11331
- if (cursorId) wr.cursor.settle(cursorId);
11332
11854
  return;
11333
11855
  }
11334
11856
  }
11335
11857
  const chainKey = `${payload.conversationId}|${payload.agentId}`;
11336
11858
  const prev = wr.chains.get(chainKey) ?? Promise.resolve();
11337
- const tail = prev.then(() => this.runDispatch(wr, { dispatchId: wire.id, cursorId }, payload, agent)).catch((err) => {
11859
+ const tail = prev.then(() => this.runDispatch(wr, wire.id, payload, agent)).catch((err) => {
11338
11860
  this.log.warn(
11339
11861
  {
11340
11862
  workspaceId: wr.workspaceId,
@@ -11351,8 +11873,7 @@ var CompanionSupervisor = class {
11351
11873
  }
11352
11874
  // CT1146: tell the server this device cannot run a dispatch it was addressed to.
11353
11875
  //
11354
- // Returns whether the report LANDED, because that answer decides whether the
11355
- // caller may settle the cursor. The server checks the recorded dispatch
11876
+ // Returns whether the report LANDED. The server checks the recorded dispatch
11356
11877
  // destination even if the agent's assignment has moved. Reporting promptly
11357
11878
  // explains the roster miss; the thirty-minute unanswered-offer deadline is
11358
11879
  // only the backstop when no report lands.
@@ -11446,10 +11967,10 @@ var CompanionSupervisor = class {
11446
11967
  };
11447
11968
  const tail = this.runDispatch(
11448
11969
  wr,
11449
- // No frame delivered this one — it came off `/runs` — so there is
11450
- // neither a dispatch id to dedupe on nor a cursor to settle. The
11451
- // `recoveredTurns` set above is this path's own guard.
11452
- { dispatchId: "", cursorId: "" },
11970
+ // No frame delivered this one — it came off `/runs` — so there is no
11971
+ // dispatch id to dedupe on. The `recoveredTurns` set above is this
11972
+ // path's own guard.
11973
+ "",
11453
11974
  payload,
11454
11975
  agent,
11455
11976
  run.turnId
@@ -11471,61 +11992,55 @@ var CompanionSupervisor = class {
11471
11992
  }
11472
11993
  }
11473
11994
  // One turn: the SJ516 F7 / CT26 skip-vs-resume dedupe, the dispatch, then
11474
- // cursor + console summary. Mirrors the old per-workspace `handleEvent`, now
11995
+ // the console summary. Mirrors the old per-workspace `handleEvent`, now
11475
11996
  // scoped to one agent.
11476
- async runDispatch(wr, ids, payload, agent, recoveredTurnId) {
11997
+ async runDispatch(wr, dispatchId, payload, agent, recoveredTurnId) {
11477
11998
  const workspaceId = wr.workspaceId;
11478
- const settleCursor = () => {
11479
- if (ids.cursorId) wr.cursor.settle(ids.cursorId);
11480
- };
11481
- if (ids.dispatchId && hasDispatched(workspaceId, ids.dispatchId)) {
11482
- if (hasCompleted(workspaceId, ids.dispatchId)) {
11999
+ if (dispatchId && hasDispatched(workspaceId, dispatchId)) {
12000
+ if (hasCompleted(workspaceId, dispatchId)) {
11483
12001
  this.log.debug(
11484
- { workspaceId, eventId: ids.dispatchId },
12002
+ { workspaceId, eventId: dispatchId },
11485
12003
  "companion: skipping already-completed event (resume after restart)"
11486
12004
  );
11487
- settleCursor();
11488
12005
  return;
11489
12006
  }
11490
12007
  if (noResume()) {
11491
12008
  this.log.debug(
11492
- { workspaceId, eventId: ids.dispatchId },
12009
+ { workspaceId, eventId: dispatchId },
11493
12010
  "companion: skipping interrupted turn (CABANE_COMPANION_NO_RESUME=1) \u2014 resume disabled for this boot"
11494
12011
  );
11495
- markCompleted(workspaceId, ids.dispatchId);
11496
- settleCursor();
12012
+ markCompleted(workspaceId, dispatchId);
11497
12013
  return;
11498
12014
  }
11499
- const attempt = bumpResumeAttempt(workspaceId, ids.dispatchId);
12015
+ const attempt = bumpResumeAttempt(workspaceId, dispatchId);
11500
12016
  if (attempt > MAX_RESUME_ATTEMPTS) {
11501
12017
  this.log.error(
11502
- { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
12018
+ { workspaceId, eventId: dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11503
12019
  "An interrupted turn couldn't finish after several attempts. Reply in Cabane to try again."
11504
12020
  );
11505
- markCompleted(workspaceId, ids.dispatchId);
11506
- settleCursor();
12021
+ markCompleted(workspaceId, dispatchId);
11507
12022
  return;
11508
12023
  }
11509
12024
  this.log.debug(
11510
- { workspaceId, eventId: ids.dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
12025
+ { workspaceId, eventId: dispatchId, attempt, cap: MAX_RESUME_ATTEMPTS },
11511
12026
  "companion: re-dispatching interrupted turn (resume after restart)"
11512
12027
  );
11513
12028
  }
11514
- const resumedTurnId = recoveredTurnId ?? (ids.dispatchId ? turnIdForEvent(workspaceId, ids.dispatchId) : null);
11515
- const turnId = resumedTurnId ?? randomUUID4();
11516
- if (ids.dispatchId) {
11517
- const liveTurnIds = rememberTurnId(workspaceId, ids.dispatchId, turnId);
12029
+ const resumedTurnId = recoveredTurnId ?? (dispatchId ? turnIdForEvent(workspaceId, dispatchId) : null);
12030
+ const turnId = resumedTurnId ?? randomUUID5();
12031
+ if (dispatchId) {
12032
+ const liveTurnIds = rememberTurnId(workspaceId, dispatchId, turnId);
11518
12033
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
11519
12034
  this.log.debug(
11520
12035
  { workspaceId, liveTurnIds, threshold: TURN_ID_OVERFLOW_WARN },
11521
12036
  "companion: in-flight turn-id map is implausibly large \u2014 completion pruning is likely broken. Keeping every mapping; dropping one would silently lose a resumable turn."
11522
12037
  );
11523
12038
  }
11524
- markDispatched(workspaceId, ids.dispatchId);
12039
+ markDispatched(workspaceId, dispatchId);
11525
12040
  }
11526
12041
  if (resumedTurnId) {
11527
12042
  this.log.debug(
11528
- { workspaceId, eventId: ids.dispatchId, turnId },
12043
+ { workspaceId, eventId: dispatchId, turnId },
11529
12044
  "companion: resuming an interrupted turn under its original id"
11530
12045
  );
11531
12046
  }
@@ -11540,8 +12055,7 @@ var CompanionSupervisor = class {
11540
12055
  turnId,
11541
12056
  resumed: resumedTurnId !== null
11542
12057
  });
11543
- if (ids.dispatchId) markCompleted(workspaceId, ids.dispatchId);
11544
- settleCursor();
12058
+ if (dispatchId) markCompleted(workspaceId, dispatchId);
11545
12059
  const durationS = (result.durationMs / 1e3).toFixed(1);
11546
12060
  const bindings = {
11547
12061
  ...turnBindings,
@@ -11790,12 +12304,10 @@ var CompanionSupervisor = class {
11790
12304
  this.deviceSub?.stop();
11791
12305
  for (const wr of this.workspaces.values()) {
11792
12306
  for (const a of wr.agents.values()) a.cancelDrain();
11793
- if (wr.sub) wr.sub.stop();
11794
12307
  }
11795
12308
  await Promise.all([
11796
12309
  ...heartbeat ? [waitForBoundedHeartbeat(heartbeat)] : [],
11797
- ...this.deviceSub ? [this.deviceSub.finished] : [],
11798
- ...[...this.workspaces.values()].flatMap((wr) => wr.sub ? [wr.sub.finished] : [])
12310
+ ...this.deviceSub ? [this.deviceSub.finished] : []
11799
12311
  ]);
11800
12312
  }
11801
12313
  async requestStop() {
@@ -11820,9 +12332,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
11820
12332
  }
11821
12333
  function defaultReexec() {
11822
12334
  clearRuntimeState();
11823
- void import("child_process").then(({ spawn: spawn6 }) => {
12335
+ void import("child_process").then(({ spawn: spawn7 }) => {
11824
12336
  try {
11825
- const child = spawn6(process.execPath, process.argv.slice(1), {
12337
+ const child = spawn7(process.execPath, process.argv.slice(1), {
11826
12338
  stdio: "inherit",
11827
12339
  detached: false
11828
12340
  });
@@ -11892,7 +12404,7 @@ function handleUncaught(log, err, origin) {
11892
12404
  }
11893
12405
 
11894
12406
  // src/crash-marker.ts
11895
- import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync10, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
12407
+ import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync9, writeFileSync as writeFileSync8 } from "fs";
11896
12408
  import { join as join17 } from "path";
11897
12409
  function crashMarkerPath() {
11898
12410
  return join17(cabaneDir(), "last-error.json");
@@ -11907,7 +12419,7 @@ function recordCrash(rec) {
11907
12419
  function clearCrash() {
11908
12420
  try {
11909
12421
  const path = crashMarkerPath();
11910
- if (existsSync13(path)) rmSync8(path, { force: true });
12422
+ if (existsSync12(path)) rmSync9(path, { force: true });
11911
12423
  } catch {
11912
12424
  }
11913
12425
  }
@@ -11950,7 +12462,7 @@ async function createCompanionRuntime(opts = {}) {
11950
12462
  opencodeServerUrl: cfg.opencode?.serverUrl,
11951
12463
  codex: isCodexEnabled(cfg)
11952
12464
  });
11953
- const instanceId = randomUUID5();
12465
+ const instanceId = randomUUID6();
11954
12466
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
11955
12467
  const pre = readLiveRuntimeState();
11956
12468
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();