@cabane/companion 0.6.121 → 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";
@@ -7811,258 +7827,990 @@ var ConnectorHealthStore = class {
7811
7827
  };
7812
7828
 
7813
7829
  // src/dispatcher.ts
7830
+ import { randomUUID as randomUUID4 } from "crypto";
7814
7831
  import { existsSync as existsSync10, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7815
7832
  import { join as join14 } from "path";
7816
7833
 
7817
- // src/term.ts
7834
+ // src/runner/launch.ts
7818
7835
  import { createInterface } from "readline";
7819
- import { homedir as homedir2 } from "os";
7820
- import pc from "picocolors";
7821
- function tildePath(path) {
7822
- const home = homedir2();
7823
- if (home && path.startsWith(home + "/")) return `~${path.slice(home.length)}`;
7824
- return path;
7825
- }
7826
-
7827
- // src/turn-execution.ts
7828
- import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
7829
- import { existsSync as existsSync9 } from "fs";
7830
7836
 
7831
- // src/turn-control-tools.ts
7832
- import { randomUUID as randomUUID2 } from "crypto";
7833
- import { z as z13 } from "zod";
7834
- var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
7835
- var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
7836
- var SKIP_TURN_TOOL = "skip_turn";
7837
- var ASK_TOOL = "ask";
7838
- var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
7839
- var WITHDRAW_ASK_TOOL = "withdraw_ask";
7840
- var WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
7841
- var WAKE_ME_TOOL = "wake_me";
7842
- var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
7843
- var CANCEL_WAKE_TOOL = "cancel_wake";
7844
- var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
7845
- var TURN_ACT_TIMEOUT_MS = 45e3;
7846
- function createSkipState() {
7847
- return { skipped: false, reason: null };
7837
+ // src/runner/opencode-lock.ts
7838
+ function opencodeLockKey(serverUrl) {
7839
+ return `opencode:${serverUrl}`;
7848
7840
  }
7849
- function toolError(text) {
7850
- return { isError: true, content: [{ type: "text", text }] };
7851
- }
7852
- async function performAct(acts, act) {
7853
- let result;
7841
+ async function stopRemoteOpencodeSession(serverUrl, sessionId, opts = {}) {
7842
+ const doFetch = opts.fetchImpl ?? fetch;
7843
+ const base = serverUrl.replace(/\/+$/, "");
7844
+ const id = encodeURIComponent(sessionId);
7854
7845
  try {
7855
- result = await acts.api.turnAct(
7856
- acts.workspaceId,
7857
- acts.turnId,
7858
- act,
7859
- AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
7860
- );
7861
- } catch (err) {
7862
- if (err instanceof ApiError && err.status >= 500) {
7863
- return uncertainOutcome(`${err.status}: ${apiErrorMessage(err)}`);
7864
- }
7865
- if (err instanceof ApiError) {
7866
- if (err.status === 409 && apiErrorCode(err) === "no_active_turn") {
7867
- return toolError(
7868
- "No active turn: this turn has already settled, so the act was not performed."
7869
- );
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;
7870
7860
  }
7871
- return toolError(`The act was refused (${err.status}): ${apiErrorMessage(err)}`);
7861
+ } catch {
7872
7862
  }
7873
- return uncertainOutcome(err instanceof Error ? err.message : String(err));
7863
+ await new Promise((resolve2) => setTimeout(resolve2, opts.pollMs ?? 500));
7874
7864
  }
7875
- if (!result.ok) return toolError(JSON.stringify(result));
7876
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
7865
+ return false;
7877
7866
  }
7878
- function uncertainOutcome(cause) {
7879
- return toolError(
7880
- `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.`
7881
- );
7867
+
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}`;
7882
7880
  }
7883
- function createTurnControlMcpServer(acts, skipState) {
7884
- const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
7885
- const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
7886
- const RECORDER_TOOL = { readOnlyHint: true, openWorldHint: false };
7887
- return createSdkMcpServer({
7888
- name: COMPANION_LOCAL_MCP_SERVER,
7889
- version: "0.0.0",
7890
- tools: [
7891
- ...skipState ? [
7892
- tool(
7893
- SKIP_TURN_TOOL,
7894
- TURN_CONTROL_TOOL_DESCRIPTIONS.skip_turn,
7895
- {
7896
- reason: z13.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
7897
- },
7898
- async (args) => {
7899
- skipState.skipped = true;
7900
- skipState.reason = args.reason;
7901
- return {
7902
- content: [{ type: "text", text: JSON.stringify({ skipped: true }) }]
7903
- };
7904
- },
7905
- { annotations: RECORDER_TOOL, alwaysLoad: true }
7906
- )
7907
- ] : [],
7908
- tool(
7909
- ASK_TOOL,
7910
- TURN_CONTROL_TOOL_DESCRIPTIONS.ask,
7911
- {
7912
- targetUserId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
7913
- question: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
7914
- headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
7915
- options: z13.array(
7916
- z13.union([
7917
- z13.string().min(1).max(MAX_ASK_OPTION_BYTES),
7918
- z13.object({
7919
- label: z13.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
7920
- move: z13.object({
7921
- task: z13.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
7922
- status: z13.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
7923
- }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
7924
- })
7925
- ])
7926
- ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
7927
- questions: z13.array(
7928
- z13.object({
7929
- headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
7930
- body: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
7931
- options: z13.array(
7932
- z13.union([
7933
- z13.string().min(1).max(MAX_ASK_OPTION_BYTES),
7934
- z13.object({
7935
- label: z13.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
7936
- move: z13.object({
7937
- task: z13.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
7938
- status: z13.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
7939
- }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
7940
- })
7941
- ])
7942
- ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
7943
- })
7944
- ).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
7945
- replaces: z13.object({
7946
- askId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
7947
- reason: z13.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
7948
- }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.replaces)
7949
- },
7950
- async (args) => {
7951
- const hasSingle = args.question !== void 0;
7952
- const hasArray = args.questions !== void 0 && args.questions.length > 0;
7953
- if (hasSingle && hasArray) {
7954
- return toolError(
7955
- "Provide either `question` (single) or `questions` (array), not both."
7956
- );
7957
- }
7958
- if (!hasSingle && !hasArray) {
7959
- return toolError("Provide `question` (single) or `questions` (array).");
7960
- }
7961
- return performAct(acts, {
7962
- kind: "ask",
7963
- callId: randomUUID2(),
7964
- // CT1350: the swap rides the ask, so the server commits both halves
7965
- // in one transaction — the person never sees neither or both.
7966
- ...args.replaces ? { replaces: args.replaces } : {},
7967
- ask: hasArray ? {
7968
- targetUserId: args.targetUserId,
7969
- questions: args.questions.map((q) => ({
7970
- headline: q.headline,
7971
- ...q.body ? { body: q.body } : {},
7972
- ...q.options && q.options.length > 0 ? { options: q.options } : {}
7973
- }))
7974
- } : {
7975
- targetUserId: args.targetUserId,
7976
- question: args.question,
7977
- ...args.headline ? { headline: args.headline } : {},
7978
- ...args.options && args.options.length > 0 ? { options: args.options } : {}
7979
- }
7980
- });
7981
- },
7982
- { annotations: ACT_TOOL, alwaysLoad: true }
7983
- ),
7984
- tool(
7985
- WAKE_ME_TOOL,
7986
- TURN_CONTROL_TOOL_DESCRIPTIONS.wake_me,
7987
- {
7988
- afterSeconds: z13.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
7989
- at: z13.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
7990
- note: z13.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
7991
- },
7992
- async (args) => {
7993
- const hasAfter = args.afterSeconds !== void 0;
7994
- const hasAt = args.at !== void 0;
7995
- if (hasAfter && hasAt) {
7996
- return toolError(
7997
- "Provide either `afterSeconds` (relative) or `at` (absolute), not both."
7998
- );
7999
- }
8000
- if (!hasAfter && !hasAt) {
8001
- return toolError("Provide `afterSeconds` or `at`.");
8002
- }
8003
- return performAct(acts, {
8004
- kind: "wake",
8005
- callId: randomUUID2(),
8006
- wake: {
8007
- ...hasAfter ? { afterSeconds: args.afterSeconds } : {},
8008
- ...hasAt ? { at: args.at } : {},
8009
- note: args.note
8010
- }
8011
- });
8012
- },
8013
- { annotations: ACT_TOOL, alwaysLoad: true }
8014
- ),
8015
- // CT1350: the standalone withdrawal. Not annotated destructive — an ask
8016
- // is a question, not data, and a harness that gated this on a live
8017
- // operator would hang a turn nobody is watching (the `cancel_wake`
8018
- // reasoning). Not idempotent either: a second call with the same id is
8019
- // refused with `ask_not_open`, which is information, not a no-op.
8020
- tool(
8021
- WITHDRAW_ASK_TOOL,
8022
- TURN_CONTROL_TOOL_DESCRIPTIONS.withdraw_ask,
8023
- {
8024
- askId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
8025
- reason: z13.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
8026
- },
8027
- async (args) => performAct(acts, {
8028
- kind: "withdraw_ask",
8029
- callId: randomUUID2(),
8030
- withdraw: { askId: args.askId, reason: args.reason }
8031
- }),
8032
- { annotations: ACT_TOOL, alwaysLoad: true }
8033
- ),
8034
- // CT990: the inverse. No arguments — it can only ever stand down the
8035
- // caller's own wake in the conversation it's holding a turn in.
8036
- tool(
8037
- CANCEL_WAKE_TOOL,
8038
- TURN_CONTROL_TOOL_DESCRIPTIONS.cancel_wake,
8039
- {},
8040
- async () => performAct(acts, { kind: "cancel_wake", callId: randomUUID2() }),
8041
- { annotations: CANCEL_TOOL, alwaysLoad: true }
8042
- )
8043
- ]
8044
- });
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
+ }
8045
7893
  }
8046
-
8047
- // src/build-options.ts
8048
- function cabaneMcpUrl(baseUrl) {
8049
- return `${trimSlash2(baseUrl)}/api/mcp`;
7894
+ function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
7895
+ if (process.platform !== "linux") return false;
7896
+ try {
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
+ }
8050
7906
  }
8051
- function turnControlMcpUrl(baseUrl) {
8052
- return `${trimSlash2(baseUrl)}/api/turn-control`;
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";
8053
7916
  }
8054
- function buildCompanionTurnRequest(params) {
8055
- const { turnContext: t } = params;
8056
- return {
8057
- systemPrompt: t.systemPrompt,
8058
- prompt: t.prompt,
8059
- content: t.content,
8060
- config: t.config,
8061
- policy: t.policy,
8062
- session: t.session,
8063
- cabane: {
8064
- mcpUrl: cabaneMcpUrl(params.baseUrl),
8065
- bearer: params.turnToken,
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
+ };
7940
+ } catch (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
+ });
7981
+ }
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 }
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
+ });
8047
+ }
8048
+ }
8049
+ }
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"])
8671
+ }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
8672
+ })
8673
+ ])
8674
+ ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
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"])
8687
+ }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
8688
+ })
8689
+ ])
8690
+ ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
8691
+ })
8692
+ ).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
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"])
8696
+ }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.replaces)
8697
+ },
8698
+ async (args) => {
8699
+ const hasSingle = args.question !== void 0;
8700
+ const hasArray = args.questions !== void 0 && args.questions.length > 0;
8701
+ if (hasSingle && hasArray) {
8702
+ return toolError(
8703
+ "Provide either `question` (single) or `questions` (array), not both."
8704
+ );
8705
+ }
8706
+ if (!hasSingle && !hasArray) {
8707
+ return toolError("Provide `question` (single) or `questions` (array).");
8708
+ }
8709
+ return performAct(acts, {
8710
+ kind: "ask",
8711
+ callId: randomUUID2(),
8712
+ // CT1350: the swap rides the ask, so the server commits both halves
8713
+ // in one transaction — the person never sees neither or both.
8714
+ ...args.replaces ? { replaces: args.replaces } : {},
8715
+ ask: hasArray ? {
8716
+ targetUserId: args.targetUserId,
8717
+ questions: args.questions.map((q) => ({
8718
+ headline: q.headline,
8719
+ ...q.body ? { body: q.body } : {},
8720
+ ...q.options && q.options.length > 0 ? { options: q.options } : {}
8721
+ }))
8722
+ } : {
8723
+ targetUserId: args.targetUserId,
8724
+ question: args.question,
8725
+ ...args.headline ? { headline: args.headline } : {},
8726
+ ...args.options && args.options.length > 0 ? { options: args.options } : {}
8727
+ }
8728
+ });
8729
+ },
8730
+ { annotations: ACT_TOOL, alwaysLoad: true }
8731
+ ),
8732
+ tool(
8733
+ WAKE_ME_TOOL,
8734
+ TURN_CONTROL_TOOL_DESCRIPTIONS.wake_me,
8735
+ {
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)
8739
+ },
8740
+ async (args) => {
8741
+ const hasAfter = args.afterSeconds !== void 0;
8742
+ const hasAt = args.at !== void 0;
8743
+ if (hasAfter && hasAt) {
8744
+ return toolError(
8745
+ "Provide either `afterSeconds` (relative) or `at` (absolute), not both."
8746
+ );
8747
+ }
8748
+ if (!hasAfter && !hasAt) {
8749
+ return toolError("Provide `afterSeconds` or `at`.");
8750
+ }
8751
+ return performAct(acts, {
8752
+ kind: "wake",
8753
+ callId: randomUUID2(),
8754
+ wake: {
8755
+ ...hasAfter ? { afterSeconds: args.afterSeconds } : {},
8756
+ ...hasAt ? { at: args.at } : {},
8757
+ note: args.note
8758
+ }
8759
+ });
8760
+ },
8761
+ { annotations: ACT_TOOL, alwaysLoad: true }
8762
+ ),
8763
+ // CT1350: the standalone withdrawal. Not annotated destructive — an ask
8764
+ // is a question, not data, and a harness that gated this on a live
8765
+ // operator would hang a turn nobody is watching (the `cancel_wake`
8766
+ // reasoning). Not idempotent either: a second call with the same id is
8767
+ // refused with `ask_not_open`, which is information, not a no-op.
8768
+ tool(
8769
+ WITHDRAW_ASK_TOOL,
8770
+ TURN_CONTROL_TOOL_DESCRIPTIONS.withdraw_ask,
8771
+ {
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)
8774
+ },
8775
+ async (args) => performAct(acts, {
8776
+ kind: "withdraw_ask",
8777
+ callId: randomUUID2(),
8778
+ withdraw: { askId: args.askId, reason: args.reason }
8779
+ }),
8780
+ { annotations: ACT_TOOL, alwaysLoad: true }
8781
+ ),
8782
+ // CT990: the inverse. No arguments — it can only ever stand down the
8783
+ // caller's own wake in the conversation it's holding a turn in.
8784
+ tool(
8785
+ CANCEL_WAKE_TOOL,
8786
+ TURN_CONTROL_TOOL_DESCRIPTIONS.cancel_wake,
8787
+ {},
8788
+ async () => performAct(acts, { kind: "cancel_wake", callId: randomUUID2() }),
8789
+ { annotations: CANCEL_TOOL, alwaysLoad: true }
8790
+ )
8791
+ ]
8792
+ });
8793
+ }
8794
+
8795
+ // src/build-options.ts
8796
+ function cabaneMcpUrl(baseUrl) {
8797
+ return `${trimSlash2(baseUrl)}/api/mcp`;
8798
+ }
8799
+ function turnControlMcpUrl(baseUrl) {
8800
+ return `${trimSlash2(baseUrl)}/api/turn-control`;
8801
+ }
8802
+ function buildCompanionTurnRequest(params) {
8803
+ const { turnContext: t } = params;
8804
+ return {
8805
+ systemPrompt: t.systemPrompt,
8806
+ prompt: t.prompt,
8807
+ content: t.content,
8808
+ config: t.config,
8809
+ policy: t.policy,
8810
+ session: t.session,
8811
+ cabane: {
8812
+ mcpUrl: cabaneMcpUrl(params.baseUrl),
8813
+ bearer: params.turnToken,
8066
8814
  activeConversationId: params.activeConversationId,
8067
8815
  workspaceId: params.workspaceId,
8068
8816
  turnControlUrl: turnControlMcpUrl(params.baseUrl)
@@ -8137,7 +8885,7 @@ function readOutboxFloor(read, turnId, log) {
8137
8885
  }
8138
8886
 
8139
8887
  // src/prepare-failure.ts
8140
- import { spawn as spawn4 } from "child_process";
8888
+ import { spawn as spawn5 } from "child_process";
8141
8889
  import { hostname } from "os";
8142
8890
  function prepareFailureCause(error) {
8143
8891
  const message = error instanceof Error ? error.message : String(error);
@@ -8175,7 +8923,7 @@ async function runPrepareWithReporting(runner, hook, input, context, reportError
8175
8923
  }
8176
8924
  function reportFailure(config, body) {
8177
8925
  return new Promise((resolve2, reject) => {
8178
- const child = spawn4(config.command, config.args ?? [], {
8926
+ const child = spawn5(config.command, config.args ?? [], {
8179
8927
  stdio: ["pipe", "ignore", "ignore"],
8180
8928
  env: { ...process.env, ...config.env }
8181
8929
  });
@@ -8199,7 +8947,7 @@ function reportFailure(config, body) {
8199
8947
  }
8200
8948
 
8201
8949
  // src/prepared.ts
8202
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
8950
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
8203
8951
  import { join as join11 } from "path";
8204
8952
  function dirFor(workspaceId) {
8205
8953
  return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
@@ -8214,7 +8962,7 @@ function readPrepared(workspaceId, conversationId, agentId) {
8214
8962
  const path = pathFor2(workspaceId, conversationId, agentId);
8215
8963
  if (!existsSync7(path)) return null;
8216
8964
  try {
8217
- const parsed = JSON.parse(readFileSync5(path, "utf8"));
8965
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
8218
8966
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
8219
8967
  return {
8220
8968
  cwd: parsed.cwd,
@@ -8239,20 +8987,20 @@ function clearPrepared(workspaceId, conversationId, agentId) {
8239
8987
  }
8240
8988
 
8241
8989
  // src/secrets.ts
8242
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
8990
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
8243
8991
  import { join as join12 } from "path";
8244
- import { z as z14 } from "zod";
8992
+ import { z as z16 } from "zod";
8245
8993
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8246
8994
  function secretsPath() {
8247
8995
  return join12(cabaneDir(), "secrets.json");
8248
8996
  }
8249
- var secretStoreSchema = z14.record(z14.string(), z14.string());
8997
+ var secretStoreSchema = z16.record(z16.string(), z16.string());
8250
8998
  function loadSecretStore() {
8251
8999
  const path = secretsPath();
8252
9000
  if (!existsSync8(path)) return makeStore({});
8253
9001
  let raw;
8254
9002
  try {
8255
- raw = readFileSync6(path, "utf8");
9003
+ raw = readFileSync7(path, "utf8");
8256
9004
  } catch (err) {
8257
9005
  throw new ConfigError(
8258
9006
  `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -8333,7 +9081,7 @@ function resolveMcpSecrets(mcpServers, store) {
8333
9081
  import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync6 } from "fs";
8334
9082
  import { basename, dirname as dirname6, join as join13 } from "path";
8335
9083
  function transcriptsDir() {
8336
- return join13(cabaneDir(), "transcripts");
9084
+ return join13(runnerStateDir(), "transcripts");
8337
9085
  }
8338
9086
  var RETAIN = 200;
8339
9087
  var ANOMALY_RETAIN = 50;
@@ -8343,344 +9091,88 @@ var TranscriptWriter = class {
8343
9091
  onWarn;
8344
9092
  constructor(dir2, meta, onWarn) {
8345
9093
  this.onWarn = onWarn;
8346
- this.path = join13(dir2, fileName(meta));
8347
- try {
8348
- mkdirSync8(dir2, { recursive: true });
8349
- try {
8350
- chmodSync4(dir2, 448);
8351
- } catch {
8352
- }
8353
- pruneOld(dir2, RETAIN);
8354
- } catch (err) {
8355
- this.fail(err);
8356
- }
8357
- this.line({ type: "_meta", ...meta });
8358
- }
8359
- // Append a raw SDK message verbatim.
8360
- write(msg) {
8361
- this.line(msg);
8362
- }
8363
- // Append the outcome footer.
8364
- close(outcome) {
8365
- this.line({ type: "_outcome", ...outcome });
8366
- }
8367
- // CT1145: keep an independent copy of an empty successful envelope after the
8368
- // footer has landed. The ordinary 200-file FIFO keeps rotating as before;
8369
- // this bucket retains the newest 50 anomalies regardless of ordinary traffic.
8370
- // Best-effort like every other transcript operation: observability can never
8371
- // fail the turn.
8372
- preserveAnomaly() {
8373
- if (this.broken) return;
8374
- try {
8375
- const dir2 = join13(dirname6(this.path), "anomalies");
8376
- mkdirSync8(dir2, { recursive: true, mode: 448 });
8377
- chmodSync4(dir2, 448);
8378
- const target = join13(dir2, basename(this.path));
8379
- copyFileSync(this.path, target);
8380
- chmodSync4(target, 384);
8381
- pruneOld(dir2, ANOMALY_RETAIN);
8382
- } catch (err) {
8383
- this.fail(err);
8384
- }
8385
- }
8386
- line(obj) {
8387
- if (this.broken) return;
8388
- try {
8389
- appendFileSync(this.path, JSON.stringify(obj) + "\n", { mode: 384 });
8390
- } catch (err) {
8391
- this.fail(err);
8392
- }
8393
- }
8394
- fail(err) {
8395
- if (this.broken) return;
8396
- this.broken = true;
8397
- this.onWarn?.(`transcript write failed (${err instanceof Error ? err.message : String(err)})`);
8398
- }
8399
- };
8400
- function fileName(meta) {
8401
- const d = new Date(meta.ts);
8402
- const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
8403
- const slug = safe2(meta.workspaceSlug) || "ws";
8404
- const short = meta.dispatchId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "turn";
8405
- return `${stamp}-${slug}-${short}.jsonl`;
8406
- }
8407
- function pad(n) {
8408
- return String(n).padStart(2, "0");
8409
- }
8410
- function safe2(s) {
8411
- return s.replace(/[^a-zA-Z0-9_-]/g, "-");
8412
- }
8413
- function pruneOld(dir2, retain) {
8414
- let files;
8415
- try {
8416
- files = readdirSync(dir2).filter((f) => f.endsWith(".jsonl"));
8417
- } catch {
8418
- return;
8419
- }
8420
- if (files.length <= retain) return;
8421
- const drop = files.sort().slice(0, files.length - retain);
8422
- for (const f of drop) {
8423
- try {
8424
- rmSync6(join13(dir2, f), { force: true });
8425
- } catch {
8426
- }
8427
- }
8428
- }
8429
-
8430
- // src/turn-containment.ts
8431
- import { execFile, execFileSync, spawn as spawn5 } from "child_process";
8432
- import { readFileSync as readFileSync7 } from "fs";
8433
- import { promisify } from "util";
8434
- var execFileAsync = promisify(execFile);
8435
- var asContained = (child) => child;
8436
- var CONTAINMENT_STOP_GRACE_SEC = 5;
8437
- var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
8438
- var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
8439
- function turnScopeUnit(turnId, attempt) {
8440
- const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
8441
- return `cabane-turn-${safe3}-${attempt}`;
8442
- }
8443
- function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
8444
- if (process.platform !== "linux") return false;
8445
- try {
8446
- execFileSync("systemd-run", ["--user", "--scope", "--quiet", "--collect", "--", "/bin/true"], {
8447
- env,
8448
- timeout: timeoutMs,
8449
- stdio: "ignore"
8450
- });
8451
- return true;
8452
- } catch {
8453
- return false;
8454
- }
8455
- }
8456
- function classifyStopError(message) {
8457
- 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(
8458
- message
8459
- )) {
8460
- return "failure";
8461
- }
8462
- if (/not loaded|could not be found|no such unit|unit .* not found/i.test(message))
8463
- return "absent";
8464
- return "failure";
8465
- }
8466
- function currentSystemdUnit(cgroupText) {
8467
- for (const line of cgroupText.split("\n")) {
8468
- const path = line.trim().split(":").pop();
8469
- if (!path?.startsWith("/")) continue;
8470
- const leaf = path.split("/").filter(Boolean).pop();
8471
- if (leaf && (leaf.endsWith(".service") || leaf.endsWith(".scope"))) return leaf;
8472
- }
8473
- return null;
8474
- }
8475
- function readCurrentSystemdUnit() {
8476
- try {
8477
- return currentSystemdUnit(readFileSync7("/proc/self/cgroup", "utf8"));
8478
- } catch {
8479
- return null;
8480
- }
8481
- }
8482
- function processGroupMembers(pgid) {
8483
- try {
8484
- const out = execFileSync("ps", ["-eo", "pid=,pgid="], { timeout: 5e3 }).toString();
8485
- return {
8486
- ok: true,
8487
- 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]))
8488
- };
8489
- } catch (err) {
8490
- return { ok: false, error: (err instanceof Error ? err.message : String(err)).slice(0, 150) };
8491
- }
8492
- }
8493
- var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
8494
- var MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
8495
- var managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
8496
- function createTurnContainment(turnId, deps = {}) {
8497
- const probe = deps.capabilityProbe ?? scopeCapabilityUsable;
8498
- const fallbackGraceMs = deps.fallbackGraceMs ?? FALLBACK_GRACE_MS;
8499
- const ownUnit = (deps.ownSystemdUnit ?? readCurrentSystemdUnit)();
8500
- const scopeUnits = [];
8501
- const groups = [];
8502
- let kind = null;
8503
- let probedEnvKey = null;
8504
- let attempt = 0;
8505
- function spawnInScope(options) {
8506
- const unit = turnScopeUnit(turnId, attempt++);
8507
- scopeUnits.push({ unit, env: options.env });
8508
- deps.log?.("containment: spawning the turn CLI into a systemd scope", {
8509
- unit,
8510
- partOf: ownUnit,
8511
- command: options.command
8512
- });
8513
- return asContained(
8514
- spawn5(
8515
- "systemd-run",
8516
- [
8517
- "--user",
8518
- "--scope",
8519
- "--quiet",
8520
- "--collect",
8521
- `--unit=${unit}`,
8522
- `--property=TimeoutStopSec=${CONTAINMENT_STOP_GRACE_SEC}s`,
8523
- // Bind the scope's life to the companion's own unit, so stopping or
8524
- // restarting the companion still takes its in-flight turns with it —
8525
- // which is what happens today, and what the scope would otherwise undo.
8526
- // Omitted outside a unit, where there is nothing to bind to.
8527
- ...ownUnit ? [`--property=PartOf=${ownUnit}`] : [],
8528
- "--",
8529
- options.command,
8530
- ...options.args
8531
- ],
8532
- {
8533
- ...options.cwd ? { cwd: options.cwd } : {},
8534
- env: options.env,
8535
- stdio: ["pipe", "pipe", "pipe"]
8536
- }
8537
- )
8538
- );
8539
- }
8540
- function spawnInGroup(options) {
8541
- const child = spawn5(options.command, options.args, {
8542
- ...options.cwd ? { cwd: options.cwd } : {},
8543
- env: options.env,
8544
- stdio: ["pipe", "pipe", "pipe"],
8545
- detached: process.platform !== "win32"
8546
- });
8547
- if (child.pid !== void 0) groups.push({ pgid: child.pid, child });
8548
- deps.log?.("containment: no host-owned containment here \u2014 best effort only", {
8549
- platform: process.platform,
8550
- pid: child.pid
8551
- });
8552
- return asContained(child);
8553
- }
8554
- async function reapScopes(result) {
8555
- for (const { unit, env } of scopeUnits) {
8556
- try {
8557
- await execFileAsync("systemctl", ["--user", "stop", `${unit}.scope`], {
8558
- env,
8559
- timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
8560
- });
8561
- } catch (err) {
8562
- const message = err instanceof Error ? err.message : String(err);
8563
- if (classifyStopError(message) === "failure") {
8564
- result.failures.push({ target: unit, error: message.slice(0, 200) });
8565
- continue;
8566
- }
8567
- result.reaped.push(unit);
8568
- continue;
8569
- }
8570
- try {
8571
- const { stdout } = await execFileAsync(
8572
- "systemctl",
8573
- ["--user", "show", `${unit}.scope`, "--property=ActiveState", "--value"],
8574
- { env, timeout: 1e4 }
8575
- );
8576
- const state = stdout.trim();
8577
- if (state === "" || state === "inactive" || state === "failed") result.reaped.push(unit);
8578
- else result.failures.push({ target: unit, error: `scope still ${state} after stop` });
8579
- } catch (err) {
8580
- result.failures.push({
8581
- target: unit,
8582
- error: `could not confirm the scope stopped: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`
8583
- });
9094
+ this.path = join13(dir2, fileName(meta));
9095
+ try {
9096
+ mkdirSync8(dir2, { recursive: true });
9097
+ try {
9098
+ chmodSync4(dir2, 448);
9099
+ } catch {
8584
9100
  }
9101
+ pruneOld(dir2, RETAIN);
9102
+ } catch (err) {
9103
+ this.fail(err);
8585
9104
  }
9105
+ this.line({ type: "_meta", ...meta });
8586
9106
  }
8587
- async function reapGroups(result) {
8588
- for (const { pgid, child } of groups) {
8589
- const target = String(pgid);
8590
- if (process.platform === "win32") {
8591
- try {
8592
- if (child.exitCode === null) child.kill();
8593
- result.unverified.push(target);
8594
- } catch (err) {
8595
- result.failures.push({
8596
- target,
8597
- error: (err instanceof Error ? err.message : String(err)).slice(0, 200)
8598
- });
8599
- }
8600
- continue;
8601
- }
8602
- const first = processGroupMembers(pgid);
8603
- if (!first.ok) {
8604
- result.failures.push({
8605
- target,
8606
- error: `could not enumerate the process group: ${first.error}`
8607
- });
8608
- continue;
8609
- }
8610
- if (first.members.length === 0) {
8611
- result.reaped.push(target);
8612
- continue;
8613
- }
8614
- for (const pid of first.members) {
8615
- try {
8616
- process.kill(pid, "SIGTERM");
8617
- } catch {
8618
- }
8619
- }
8620
- await sleep2(fallbackGraceMs);
8621
- const afterTerm = processGroupMembers(pgid);
8622
- if (!afterTerm.ok) {
8623
- result.failures.push({
8624
- target,
8625
- error: `could not re-enumerate the process group after SIGTERM: ${afterTerm.error}`
8626
- });
8627
- continue;
8628
- }
8629
- for (const pid of afterTerm.members) {
8630
- try {
8631
- process.kill(pid, "SIGKILL");
8632
- } catch {
8633
- }
8634
- }
8635
- const afterKill = processGroupMembers(pgid);
8636
- if (!afterKill.ok) {
8637
- result.failures.push({
8638
- target,
8639
- error: `could not confirm the process group is empty after SIGKILL: ${afterKill.error}`
8640
- });
8641
- continue;
8642
- }
8643
- if (afterKill.members.length)
8644
- result.failures.push({
8645
- target,
8646
- error: `${afterKill.members.length} process(es) still in the group after SIGKILL`
8647
- });
8648
- else result.reaped.push(target);
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);
8649
9132
  }
8650
9133
  }
8651
- return {
8652
- get kind() {
8653
- return kind;
8654
- },
8655
- spawn(options) {
8656
- const envKey = managerEnvKey(options.env);
8657
- if (kind === null || envKey !== probedEnvKey) {
8658
- kind = probe(options.env) ? "systemd-scope" : "process-group";
8659
- probedEnvKey = envKey;
8660
- deps.log?.("containment: resolved strategy for this turn", {
8661
- kind,
8662
- platform: process.platform
8663
- });
8664
- }
8665
- return kind === "systemd-scope" ? spawnInScope(options) : spawnInGroup(options);
8666
- },
8667
- async reap() {
8668
- const result = {
8669
- kind: kind ?? "unused",
8670
- reaped: [],
8671
- failures: [],
8672
- unverified: []
8673
- };
8674
- await reapScopes(result);
8675
- await reapGroups(result);
8676
- deps.log?.("containment: reap complete", {
8677
- kind: result.kind,
8678
- reaped: result.reaped,
8679
- failures: result.failures
8680
- });
8681
- return result;
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);
8682
9140
  }
8683
- };
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
+ }
8684
9176
  }
8685
9177
 
8686
9178
  // src/turn-committer.ts
@@ -9371,7 +9863,9 @@ var TurnExecution = class {
9371
9863
  try {
9372
9864
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
9373
9865
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
9374
- 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,
9375
9869
  // CT1380: the durable record that this turn resumed, and from where.
9376
9870
  ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
9377
9871
  // CT1454: the issuance the context THIS attempt runs on came from. Read
@@ -9468,10 +9962,14 @@ var TurnExecution = class {
9468
9962
  const { payload, workspaceId, turnId, turnLog } = this;
9469
9963
  await guardBundledBinary(this.integrityContext(), (r, e) => this.concluded(r, e), this.outcome);
9470
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;
9471
9966
  const adapters = [];
9472
9967
  if (this.opts.claudeCodeAvailable?.() ?? true) {
9473
9968
  this.containment = createTurnContainment(this.turnId, {
9474
- 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 } : {}
9475
9973
  });
9476
9974
  const containment = this.containment;
9477
9975
  adapters.push(
@@ -9480,20 +9978,43 @@ var TurnExecution = class {
9480
9978
  onWarn,
9481
9979
  augmentOptions: (options) => ({
9482
9980
  ...options,
9483
- spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(spawnOptions)
9981
+ spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(
9982
+ sandbox ? { ...spawnOptions, ...sandbox.wrap(spawnOptions.command, spawnOptions.args) } : spawnOptions
9983
+ )
9484
9984
  })
9485
9985
  })
9486
9986
  );
9487
9987
  }
9488
9988
  if (this.opts.opencodeServerUrl) {
9489
- 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
+ );
9490
10000
  }
9491
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
+ }
9492
10012
  adapters.push(
9493
10013
  createCodexAdapter({
9494
10014
  enabled: true,
9495
10015
  onWarn,
9496
- writeInstructionsFile: writeCodexInstructionsFile
10016
+ writeInstructionsFile: writeCodexInstructionsFile,
10017
+ ...codexPath ? { transport: createSdkCodexTransport({ codexPathOverride: codexPath }) } : {}
9497
10018
  })
9498
10019
  );
9499
10020
  }
@@ -10030,6 +10551,24 @@ var Dispatcher = class {
10030
10551
  notifyStart: (info) => this.notifyStart(info),
10031
10552
  notifyEnd: (info) => this.notifyEnd(info)
10032
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
+ }
10033
10572
  return new TurnExecution(this.opts, supervisor, payload, opts).run();
10034
10573
  }
10035
10574
  // SJ383: cancel a specific (conversation, agent) run if one is in flight in
@@ -10048,6 +10587,176 @@ var Dispatcher = class {
10048
10587
  }
10049
10588
  };
10050
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
+
10051
10760
  // src/opencode-models.ts
10052
10761
  var OPENCODE_RUNTIME = "opencode";
10053
10762
  function mapOpencodeProviders(json) {
@@ -10127,14 +10836,14 @@ var DispatchClaims = class {
10127
10836
  // src/outbox.ts
10128
10837
  import {
10129
10838
  existsSync as existsSync11,
10130
- mkdirSync as mkdirSync9,
10839
+ mkdirSync as mkdirSync10,
10131
10840
  readdirSync as readdirSync3,
10132
10841
  readFileSync as readFileSync8,
10133
10842
  renameSync as renameSync4,
10134
- rmSync as rmSync7,
10135
- writeFileSync as writeFileSync6
10843
+ rmSync as rmSync8,
10844
+ writeFileSync as writeFileSync7
10136
10845
  } from "fs";
10137
- import { join as join15 } from "path";
10846
+ import { join as join16 } from "path";
10138
10847
  var MAX_ENTRIES = 2e3;
10139
10848
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
10140
10849
  var Outbox = class {
@@ -10147,25 +10856,25 @@ var Outbox = class {
10147
10856
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
10148
10857
  // cases route writes at the right tmpdir.
10149
10858
  dir() {
10150
- return join15(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
10859
+ return join16(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
10151
10860
  }
10152
10861
  fileFor(turnId, seq) {
10153
- return join15(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
10862
+ return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
10154
10863
  }
10155
10864
  // Persist a commit for later draining. Atomic (temp file + rename) so a
10156
10865
  // concurrent `list()` never reads a half-written entry, then enforces the
10157
10866
  // per-workspace bounds.
10158
10867
  persist(entry) {
10159
10868
  const dir2 = this.dir();
10160
- mkdirSync9(dir2, { recursive: true });
10869
+ mkdirSync10(dir2, { recursive: true });
10161
10870
  const target = this.fileFor(entry.turnId, entry.seq);
10162
10871
  const tmp = `${target}.${process.pid}.tmp`;
10163
10872
  try {
10164
- writeFileSync6(tmp, JSON.stringify(entry) + "\n", "utf8");
10873
+ writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
10165
10874
  renameSync4(tmp, target);
10166
10875
  } catch (err) {
10167
10876
  try {
10168
- rmSync7(tmp, { force: true });
10877
+ rmSync8(tmp, { force: true });
10169
10878
  } catch {
10170
10879
  }
10171
10880
  this.log?.warn(
@@ -10192,7 +10901,7 @@ var Outbox = class {
10192
10901
  const entries = [];
10193
10902
  for (const name of names) {
10194
10903
  if (!name.endsWith(".json")) continue;
10195
- const full = join15(dir2, name);
10904
+ const full = join16(dir2, name);
10196
10905
  try {
10197
10906
  const parsed = JSON.parse(readFileSync8(full, "utf8"));
10198
10907
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -10242,7 +10951,7 @@ var Outbox = class {
10242
10951
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
10243
10952
  remove(turnId, seq) {
10244
10953
  try {
10245
- rmSync7(this.fileFor(turnId, seq), { force: true });
10954
+ rmSync8(this.fileFor(turnId, seq), { force: true });
10246
10955
  } catch {
10247
10956
  }
10248
10957
  }
@@ -10261,7 +10970,7 @@ var Outbox = class {
10261
10970
  "A saved update is unreadable and couldn't be delivered. Check the conversation for missing replies."
10262
10971
  );
10263
10972
  try {
10264
- rmSync7(full, { force: true });
10973
+ rmSync8(full, { force: true });
10265
10974
  } catch {
10266
10975
  }
10267
10976
  }
@@ -10295,53 +11004,6 @@ var Outbox = class {
10295
11004
  }
10296
11005
  };
10297
11006
 
10298
- // src/run-config.ts
10299
- import { z as z15 } from "zod";
10300
- var mcpStdioServerSchema = z15.object({
10301
- type: z15.literal("stdio").optional(),
10302
- command: z15.string().min(1),
10303
- args: z15.array(z15.string()).optional(),
10304
- env: z15.record(z15.string(), z15.string()).optional()
10305
- });
10306
- var mcpHttpServerSchema = z15.object({
10307
- type: z15.literal("http"),
10308
- url: z15.string().url(),
10309
- headers: z15.record(z15.string(), z15.string()).optional()
10310
- });
10311
- var mcpSseServerSchema = z15.object({
10312
- type: z15.literal("sse"),
10313
- url: z15.string().url(),
10314
- headers: z15.record(z15.string(), z15.string()).optional()
10315
- });
10316
- var mcpServerDefSchema = z15.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
10317
- var thinkingConfigSchema = z15.discriminatedUnion("type", [
10318
- z15.object({ type: z15.literal("adaptive") }),
10319
- z15.object({ type: z15.literal("enabled"), budgetTokens: z15.number().int().positive().optional() }),
10320
- z15.object({ type: z15.literal("disabled") })
10321
- ]);
10322
- var effortSchema = z15.enum(["low", "medium", "high", "xhigh", "max"]);
10323
- var runConfigSchema = z15.object({
10324
- // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
10325
- // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
10326
- // is the locked surface. Kept in lockstep with `@cabane/shared`'s
10327
- // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
10328
- // stripped, so an older companion riding a newer server never rejects the config).
10329
- hostAccess: z15.boolean().optional(),
10330
- mcpServers: z15.record(z15.string(), mcpServerDefSchema).optional(),
10331
- model: z15.string().min(1).optional(),
10332
- thinking: thinkingConfigSchema.optional(),
10333
- effort: effortSchema.optional()
10334
- });
10335
- var EMPTY_RUN_CONFIG = {};
10336
- function parseRunConfig(raw, onWarn) {
10337
- const result = runConfigSchema.safeParse(raw ?? {});
10338
- if (result.success) return result.data;
10339
- onWarn?.(
10340
- `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`
10341
- );
10342
- return EMPTY_RUN_CONFIG;
10343
- }
10344
-
10345
11007
  // src/sse.ts
10346
11008
  import { createParser } from "eventsource-parser";
10347
11009
  var MAX_BACKOFF_MS = 3e4;
@@ -10506,6 +11168,8 @@ var CompanionSupervisor = class {
10506
11168
  exitFn;
10507
11169
  reexecFn;
10508
11170
  dispatcherFactory;
11171
+ runnerCommand;
11172
+ providerForRunners;
10509
11173
  unrunnableRetryDelaysMs;
10510
11174
  deviceApi = null;
10511
11175
  deviceSub = null;
@@ -10550,6 +11214,8 @@ var CompanionSupervisor = class {
10550
11214
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
10551
11215
  this.reexecFn = opts.reexec ?? defaultReexec;
10552
11216
  this.dispatcherFactory = opts.dispatcherFactory;
11217
+ if (opts.runnerCommand) this.runnerCommand = opts.runnerCommand;
11218
+ if (opts.runnerProvider) this.providerForRunners = opts.runnerProvider;
10553
11219
  this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
10554
11220
  this.latchBootBundledState();
10555
11221
  }
@@ -10995,6 +11661,21 @@ var CompanionSupervisor = class {
10995
11661
  );
10996
11662
  }
10997
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
+ }
10998
11679
  buildDispatcher(ctx) {
10999
11680
  const local = localAgentConfig(this.config, {
11000
11681
  agentId: ctx.agentId,
@@ -11045,7 +11726,17 @@ var CompanionSupervisor = class {
11045
11726
  observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
11046
11727
  transcriptDir: transcriptsDir(),
11047
11728
  // CT566: the shared connector-health memory the turn records into on settle.
11048
- connectorHealth: this.connectorHealth
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
+ } : {}
11049
11740
  });
11050
11741
  }
11051
11742
  async removeWorkspace(workspaceId) {
@@ -11336,7 +12027,7 @@ var CompanionSupervisor = class {
11336
12027
  );
11337
12028
  }
11338
12029
  const resumedTurnId = recoveredTurnId ?? (dispatchId ? turnIdForEvent(workspaceId, dispatchId) : null);
11339
- const turnId = resumedTurnId ?? randomUUID4();
12030
+ const turnId = resumedTurnId ?? randomUUID5();
11340
12031
  if (dispatchId) {
11341
12032
  const liveTurnIds = rememberTurnId(workspaceId, dispatchId, turnId);
11342
12033
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
@@ -11641,9 +12332,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
11641
12332
  }
11642
12333
  function defaultReexec() {
11643
12334
  clearRuntimeState();
11644
- void import("child_process").then(({ spawn: spawn6 }) => {
12335
+ void import("child_process").then(({ spawn: spawn7 }) => {
11645
12336
  try {
11646
- const child = spawn6(process.execPath, process.argv.slice(1), {
12337
+ const child = spawn7(process.execPath, process.argv.slice(1), {
11647
12338
  stdio: "inherit",
11648
12339
  detached: false
11649
12340
  });
@@ -11713,22 +12404,22 @@ function handleUncaught(log, err, origin) {
11713
12404
  }
11714
12405
 
11715
12406
  // src/crash-marker.ts
11716
- import { existsSync as existsSync12, mkdirSync as mkdirSync10, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync7 } from "fs";
11717
- import { join as join16 } from "path";
12407
+ import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync9, writeFileSync as writeFileSync8 } from "fs";
12408
+ import { join as join17 } from "path";
11718
12409
  function crashMarkerPath() {
11719
- return join16(cabaneDir(), "last-error.json");
12410
+ return join17(cabaneDir(), "last-error.json");
11720
12411
  }
11721
12412
  function recordCrash(rec) {
11722
12413
  try {
11723
- mkdirSync10(cabaneDir(), { recursive: true });
11724
- writeFileSync7(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
12414
+ mkdirSync11(cabaneDir(), { recursive: true });
12415
+ writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
11725
12416
  } catch {
11726
12417
  }
11727
12418
  }
11728
12419
  function clearCrash() {
11729
12420
  try {
11730
12421
  const path = crashMarkerPath();
11731
- if (existsSync12(path)) rmSync8(path, { force: true });
12422
+ if (existsSync12(path)) rmSync9(path, { force: true });
11732
12423
  } catch {
11733
12424
  }
11734
12425
  }
@@ -11771,7 +12462,7 @@ async function createCompanionRuntime(opts = {}) {
11771
12462
  opencodeServerUrl: cfg.opencode?.serverUrl,
11772
12463
  codex: isCodexEnabled(cfg)
11773
12464
  });
11774
- const instanceId = randomUUID5();
12465
+ const instanceId = randomUUID6();
11775
12466
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
11776
12467
  const pre = readLiveRuntimeState();
11777
12468
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();