@cabane/companion 0.6.121 → 0.6.123

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";
@@ -2500,6 +2516,13 @@ var CabaneApi = class {
2500
2516
  (e) => e.turnId === turnId && e.kind === "message" && e.body?.kind !== "runtime_notice"
2501
2517
  );
2502
2518
  }
2519
+ // CT1680: the launcher mints the runner credential for one dispatch, on its
2520
+ // PAT and from its device, and hands it to the runner in place of both. A
2521
+ // retry is safe: the server returns the same runner id and retires the
2522
+ // credential the lost response carried.
2523
+ mintRunnerCredential(body) {
2524
+ return this.request("POST", "/api/agent/runner-credentials", body, { retry: true });
2525
+ }
2503
2526
  // Read the skip intent recorded by the URL turn-control MCP surface.
2504
2527
  // Codex/opencode use this server-side state; settle imports skipped/skipReason
2505
2528
  // into the same local skip state used by claude-code's in-process tools.
@@ -7811,257 +7834,1039 @@ var ConnectorHealthStore = class {
7811
7834
  };
7812
7835
 
7813
7836
  // src/dispatcher.ts
7837
+ import { randomUUID as randomUUID4 } from "crypto";
7814
7838
  import { existsSync as existsSync10, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
7815
7839
  import { join as join14 } from "path";
7816
7840
 
7817
- // src/term.ts
7841
+ // src/runner/launch.ts
7818
7842
  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
7843
 
7827
- // src/turn-execution.ts
7828
- import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
7829
- import { existsSync as existsSync9 } from "fs";
7830
-
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 };
7848
- }
7849
- function toolError(text) {
7850
- return { isError: true, content: [{ type: "text", text }] };
7844
+ // src/runner/opencode-lock.ts
7845
+ function opencodeLockKey(serverUrl) {
7846
+ return `opencode:${serverUrl}`;
7851
7847
  }
7852
- async function performAct(acts, act) {
7853
- let result;
7848
+ async function stopRemoteOpencodeSession(serverUrl, sessionId, opts = {}) {
7849
+ const doFetch = opts.fetchImpl ?? fetch;
7850
+ const base = serverUrl.replace(/\/+$/, "");
7851
+ const id = encodeURIComponent(sessionId);
7854
7852
  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
- );
7853
+ await doFetch(`${base}/session/${id}/abort`, {
7854
+ method: "POST",
7855
+ signal: AbortSignal.timeout(5e3)
7856
+ });
7857
+ } catch {
7858
+ }
7859
+ const attempts = opts.attempts ?? 20;
7860
+ for (let i = 0; i < attempts; i++) {
7861
+ try {
7862
+ const res = await doFetch(`${base}/session/status`, { signal: AbortSignal.timeout(5e3) });
7863
+ if (res.ok) {
7864
+ const status = await res.json();
7865
+ const state = status[sessionId];
7866
+ if (!state || state.type === "idle") return true;
7870
7867
  }
7871
- return toolError(`The act was refused (${err.status}): ${apiErrorMessage(err)}`);
7868
+ } catch {
7872
7869
  }
7873
- return uncertainOutcome(err instanceof Error ? err.message : String(err));
7870
+ await new Promise((resolve2) => setTimeout(resolve2, opts.pollMs ?? 500));
7874
7871
  }
7875
- if (!result.ok) return toolError(JSON.stringify(result));
7876
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
7872
+ return false;
7877
7873
  }
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
- );
7874
+
7875
+ // src/turn-containment.ts
7876
+ import { execFile, execFileSync, spawn as spawn4 } from "child_process";
7877
+ import { readFileSync as readFileSync5 } from "fs";
7878
+ import { promisify } from "util";
7879
+ var execFileAsync = promisify(execFile);
7880
+ var asContained = (child) => child;
7881
+ var CONTAINMENT_STOP_GRACE_SEC = 5;
7882
+ var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
7883
+ var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
7884
+ function turnScopeUnit(turnId, attempt) {
7885
+ const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
7886
+ return `cabane-turn-${safe3}-${attempt}`;
7882
7887
  }
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
- });
7888
+ async function reapTurnScopes(turnId) {
7889
+ if (process.platform !== "linux") return { failures: [] };
7890
+ const pattern = `${turnScopeUnit(turnId, 0).replace(/-0$/, "")}-*.scope`;
7891
+ try {
7892
+ await execFileAsync("systemctl", ["--user", "stop", pattern], {
7893
+ timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
7894
+ });
7895
+ return { failures: [] };
7896
+ } catch (err) {
7897
+ const message = err instanceof Error ? err.message : String(err);
7898
+ return classifyStopError(message) === "absent" ? { failures: [] } : { failures: [message.slice(0, 200)] };
7899
+ }
8045
7900
  }
8046
-
8047
- // src/build-options.ts
8048
- function cabaneMcpUrl(baseUrl) {
8049
- return `${trimSlash2(baseUrl)}/api/mcp`;
7901
+ function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
7902
+ if (process.platform !== "linux") return false;
7903
+ try {
7904
+ execFileSync("systemd-run", ["--user", "--scope", "--quiet", "--collect", "--", "/bin/true"], {
7905
+ env,
7906
+ timeout: timeoutMs,
7907
+ stdio: "ignore"
7908
+ });
7909
+ return true;
7910
+ } catch {
7911
+ return false;
7912
+ }
8050
7913
  }
8051
- function turnControlMcpUrl(baseUrl) {
8052
- return `${trimSlash2(baseUrl)}/api/turn-control`;
7914
+ function classifyStopError(message) {
7915
+ 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(
7916
+ message
7917
+ )) {
7918
+ return "failure";
7919
+ }
7920
+ if (/not loaded|could not be found|no such unit|unit .* not found/i.test(message))
7921
+ return "absent";
7922
+ return "failure";
8053
7923
  }
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),
7924
+ function currentSystemdUnit(cgroupText) {
7925
+ for (const line of cgroupText.split("\n")) {
7926
+ const path = line.trim().split(":").pop();
7927
+ if (!path?.startsWith("/")) continue;
7928
+ const leaf = path.split("/").filter(Boolean).pop();
7929
+ if (leaf && (leaf.endsWith(".service") || leaf.endsWith(".scope"))) return leaf;
7930
+ }
7931
+ return null;
7932
+ }
7933
+ function readCurrentSystemdUnit() {
7934
+ try {
7935
+ return currentSystemdUnit(readFileSync5("/proc/self/cgroup", "utf8"));
7936
+ } catch {
7937
+ return null;
7938
+ }
7939
+ }
7940
+ function processGroupMembers(pgid) {
7941
+ try {
7942
+ const out = execFileSync("ps", ["-eo", "pid=,pgid="], { timeout: 5e3 }).toString();
7943
+ return {
7944
+ ok: true,
7945
+ 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]))
7946
+ };
7947
+ } catch (err) {
7948
+ return { ok: false, error: (err instanceof Error ? err.message : String(err)).slice(0, 150) };
7949
+ }
7950
+ }
7951
+ var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
7952
+ var MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
7953
+ var managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
7954
+ function createTurnContainment(turnId, deps = {}) {
7955
+ const probe = deps.capabilityProbe ?? scopeCapabilityUsable;
7956
+ const fallbackGraceMs = deps.fallbackGraceMs ?? FALLBACK_GRACE_MS;
7957
+ const ownUnit = (deps.ownSystemdUnit ?? readCurrentSystemdUnit)();
7958
+ const scopeUnits = [];
7959
+ const groups = [];
7960
+ let kind = null;
7961
+ let probedEnvKey = null;
7962
+ let attempt = 0;
7963
+ function scopeArgs(unit) {
7964
+ return [
7965
+ "--user",
7966
+ "--scope",
7967
+ "--quiet",
7968
+ "--collect",
7969
+ `--unit=${unit}`,
7970
+ `--property=TimeoutStopSec=${CONTAINMENT_STOP_GRACE_SEC}s`,
7971
+ // Bind the scope's life to the companion's own unit, so stopping or
7972
+ // restarting the companion still takes its in-flight turns with it —
7973
+ // which is what happens today, and what the scope would otherwise undo.
7974
+ // Omitted outside a unit, where there is nothing to bind to.
7975
+ ...ownUnit ? [`--property=PartOf=${ownUnit}`] : [],
7976
+ "--"
7977
+ ];
7978
+ }
7979
+ function resolveKind(env) {
7980
+ const envKey = managerEnvKey(env);
7981
+ if (kind === null || envKey !== probedEnvKey) {
7982
+ kind = probe(env) ? "systemd-scope" : "process-group";
7983
+ probedEnvKey = envKey;
7984
+ deps.log?.("containment: resolved strategy for this turn", {
7985
+ kind,
7986
+ platform: process.platform
7987
+ });
7988
+ }
7989
+ return kind;
7990
+ }
7991
+ function spawnInScope(options) {
7992
+ const unit = turnScopeUnit(turnId, attempt++);
7993
+ scopeUnits.push({ unit, env: options.env });
7994
+ deps.log?.("containment: spawning the turn CLI into a systemd scope", {
7995
+ unit,
7996
+ partOf: ownUnit,
7997
+ command: options.command
7998
+ });
7999
+ return asContained(
8000
+ spawn4("systemd-run", [...scopeArgs(unit), options.command, ...options.args], {
8001
+ ...options.cwd ? { cwd: options.cwd } : {},
8002
+ env: options.env,
8003
+ stdio: ["pipe", "pipe", "pipe"]
8004
+ })
8005
+ );
8006
+ }
8007
+ function spawnInGroup(options) {
8008
+ const child = spawn4(options.command, options.args, {
8009
+ ...options.cwd ? { cwd: options.cwd } : {},
8010
+ env: options.env,
8011
+ stdio: ["pipe", "pipe", "pipe"],
8012
+ detached: process.platform !== "win32"
8013
+ });
8014
+ if (child.pid !== void 0) {
8015
+ groups.push({ pgid: child.pid, child });
8016
+ deps.onProcessGroup?.(child.pid);
8017
+ }
8018
+ deps.log?.("containment: no host-owned containment here \u2014 best effort only", {
8019
+ platform: process.platform,
8020
+ pid: child.pid
8021
+ });
8022
+ return asContained(child);
8023
+ }
8024
+ async function reapScopes(result) {
8025
+ for (const { unit, env } of scopeUnits) {
8026
+ try {
8027
+ await execFileAsync("systemctl", ["--user", "stop", `${unit}.scope`], {
8028
+ env,
8029
+ timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
8030
+ });
8031
+ } catch (err) {
8032
+ const message = err instanceof Error ? err.message : String(err);
8033
+ if (classifyStopError(message) === "failure") {
8034
+ result.failures.push({ target: unit, error: message.slice(0, 200) });
8035
+ continue;
8036
+ }
8037
+ result.reaped.push(unit);
8038
+ continue;
8039
+ }
8040
+ try {
8041
+ const { stdout } = await execFileAsync(
8042
+ "systemctl",
8043
+ ["--user", "show", `${unit}.scope`, "--property=ActiveState", "--value"],
8044
+ { env, timeout: 1e4 }
8045
+ );
8046
+ const state = stdout.trim();
8047
+ if (state === "" || state === "inactive" || state === "failed") result.reaped.push(unit);
8048
+ else result.failures.push({ target: unit, error: `scope still ${state} after stop` });
8049
+ } catch (err) {
8050
+ result.failures.push({
8051
+ target: unit,
8052
+ error: `could not confirm the scope stopped: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`
8053
+ });
8054
+ }
8055
+ }
8056
+ }
8057
+ async function reapGroups(result) {
8058
+ for (const { pgid, child } of groups) {
8059
+ const target = String(pgid);
8060
+ if (process.platform === "win32") {
8061
+ try {
8062
+ if (child.exitCode === null) child.kill();
8063
+ result.unverified.push(target);
8064
+ } catch (err) {
8065
+ result.failures.push({
8066
+ target,
8067
+ error: (err instanceof Error ? err.message : String(err)).slice(0, 200)
8068
+ });
8069
+ }
8070
+ continue;
8071
+ }
8072
+ const first = processGroupMembers(pgid);
8073
+ if (!first.ok) {
8074
+ result.failures.push({
8075
+ target,
8076
+ error: `could not enumerate the process group: ${first.error}`
8077
+ });
8078
+ continue;
8079
+ }
8080
+ if (first.members.length === 0) {
8081
+ result.reaped.push(target);
8082
+ continue;
8083
+ }
8084
+ for (const pid of first.members) {
8085
+ try {
8086
+ process.kill(pid, "SIGTERM");
8087
+ } catch {
8088
+ }
8089
+ }
8090
+ await sleep2(fallbackGraceMs);
8091
+ const afterTerm = processGroupMembers(pgid);
8092
+ if (!afterTerm.ok) {
8093
+ result.failures.push({
8094
+ target,
8095
+ error: `could not re-enumerate the process group after SIGTERM: ${afterTerm.error}`
8096
+ });
8097
+ continue;
8098
+ }
8099
+ for (const pid of afterTerm.members) {
8100
+ try {
8101
+ process.kill(pid, "SIGKILL");
8102
+ } catch {
8103
+ }
8104
+ }
8105
+ const afterKill = processGroupMembers(pgid);
8106
+ if (!afterKill.ok) {
8107
+ result.failures.push({
8108
+ target,
8109
+ error: `could not confirm the process group is empty after SIGKILL: ${afterKill.error}`
8110
+ });
8111
+ continue;
8112
+ }
8113
+ if (afterKill.members.length)
8114
+ result.failures.push({
8115
+ target,
8116
+ error: `${afterKill.members.length} process(es) still in the group after SIGKILL`
8117
+ });
8118
+ else result.reaped.push(target);
8119
+ }
8120
+ }
8121
+ return {
8122
+ get kind() {
8123
+ return kind;
8124
+ },
8125
+ spawn(options) {
8126
+ return resolveKind(options.env) === "systemd-scope" ? spawnInScope(options) : spawnInGroup(options);
8127
+ },
8128
+ scopePrefix(env) {
8129
+ if (resolveKind(env) !== "systemd-scope") return null;
8130
+ const unit = turnScopeUnit(turnId, attempt++);
8131
+ scopeUnits.push({ unit, env });
8132
+ deps.log?.("containment: a harness will start in a systemd scope", { unit });
8133
+ return ["systemd-run", ...scopeArgs(unit)];
8134
+ },
8135
+ async reap() {
8136
+ const result = {
8137
+ kind: kind ?? "unused",
8138
+ reaped: [],
8139
+ failures: [],
8140
+ unverified: []
8141
+ };
8142
+ await reapScopes(result);
8143
+ await reapGroups(result);
8144
+ deps.log?.("containment: reap complete", {
8145
+ kind: result.kind,
8146
+ reaped: result.reaped,
8147
+ failures: result.failures
8148
+ });
8149
+ return result;
8150
+ }
8151
+ };
8152
+ }
8153
+
8154
+ // src/runner/protocol.ts
8155
+ import { z as z14 } from "zod";
8156
+
8157
+ // src/run-config.ts
8158
+ import { z as z13 } from "zod";
8159
+ var mcpStdioServerSchema = z13.object({
8160
+ type: z13.literal("stdio").optional(),
8161
+ command: z13.string().min(1),
8162
+ args: z13.array(z13.string()).optional(),
8163
+ env: z13.record(z13.string(), z13.string()).optional()
8164
+ });
8165
+ var mcpHttpServerSchema = z13.object({
8166
+ type: z13.literal("http"),
8167
+ url: z13.string().url(),
8168
+ headers: z13.record(z13.string(), z13.string()).optional()
8169
+ });
8170
+ var mcpSseServerSchema = z13.object({
8171
+ type: z13.literal("sse"),
8172
+ url: z13.string().url(),
8173
+ headers: z13.record(z13.string(), z13.string()).optional()
8174
+ });
8175
+ var mcpServerDefSchema = z13.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
8176
+ var thinkingConfigSchema = z13.discriminatedUnion("type", [
8177
+ z13.object({ type: z13.literal("adaptive") }),
8178
+ z13.object({ type: z13.literal("enabled"), budgetTokens: z13.number().int().positive().optional() }),
8179
+ z13.object({ type: z13.literal("disabled") })
8180
+ ]);
8181
+ var effortSchema = z13.enum(["low", "medium", "high", "xhigh", "max"]);
8182
+ var runConfigSchema = z13.object({
8183
+ // CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
8184
+ // trio + its custom tool lists — `true` grants the host filesystem/shell, absent
8185
+ // is the locked surface. Kept in lockstep with `@cabane/shared`'s
8186
+ // `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
8187
+ // stripped, so an older companion riding a newer server never rejects the config).
8188
+ hostAccess: z13.boolean().optional(),
8189
+ mcpServers: z13.record(z13.string(), mcpServerDefSchema).optional(),
8190
+ model: z13.string().min(1).optional(),
8191
+ thinking: thinkingConfigSchema.optional(),
8192
+ effort: effortSchema.optional()
8193
+ });
8194
+ var EMPTY_RUN_CONFIG = {};
8195
+ function parseRunConfig(raw, onWarn) {
8196
+ const result = runConfigSchema.safeParse(raw ?? {});
8197
+ if (result.success) return result.data;
8198
+ onWarn?.(
8199
+ `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`
8200
+ );
8201
+ return EMPTY_RUN_CONFIG;
8202
+ }
8203
+
8204
+ // src/runner/protocol.ts
8205
+ var RUNNER_ENV = {
8206
+ // The wire the runner speaks.
8207
+ url: "CABANE_URL",
8208
+ // The dispatch as the device stream delivered it.
8209
+ dispatch: "CABANE_DISPATCH",
8210
+ // The turn id the launcher resolved, and whether this is a replay of a turn
8211
+ // an earlier process started. The seq floor for a replay is derived inside
8212
+ // the turn from the outbox and the server, as it always was.
8213
+ turnId: "CABANE_TURN_ID",
8214
+ resumed: "CABANE_RESUMED",
8215
+ // Everything else the turn is constructed from, as JSON: see `RunnerSpec`.
8216
+ spec: "CABANE_RUNNER_SPEC"
8217
+ };
8218
+ var runtimeHealthSchema = z14.object({
8219
+ quotaState: z14.enum(["ok", "limited", "auth_expired", "unknown"]),
8220
+ limitedUntil: z14.string().nullable(),
8221
+ lastError: z14.string().nullable()
8222
+ });
8223
+ var runnerSpecSchema = z14.object({
8224
+ deviceId: z14.string().optional(),
8225
+ // The launcher's boot id: the one its heartbeat carries, so a runner's lease
8226
+ // matches the device that holds it.
8227
+ bootId: z14.string(),
8228
+ workspaceId: z14.string(),
8229
+ workspaceSlug: z14.string(),
8230
+ agentId: z14.string(),
8231
+ agentUsername: z14.string(),
8232
+ agentName: z14.string(),
8233
+ local: localAgentConfigSchema,
8234
+ runConfig: runConfigSchema,
8235
+ opencodeServerUrl: z14.string().optional(),
8236
+ claudeCodeAvailable: z14.boolean(),
8237
+ codexAvailable: z14.boolean(),
8238
+ idleTimeoutMs: z14.number().int().positive().optional(),
8239
+ totalTimeoutMs: z14.number().int().positive().optional(),
8240
+ leaseRenewalMs: z14.number().int().positive().optional(),
8241
+ preparingRowDelayMs: z14.number().int().nonnegative().optional(),
8242
+ // Whether the turn writes a transcript. The directory is the runner's own
8243
+ // (`transcriptsDir()`), so `CABANE_RUNNER_STATE_DIR` moves it with the outbox.
8244
+ transcripts: z14.boolean(),
8245
+ // Whether the harness starts under the OS sandbox. The launcher sends 'os'
8246
+ // only when this host can make one.
8247
+ sandbox: z14.enum(["os", "none"]),
8248
+ logLevel: z14.string(),
8249
+ // The launcher's connector-health observations, by runtime, as of the start.
8250
+ connectorHealth: z14.record(z14.string(), runtimeHealthSchema),
8251
+ // True when this (conversation, agent) pair was already told its session
8252
+ // could not be saved: the notice is once per pair, not once per process.
8253
+ sessionWriteNoticeClaimed: z14.boolean(),
8254
+ // CT1680: the runner's identity, minted with its credential. The start
8255
+ // PATCH carries it so the lease knows which runner holds the span.
8256
+ runnerId: z14.string()
8257
+ });
8258
+ var dispatchSchema = z14.object({
8259
+ type: z14.literal("device:dispatch_requested"),
8260
+ agentId: z14.string(),
8261
+ deviceId: z14.string(),
8262
+ conversationId: z14.string(),
8263
+ messageId: z14.string(),
8264
+ episode: z14.number().int().optional()
8265
+ });
8266
+ var credentialsSchema = z14.object({
8267
+ credential: z14.string().startsWith("cabrn_")
8268
+ });
8269
+ function runnerEnv(input) {
8270
+ return {
8271
+ [RUNNER_ENV.url]: input.baseUrl,
8272
+ [RUNNER_ENV.dispatch]: JSON.stringify(input.dispatch),
8273
+ [RUNNER_ENV.turnId]: input.turnId,
8274
+ ...input.resumed ? { [RUNNER_ENV.resumed]: "1" } : {},
8275
+ [RUNNER_ENV.spec]: JSON.stringify(input.spec)
8276
+ };
8277
+ }
8278
+ function runnerStdin(input) {
8279
+ return `${JSON.stringify({ credential: input.credential })}
8280
+ `;
8281
+ }
8282
+ function encodeLauncherMessage(message) {
8283
+ return `${JSON.stringify(message)}
8284
+ `;
8285
+ }
8286
+ function decodeRunnerEvent(line) {
8287
+ if (!line.startsWith("{")) return null;
8288
+ let value;
8289
+ try {
8290
+ value = JSON.parse(line);
8291
+ } catch {
8292
+ return null;
8293
+ }
8294
+ if (!value || typeof value !== "object" || !("cabaneRunner" in value)) return null;
8295
+ return value;
8296
+ }
8297
+
8298
+ // src/runner/launch.ts
8299
+ function selfRunnerCommand() {
8300
+ const script = process.argv[1];
8301
+ if (!script) throw new Error("runners: cannot tell which script started this companion");
8302
+ return [process.execPath, ...process.execArgv, script, "run-turn"];
8303
+ }
8304
+ function healthSnapshot(store) {
8305
+ const out = {};
8306
+ for (const r of store?.reports() ?? []) {
8307
+ out[r.runtime] = {
8308
+ quotaState: r.quotaState ?? "unknown",
8309
+ limitedUntil: r.limitedUntil ?? null,
8310
+ lastError: r.lastError ?? null
8311
+ };
8312
+ }
8313
+ return out;
8314
+ }
8315
+ function runnerSpec(opts, launch, duties, runnerId) {
8316
+ return {
8317
+ runnerId,
8318
+ ...opts.deviceId ? { deviceId: opts.deviceId } : {},
8319
+ bootId,
8320
+ workspaceId: opts.workspaceId,
8321
+ workspaceSlug: opts.workspaceSlug,
8322
+ agentId: opts.agentId,
8323
+ agentUsername: opts.agentUsername,
8324
+ agentName: String(opts.log.bindings().agentName ?? opts.agentUsername),
8325
+ local: opts.local,
8326
+ runConfig: opts.runConfig,
8327
+ ...opts.opencodeServerUrl ? { opencodeServerUrl: opts.opencodeServerUrl } : {},
8328
+ // Read now, per turn, as the in-process path reads them at adapter selection.
8329
+ claudeCodeAvailable: opts.claudeCodeAvailable?.() ?? true,
8330
+ codexAvailable: opts.codexAvailable?.() ?? false,
8331
+ ...opts.idleTimeoutMs !== void 0 ? { idleTimeoutMs: opts.idleTimeoutMs } : {},
8332
+ ...opts.totalTimeoutMs !== void 0 ? { totalTimeoutMs: opts.totalTimeoutMs } : {},
8333
+ ...opts.leaseRenewalMs !== void 0 ? { leaseRenewalMs: opts.leaseRenewalMs } : {},
8334
+ ...opts.preparingRowDelayMs !== void 0 ? { preparingRowDelayMs: opts.preparingRowDelayMs } : {},
8335
+ transcripts: opts.transcriptDir !== void 0,
8336
+ sandbox: launch.sandbox ?? "none",
8337
+ logLevel: opts.log.level,
8338
+ connectorHealth: healthSnapshot(launch.connectorHealth),
8339
+ sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
8340
+ };
8341
+ }
8342
+ var PINO_LEVELS = {
8343
+ 10: "trace",
8344
+ 20: "debug",
8345
+ 30: "info",
8346
+ 40: "warn",
8347
+ 50: "error",
8348
+ 60: "fatal"
8349
+ };
8350
+ function relayRunnerLog(line, log) {
8351
+ if (!line.trim()) return;
8352
+ let record = null;
8353
+ try {
8354
+ const parsed = JSON.parse(line);
8355
+ if (parsed && typeof parsed === "object") record = parsed;
8356
+ } catch {
8357
+ record = null;
8358
+ }
8359
+ if (!record || typeof record.level !== "number") {
8360
+ log.debug({ runnerStderr: line }, "runner: output");
8361
+ return;
8362
+ }
8363
+ const {
8364
+ level,
8365
+ msg,
8366
+ time: _time,
8367
+ pid: _pid,
8368
+ hostname: _hostname,
8369
+ agentName: _agentName,
8370
+ ...fields
8371
+ } = record;
8372
+ const method = PINO_LEVELS[level] ?? "info";
8373
+ log[method](fields, typeof msg === "string" ? msg : "");
8374
+ }
8375
+ function lines(stream, onLine) {
8376
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
8377
+ rl.on("line", onLine);
8378
+ return new Promise((resolve2) => rl.once("close", resolve2));
8379
+ }
8380
+ function giveBackLocks(locks, prefix, remote, launch, log) {
8381
+ locks.withdraw(prefix);
8382
+ const stop = launch.stopRemoteSession ?? ((url, id) => stopRemoteOpencodeSession(url, id));
8383
+ for (const { key, owner } of locks.heldBy(prefix)) {
8384
+ const left = remote.filter((r) => opencodeLockKey(r.serverUrl) === key);
8385
+ if (!left.length) {
8386
+ locks.release(owner);
8387
+ continue;
8388
+ }
8389
+ void (async () => {
8390
+ for (let attempt = 1; ; attempt++) {
8391
+ const stopped = await Promise.all(left.map((r) => stop(r.serverUrl, r.sessionId))).catch(
8392
+ () => [false]
8393
+ );
8394
+ if (stopped.every(Boolean)) break;
8395
+ if (attempt === 1)
8396
+ log.warn(
8397
+ { key, sessions: left.map((r) => r.sessionId) },
8398
+ "runner: a dead runner left work on a shared server that could not be confirmed stopped; holding the server until it is"
8399
+ );
8400
+ await new Promise((resolve2) => setTimeout(resolve2, launch.reconcileRetryMs ?? 5e3));
8401
+ }
8402
+ locks.release(owner);
8403
+ })();
8404
+ }
8405
+ }
8406
+ async function mintForRunner(opts, payload, handleOpts, signal, log) {
8407
+ try {
8408
+ const minted = await opts.api.mintRunnerCredential({
8409
+ conversationId: payload.conversationId,
8410
+ messageId: payload.messageId,
8411
+ ...payload.episode !== void 0 ? { episode: payload.episode } : {},
8412
+ // The one turn the credential will operate: this run's.
8413
+ turnId: handleOpts.turnId
8414
+ });
8415
+ return { ok: true, credential: minted.credential, runnerId: minted.runnerId };
8416
+ } catch (err) {
8417
+ if (signal.aborted) return { ok: false, reason: "cancelled" };
8418
+ const status = err instanceof ApiError ? err.status : 0;
8419
+ if (status === 404) return { ok: false, reason: "runner_credential_not_found" };
8420
+ if (status === 409 && apiErrorCode(err) === "dispatch_not_admitted")
8421
+ return { ok: false, reason: "runner_credential_not_admitted" };
8422
+ log.warn(apiErrorLogFields(err), "runner: couldn't get a credential for the turn's runner");
8423
+ const reason = `fetch_failed: ${describeError(err)}`;
8424
+ return { ok: false, reason, errorReason: reason };
8425
+ }
8426
+ }
8427
+ async function settleUnstarted(opts, payload, turnId, errorReason, log) {
8428
+ try {
8429
+ await opts.api.setActiveRun(opts.workspaceId, payload.conversationId, payload.agentId, {
8430
+ activeRunStartedAt: null,
8431
+ turnId,
8432
+ outcome: "failed",
8433
+ ...errorReason ? { errorReason: errorReason.slice(0, 200) } : {},
8434
+ readThrough: payload.messageId
8435
+ });
8436
+ } catch (err) {
8437
+ log.warn(
8438
+ { ...apiErrorLogFields(err), turnId },
8439
+ "Couldn't update the turn's status in Cabane. Check the conversation before trying again."
8440
+ );
8441
+ }
8442
+ }
8443
+ async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
8444
+ const startedAt = Date.now();
8445
+ const { turnId } = handleOpts;
8446
+ const log = opts.log.child({
8447
+ workspaceId: opts.workspaceId,
8448
+ conversationId: payload.conversationId,
8449
+ agentId: payload.agentId,
8450
+ messageId: payload.messageId
8451
+ });
8452
+ const controller = new AbortController();
8453
+ duties.registerAbort(turnId, controller);
8454
+ const stopRunner = new AbortController();
8455
+ let ready = false;
8456
+ const forwardStop = () => {
8457
+ if (ready) stopRunner.abort();
8458
+ };
8459
+ controller.signal.addEventListener("abort", forwardStop, { once: true });
8460
+ const attempt = {
8461
+ workspaceId: opts.workspaceId,
8462
+ conversationId: payload.conversationId,
8463
+ agentId: payload.agentId,
8464
+ turnId
8465
+ };
8466
+ const minted = await mintForRunner(opts, payload, handleOpts, controller.signal, log);
8467
+ if (!minted.ok) {
8468
+ controller.signal.removeEventListener("abort", forwardStop);
8469
+ duties.releaseAbort(turnId, controller);
8470
+ const durationMs2 = Date.now() - startedAt;
8471
+ await settleUnstarted(opts, payload, turnId, minted.errorReason, log);
8472
+ duties.notifyEnd({ id: payload.messageId, ok: false, durationMs: durationMs2, reason: minted.reason });
8473
+ return { ok: false, durationMs: durationMs2, reason: minted.reason };
8474
+ }
8475
+ let result;
8476
+ let thrown;
8477
+ let code;
8478
+ let handle;
8479
+ let child;
8480
+ const groups = [];
8481
+ const remote = [];
8482
+ const lockOwner = (id) => `${turnId}#${id}`;
8483
+ const locks = launch.locks;
8484
+ try {
8485
+ handle = await launch.provider.acquire(attempt);
8486
+ const env = {};
8487
+ for (const [k, v] of Object.entries(launch.env ?? process.env)) {
8488
+ if (v !== void 0) env[k] = v;
8489
+ }
8490
+ const input = {
8491
+ baseUrl: opts.baseUrl,
8492
+ credential: minted.credential,
8493
+ dispatch: payload,
8494
+ turnId,
8495
+ resumed: handleOpts.resumed,
8496
+ spec: runnerSpec(opts, launch, duties, minted.runnerId)
8497
+ };
8498
+ Object.assign(env, runnerEnv(input));
8499
+ const running = launch.provider.exec(handle, launch.command, env, {
8500
+ signal: stopRunner.signal,
8501
+ stdin: runnerStdin(input)
8502
+ });
8503
+ child = running;
8504
+ log.debug(
8505
+ { turnId, runnerPid: running.pid, provider: launch.provider.name },
8506
+ "runner: started"
8507
+ );
8508
+ const stdoutDone = lines(running.stdout, (line) => {
8509
+ const event = decodeRunnerEvent(line);
8510
+ if (!event) {
8511
+ log.debug({ runnerStdout: line }, "runner: output");
8512
+ return;
8513
+ }
8514
+ switch (event.cabaneRunner) {
8515
+ case "ready":
8516
+ ready = true;
8517
+ if (controller.signal.aborted) stopRunner.abort();
8518
+ return;
8519
+ case "start":
8520
+ duties.notifyStart({
8521
+ id: event.id,
8522
+ conversationId: event.conversationId,
8523
+ message: event.message
8524
+ });
8525
+ return;
8526
+ case "end": {
8527
+ const { cabaneRunner: _kind, ...info } = event;
8528
+ duties.notifyEnd(info);
8529
+ return;
8530
+ }
8531
+ case "session_write_notice":
8532
+ duties.markSessionWriteNotice();
8533
+ return;
8534
+ case "connector_health":
8535
+ launch.connectorHealth?.recordSettle(event.runtime, {
8536
+ ok: event.ok,
8537
+ errorReason: event.errorReason
8538
+ });
8539
+ return;
8540
+ case "result":
8541
+ result = event.result;
8542
+ return;
8543
+ case "error":
8544
+ thrown = event.message;
8545
+ return;
8546
+ case "lock":
8547
+ if (!locks) {
8548
+ log.warn(
8549
+ { key: event.key },
8550
+ "runner: asked for a device lock this launcher has none of"
8551
+ );
8552
+ return;
8553
+ }
8554
+ locks.request(
8555
+ event.key,
8556
+ lockOwner(event.id),
8557
+ () => running.stdin.write(encodeLauncherMessage({ cabaneLauncher: "granted", id: event.id }))
8558
+ );
8559
+ return;
8560
+ case "unlock":
8561
+ locks?.release(lockOwner(event.id));
8562
+ return;
8563
+ case "process_group":
8564
+ groups.push(event.pgid);
8565
+ return;
8566
+ case "remote_session":
8567
+ remote.push({ serverUrl: event.serverUrl, sessionId: event.sessionId });
8568
+ return;
8569
+ }
8570
+ });
8571
+ const stderrDone = lines(running.stderr, (line) => relayRunnerLog(line, opts.log));
8572
+ code = await running.exit;
8573
+ await Promise.all([stdoutDone, stderrDone]);
8574
+ } finally {
8575
+ if (locks) giveBackLocks(locks, `${turnId}#`, remote, launch, log);
8576
+ child?.stdin.end();
8577
+ controller.signal.removeEventListener("abort", forwardStop);
8578
+ duties.releaseAbort(turnId, controller);
8579
+ if (handle) await launch.provider.release(handle, { destroy: false });
8580
+ }
8581
+ if (result) return result;
8582
+ const durationMs = Date.now() - startedAt;
8583
+ const reason = thrown !== void 0 ? `runner_failed: ${thrown}` : `runner_exited: ${code}`;
8584
+ await child?.reap(groups);
8585
+ const reaped = await (launch.reapTurn ?? reapTurnScopes)(turnId).catch((err) => ({
8586
+ failures: [err instanceof Error ? err.message : String(err)]
8587
+ }));
8588
+ if (reaped.failures.length) {
8589
+ log.warn(
8590
+ { turnId, failures: reaped.failures },
8591
+ "runner: couldn't stop what the dead runner's turn left running"
8592
+ );
8593
+ }
8594
+ try {
8595
+ await opts.api.setActiveRun(opts.workspaceId, payload.conversationId, payload.agentId, {
8596
+ activeRunStartedAt: null,
8597
+ turnId,
8598
+ outcome: "failed",
8599
+ errorReason: reason.slice(0, 200),
8600
+ readThrough: payload.messageId
8601
+ });
8602
+ } catch (err) {
8603
+ log.warn(
8604
+ { ...apiErrorLogFields(err), turnId },
8605
+ "Couldn't update the turn's status in Cabane. Check the conversation before trying again."
8606
+ );
8607
+ }
8608
+ await launch.reportRunnerGone?.({
8609
+ workspaceId: opts.workspaceId,
8610
+ conversationId: payload.conversationId,
8611
+ agentId: payload.agentId,
8612
+ messageId: payload.messageId,
8613
+ runnerId: minted.runnerId,
8614
+ reason: "exited"
8615
+ });
8616
+ if (thrown !== void 0) throw new Error(thrown);
8617
+ log.warn({ turnId, code }, "runner: exited without reporting a result");
8618
+ duties.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
8619
+ return { ok: false, durationMs, reason };
8620
+ }
8621
+
8622
+ // src/term.ts
8623
+ import { createInterface as createInterface2 } from "readline";
8624
+ import { homedir as homedir2 } from "os";
8625
+ import pc from "picocolors";
8626
+ function tildePath(path) {
8627
+ const home = homedir2();
8628
+ if (home && path.startsWith(home + "/")) return `~${path.slice(home.length)}`;
8629
+ return path;
8630
+ }
8631
+
8632
+ // src/turn-execution.ts
8633
+ import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
8634
+ import { existsSync as existsSync9 } from "fs";
8635
+
8636
+ // src/turn-control-tools.ts
8637
+ import { randomUUID as randomUUID2 } from "crypto";
8638
+ import { z as z15 } from "zod";
8639
+ var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
8640
+ var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
8641
+ var SKIP_TURN_TOOL = "skip_turn";
8642
+ var ASK_TOOL = "ask";
8643
+ var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
8644
+ var WITHDRAW_ASK_TOOL = "withdraw_ask";
8645
+ var WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
8646
+ var WAKE_ME_TOOL = "wake_me";
8647
+ var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
8648
+ var CANCEL_WAKE_TOOL = "cancel_wake";
8649
+ var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
8650
+ var TURN_ACT_TIMEOUT_MS = 45e3;
8651
+ function createSkipState() {
8652
+ return { skipped: false, reason: null };
8653
+ }
8654
+ function toolError(text) {
8655
+ return { isError: true, content: [{ type: "text", text }] };
8656
+ }
8657
+ async function performAct(acts, act) {
8658
+ let result;
8659
+ try {
8660
+ result = await acts.api.turnAct(
8661
+ acts.workspaceId,
8662
+ acts.turnId,
8663
+ act,
8664
+ AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
8665
+ );
8666
+ } catch (err) {
8667
+ if (err instanceof ApiError && err.status >= 500) {
8668
+ return uncertainOutcome(`${err.status}: ${apiErrorMessage(err)}`);
8669
+ }
8670
+ if (err instanceof ApiError) {
8671
+ if (err.status === 409 && apiErrorCode(err) === "no_active_turn") {
8672
+ return toolError(
8673
+ "No active turn: this turn has already settled, so the act was not performed."
8674
+ );
8675
+ }
8676
+ return toolError(`The act was refused (${err.status}): ${apiErrorMessage(err)}`);
8677
+ }
8678
+ return uncertainOutcome(err instanceof Error ? err.message : String(err));
8679
+ }
8680
+ if (!result.ok) return toolError(JSON.stringify(result));
8681
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
8682
+ }
8683
+ function uncertainOutcome(cause) {
8684
+ return toolError(
8685
+ `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.`
8686
+ );
8687
+ }
8688
+ function createTurnControlMcpServer(acts, skipState) {
8689
+ const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
8690
+ const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
8691
+ const RECORDER_TOOL = { readOnlyHint: true, openWorldHint: false };
8692
+ return createSdkMcpServer({
8693
+ name: COMPANION_LOCAL_MCP_SERVER,
8694
+ version: "0.0.0",
8695
+ tools: [
8696
+ ...skipState ? [
8697
+ tool(
8698
+ SKIP_TURN_TOOL,
8699
+ TURN_CONTROL_TOOL_DESCRIPTIONS.skip_turn,
8700
+ {
8701
+ reason: z15.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
8702
+ },
8703
+ async (args) => {
8704
+ skipState.skipped = true;
8705
+ skipState.reason = args.reason;
8706
+ return {
8707
+ content: [{ type: "text", text: JSON.stringify({ skipped: true }) }]
8708
+ };
8709
+ },
8710
+ { annotations: RECORDER_TOOL, alwaysLoad: true }
8711
+ )
8712
+ ] : [],
8713
+ tool(
8714
+ ASK_TOOL,
8715
+ TURN_CONTROL_TOOL_DESCRIPTIONS.ask,
8716
+ {
8717
+ targetUserId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
8718
+ question: z15.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
8719
+ headline: z15.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
8720
+ options: z15.array(
8721
+ z15.union([
8722
+ z15.string().min(1).max(MAX_ASK_OPTION_BYTES),
8723
+ z15.object({
8724
+ label: z15.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
8725
+ move: z15.object({
8726
+ task: z15.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
8727
+ status: z15.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
8728
+ }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
8729
+ })
8730
+ ])
8731
+ ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
8732
+ questions: z15.array(
8733
+ z15.object({
8734
+ headline: z15.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
8735
+ body: z15.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
8736
+ options: z15.array(
8737
+ z15.union([
8738
+ z15.string().min(1).max(MAX_ASK_OPTION_BYTES),
8739
+ z15.object({
8740
+ label: z15.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
8741
+ move: z15.object({
8742
+ task: z15.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
8743
+ status: z15.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
8744
+ }).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
8745
+ })
8746
+ ])
8747
+ ).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
8748
+ })
8749
+ ).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
8750
+ replaces: z15.object({
8751
+ askId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
8752
+ reason: z15.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
8753
+ }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.replaces)
8754
+ },
8755
+ async (args) => {
8756
+ const hasSingle = args.question !== void 0;
8757
+ const hasArray = args.questions !== void 0 && args.questions.length > 0;
8758
+ if (hasSingle && hasArray) {
8759
+ return toolError(
8760
+ "Provide either `question` (single) or `questions` (array), not both."
8761
+ );
8762
+ }
8763
+ if (!hasSingle && !hasArray) {
8764
+ return toolError("Provide `question` (single) or `questions` (array).");
8765
+ }
8766
+ return performAct(acts, {
8767
+ kind: "ask",
8768
+ callId: randomUUID2(),
8769
+ // CT1350: the swap rides the ask, so the server commits both halves
8770
+ // in one transaction — the person never sees neither or both.
8771
+ ...args.replaces ? { replaces: args.replaces } : {},
8772
+ ask: hasArray ? {
8773
+ targetUserId: args.targetUserId,
8774
+ questions: args.questions.map((q) => ({
8775
+ headline: q.headline,
8776
+ ...q.body ? { body: q.body } : {},
8777
+ ...q.options && q.options.length > 0 ? { options: q.options } : {}
8778
+ }))
8779
+ } : {
8780
+ targetUserId: args.targetUserId,
8781
+ question: args.question,
8782
+ ...args.headline ? { headline: args.headline } : {},
8783
+ ...args.options && args.options.length > 0 ? { options: args.options } : {}
8784
+ }
8785
+ });
8786
+ },
8787
+ { annotations: ACT_TOOL, alwaysLoad: true }
8788
+ ),
8789
+ tool(
8790
+ WAKE_ME_TOOL,
8791
+ TURN_CONTROL_TOOL_DESCRIPTIONS.wake_me,
8792
+ {
8793
+ afterSeconds: z15.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
8794
+ at: z15.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
8795
+ note: z15.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
8796
+ },
8797
+ async (args) => {
8798
+ const hasAfter = args.afterSeconds !== void 0;
8799
+ const hasAt = args.at !== void 0;
8800
+ if (hasAfter && hasAt) {
8801
+ return toolError(
8802
+ "Provide either `afterSeconds` (relative) or `at` (absolute), not both."
8803
+ );
8804
+ }
8805
+ if (!hasAfter && !hasAt) {
8806
+ return toolError("Provide `afterSeconds` or `at`.");
8807
+ }
8808
+ return performAct(acts, {
8809
+ kind: "wake",
8810
+ callId: randomUUID2(),
8811
+ wake: {
8812
+ ...hasAfter ? { afterSeconds: args.afterSeconds } : {},
8813
+ ...hasAt ? { at: args.at } : {},
8814
+ note: args.note
8815
+ }
8816
+ });
8817
+ },
8818
+ { annotations: ACT_TOOL, alwaysLoad: true }
8819
+ ),
8820
+ // CT1350: the standalone withdrawal. Not annotated destructive — an ask
8821
+ // is a question, not data, and a harness that gated this on a live
8822
+ // operator would hang a turn nobody is watching (the `cancel_wake`
8823
+ // reasoning). Not idempotent either: a second call with the same id is
8824
+ // refused with `ask_not_open`, which is information, not a no-op.
8825
+ tool(
8826
+ WITHDRAW_ASK_TOOL,
8827
+ TURN_CONTROL_TOOL_DESCRIPTIONS.withdraw_ask,
8828
+ {
8829
+ askId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
8830
+ reason: z15.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
8831
+ },
8832
+ async (args) => performAct(acts, {
8833
+ kind: "withdraw_ask",
8834
+ callId: randomUUID2(),
8835
+ withdraw: { askId: args.askId, reason: args.reason }
8836
+ }),
8837
+ { annotations: ACT_TOOL, alwaysLoad: true }
8838
+ ),
8839
+ // CT990: the inverse. No arguments — it can only ever stand down the
8840
+ // caller's own wake in the conversation it's holding a turn in.
8841
+ tool(
8842
+ CANCEL_WAKE_TOOL,
8843
+ TURN_CONTROL_TOOL_DESCRIPTIONS.cancel_wake,
8844
+ {},
8845
+ async () => performAct(acts, { kind: "cancel_wake", callId: randomUUID2() }),
8846
+ { annotations: CANCEL_TOOL, alwaysLoad: true }
8847
+ )
8848
+ ]
8849
+ });
8850
+ }
8851
+
8852
+ // src/build-options.ts
8853
+ function cabaneMcpUrl(baseUrl) {
8854
+ return `${trimSlash2(baseUrl)}/api/mcp`;
8855
+ }
8856
+ function turnControlMcpUrl(baseUrl) {
8857
+ return `${trimSlash2(baseUrl)}/api/turn-control`;
8858
+ }
8859
+ function buildCompanionTurnRequest(params) {
8860
+ const { turnContext: t } = params;
8861
+ return {
8862
+ systemPrompt: t.systemPrompt,
8863
+ prompt: t.prompt,
8864
+ content: t.content,
8865
+ config: t.config,
8866
+ policy: t.policy,
8867
+ session: t.session,
8868
+ cabane: {
8869
+ mcpUrl: cabaneMcpUrl(params.baseUrl),
8065
8870
  bearer: params.turnToken,
8066
8871
  activeConversationId: params.activeConversationId,
8067
8872
  workspaceId: params.workspaceId,
@@ -8137,7 +8942,7 @@ function readOutboxFloor(read, turnId, log) {
8137
8942
  }
8138
8943
 
8139
8944
  // src/prepare-failure.ts
8140
- import { spawn as spawn4 } from "child_process";
8945
+ import { spawn as spawn5 } from "child_process";
8141
8946
  import { hostname } from "os";
8142
8947
  function prepareFailureCause(error) {
8143
8948
  const message = error instanceof Error ? error.message : String(error);
@@ -8175,7 +8980,7 @@ async function runPrepareWithReporting(runner, hook, input, context, reportError
8175
8980
  }
8176
8981
  function reportFailure(config, body) {
8177
8982
  return new Promise((resolve2, reject) => {
8178
- const child = spawn4(config.command, config.args ?? [], {
8983
+ const child = spawn5(config.command, config.args ?? [], {
8179
8984
  stdio: ["pipe", "ignore", "ignore"],
8180
8985
  env: { ...process.env, ...config.env }
8181
8986
  });
@@ -8199,7 +9004,7 @@ function reportFailure(config, body) {
8199
9004
  }
8200
9005
 
8201
9006
  // src/prepared.ts
8202
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync5, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
9007
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
8203
9008
  import { join as join11 } from "path";
8204
9009
  function dirFor(workspaceId) {
8205
9010
  return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
@@ -8214,7 +9019,7 @@ function readPrepared(workspaceId, conversationId, agentId) {
8214
9019
  const path = pathFor2(workspaceId, conversationId, agentId);
8215
9020
  if (!existsSync7(path)) return null;
8216
9021
  try {
8217
- const parsed = JSON.parse(readFileSync5(path, "utf8"));
9022
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
8218
9023
  if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
8219
9024
  return {
8220
9025
  cwd: parsed.cwd,
@@ -8239,20 +9044,20 @@ function clearPrepared(workspaceId, conversationId, agentId) {
8239
9044
  }
8240
9045
 
8241
9046
  // src/secrets.ts
8242
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
9047
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
8243
9048
  import { join as join12 } from "path";
8244
- import { z as z14 } from "zod";
9049
+ import { z as z16 } from "zod";
8245
9050
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
8246
9051
  function secretsPath() {
8247
9052
  return join12(cabaneDir(), "secrets.json");
8248
9053
  }
8249
- var secretStoreSchema = z14.record(z14.string(), z14.string());
9054
+ var secretStoreSchema = z16.record(z16.string(), z16.string());
8250
9055
  function loadSecretStore() {
8251
9056
  const path = secretsPath();
8252
9057
  if (!existsSync8(path)) return makeStore({});
8253
9058
  let raw;
8254
9059
  try {
8255
- raw = readFileSync6(path, "utf8");
9060
+ raw = readFileSync7(path, "utf8");
8256
9061
  } catch (err) {
8257
9062
  throw new ConfigError(
8258
9063
  `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -8333,7 +9138,7 @@ function resolveMcpSecrets(mcpServers, store) {
8333
9138
  import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync6 } from "fs";
8334
9139
  import { basename, dirname as dirname6, join as join13 } from "path";
8335
9140
  function transcriptsDir() {
8336
- return join13(cabaneDir(), "transcripts");
9141
+ return join13(runnerStateDir(), "transcripts");
8337
9142
  }
8338
9143
  var RETAIN = 200;
8339
9144
  var ANOMALY_RETAIN = 50;
@@ -8362,325 +9167,69 @@ var TranscriptWriter = class {
8362
9167
  }
8363
9168
  // Append the outcome footer.
8364
9169
  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
- });
8584
- }
9170
+ this.line({ type: "_outcome", ...outcome });
9171
+ }
9172
+ // CT1145: keep an independent copy of an empty successful envelope after the
9173
+ // footer has landed. The ordinary 200-file FIFO keeps rotating as before;
9174
+ // this bucket retains the newest 50 anomalies regardless of ordinary traffic.
9175
+ // Best-effort like every other transcript operation: observability can never
9176
+ // fail the turn.
9177
+ preserveAnomaly() {
9178
+ if (this.broken) return;
9179
+ try {
9180
+ const dir2 = join13(dirname6(this.path), "anomalies");
9181
+ mkdirSync8(dir2, { recursive: true, mode: 448 });
9182
+ chmodSync4(dir2, 448);
9183
+ const target = join13(dir2, basename(this.path));
9184
+ copyFileSync(this.path, target);
9185
+ chmodSync4(target, 384);
9186
+ pruneOld(dir2, ANOMALY_RETAIN);
9187
+ } catch (err) {
9188
+ this.fail(err);
8585
9189
  }
8586
9190
  }
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);
9191
+ line(obj) {
9192
+ if (this.broken) return;
9193
+ try {
9194
+ appendFileSync(this.path, JSON.stringify(obj) + "\n", { mode: 384 });
9195
+ } catch (err) {
9196
+ this.fail(err);
8649
9197
  }
8650
9198
  }
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;
9199
+ fail(err) {
9200
+ if (this.broken) return;
9201
+ this.broken = true;
9202
+ this.onWarn?.(`transcript write failed (${err instanceof Error ? err.message : String(err)})`);
9203
+ }
9204
+ };
9205
+ function fileName(meta) {
9206
+ const d = new Date(meta.ts);
9207
+ const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
9208
+ const slug = safe2(meta.workspaceSlug) || "ws";
9209
+ const short = meta.dispatchId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "turn";
9210
+ return `${stamp}-${slug}-${short}.jsonl`;
9211
+ }
9212
+ function pad(n) {
9213
+ return String(n).padStart(2, "0");
9214
+ }
9215
+ function safe2(s) {
9216
+ return s.replace(/[^a-zA-Z0-9_-]/g, "-");
9217
+ }
9218
+ function pruneOld(dir2, retain) {
9219
+ let files;
9220
+ try {
9221
+ files = readdirSync(dir2).filter((f) => f.endsWith(".jsonl"));
9222
+ } catch {
9223
+ return;
9224
+ }
9225
+ if (files.length <= retain) return;
9226
+ const drop = files.sort().slice(0, files.length - retain);
9227
+ for (const f of drop) {
9228
+ try {
9229
+ rmSync6(join13(dir2, f), { force: true });
9230
+ } catch {
8682
9231
  }
8683
- };
9232
+ }
8684
9233
  }
8685
9234
 
8686
9235
  // src/turn-committer.ts
@@ -9291,14 +9840,15 @@ var TurnExecution = class {
9291
9840
  const reportPreparing = (phase) => {
9292
9841
  preparing ??= { id: `prepare:${randomUUID3()}`, seq: this.nextSeq() };
9293
9842
  const { id, seq } = preparing;
9294
- void this.opts.api.reportActivity(workspaceId, payload.conversationId, payload.agentId, {
9843
+ const body = {
9295
9844
  turnId,
9296
9845
  toolUseId: id,
9297
9846
  toolName: PREPARING_TOOL_NAME,
9298
9847
  summary: "",
9299
9848
  phase,
9300
9849
  seq
9301
- }).catch((err) => {
9850
+ };
9851
+ void this.opts.api.reportActivity(workspaceId, payload.conversationId, payload.agentId, body).catch((err) => {
9302
9852
  turnLog.debug(
9303
9853
  { err: err instanceof Error ? err.message : String(err), phase },
9304
9854
  "dispatcher: preparing-activity report failed (continuing with the hook)"
@@ -9371,7 +9921,11 @@ var TurnExecution = class {
9371
9921
  try {
9372
9922
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
9373
9923
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
9374
- bootId,
9924
+ // CT1679: a runner leases under its launcher's boot id — the one the
9925
+ // device heartbeats — or the lost-run sweep reads it as a restart.
9926
+ bootId: this.opts.inRunner?.bootId ?? bootId,
9927
+ // CT1680: the runner holding the span, beside the device's boot id.
9928
+ ...this.opts.inRunner ? { runnerId: this.opts.inRunner.runnerId } : {},
9375
9929
  // CT1380: the durable record that this turn resumed, and from where.
9376
9930
  ...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
9377
9931
  // CT1454: the issuance the context THIS attempt runs on came from. Read
@@ -9468,10 +10022,14 @@ var TurnExecution = class {
9468
10022
  const { payload, workspaceId, turnId, turnLog } = this;
9469
10023
  await guardBundledBinary(this.integrityContext(), (r, e) => this.concluded(r, e), this.outcome);
9470
10024
  const onWarn = (msg, meta) => turnLog.debug(meta ?? {}, msg);
10025
+ const sandbox = this.opts.harnessSandbox ? await this.opts.harnessSandbox({ cwd: this.effectiveCwd, log: turnLog }) : null;
9471
10026
  const adapters = [];
9472
10027
  if (this.opts.claudeCodeAvailable?.() ?? true) {
9473
10028
  this.containment = createTurnContainment(this.turnId, {
9474
- log: (msg, meta) => turnLog.debug(meta ?? {}, msg)
10029
+ log: (msg, meta) => turnLog.debug(meta ?? {}, msg),
10030
+ // CT1679: a group outside the runner's own is reported, so a launcher
10031
+ // can stop it if the runner dies without reaping it.
10032
+ ...this.opts.inRunner?.onProcessGroup ? { onProcessGroup: this.opts.inRunner.onProcessGroup } : {}
9475
10033
  });
9476
10034
  const containment = this.containment;
9477
10035
  adapters.push(
@@ -9480,20 +10038,43 @@ var TurnExecution = class {
9480
10038
  onWarn,
9481
10039
  augmentOptions: (options) => ({
9482
10040
  ...options,
9483
- spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(spawnOptions)
10041
+ spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(
10042
+ sandbox ? { ...spawnOptions, ...sandbox.wrap(spawnOptions.command, spawnOptions.args) } : spawnOptions
10043
+ )
9484
10044
  })
9485
10045
  })
9486
10046
  );
9487
10047
  }
9488
10048
  if (this.opts.opencodeServerUrl) {
9489
- adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
10049
+ const serverUrl = this.opts.opencodeServerUrl;
10050
+ adapters.push(
10051
+ createOpencodeAdapter(
10052
+ // CT1679: the transport's per-server turn mutex is per process; in a
10053
+ // runner the turn also holds the device-wide lock every runner shares.
10054
+ this.opts.inRunner ? {
10055
+ transport: this.opts.inRunner.opencodeTransport(serverUrl),
10056
+ onWarn
10057
+ } : { serverUrl, onWarn }
10058
+ )
10059
+ );
9490
10060
  }
9491
10061
  if (this.opts.codexAvailable?.() ?? false) {
10062
+ const inRunner = this.opts.inRunner;
10063
+ const codexBinary = sandbox || inRunner ? (this.opts.resolveBundled ?? resolveBundledBinary)("codex").path : null;
10064
+ let codexPath = codexBinary && sandbox ? sandbox.executableFor(codexBinary) : codexBinary;
10065
+ if (codexPath && inRunner) {
10066
+ this.containment ??= createTurnContainment(this.turnId, {
10067
+ log: (msg, meta) => turnLog.debug(meta ?? {}, msg),
10068
+ ...this.opts.inRunner?.onProcessGroup ? { onProcessGroup: this.opts.inRunner.onProcessGroup } : {}
10069
+ });
10070
+ codexPath = inRunner.codexExecutable(codexPath, this.containment);
10071
+ }
9492
10072
  adapters.push(
9493
10073
  createCodexAdapter({
9494
10074
  enabled: true,
9495
10075
  onWarn,
9496
- writeInstructionsFile: writeCodexInstructionsFile
10076
+ writeInstructionsFile: writeCodexInstructionsFile,
10077
+ ...codexPath ? { transport: createSdkCodexTransport({ codexPathOverride: codexPath }) } : {}
9497
10078
  })
9498
10079
  );
9499
10080
  }
@@ -9712,7 +10293,10 @@ var TurnExecution = class {
9712
10293
  workspaceId,
9713
10294
  payload.conversationId,
9714
10295
  payload.agentId,
9715
- { agentSessionId: event.state }
10296
+ // CT1680: named for its turn, which a runner credential
10297
+ // requires once its start is admitted; the server then binds
10298
+ // the capture to that turn's attempt rather than the pair's.
10299
+ { agentSessionId: event.state, turnId }
9716
10300
  );
9717
10301
  } catch (err) {
9718
10302
  const status = err instanceof ApiError ? err.status : void 0;
@@ -10030,6 +10614,24 @@ var Dispatcher = class {
10030
10614
  notifyStart: (info) => this.notifyStart(info),
10031
10615
  notifyEnd: (info) => this.notifyEnd(info)
10032
10616
  };
10617
+ if (this.opts.runner) {
10618
+ return runTurnInRunner(
10619
+ this.opts,
10620
+ this.opts.runner,
10621
+ {
10622
+ registerAbort: supervisor.registerAbort,
10623
+ releaseAbort: supervisor.releaseAbort,
10624
+ sessionWriteNoticeClaimed: () => this.sessionWriteNotified.has(key),
10625
+ markSessionWriteNotice: () => {
10626
+ this.sessionWriteNotified.add(key);
10627
+ },
10628
+ notifyStart: supervisor.notifyStart,
10629
+ notifyEnd: supervisor.notifyEnd
10630
+ },
10631
+ payload,
10632
+ { turnId: opts.turnId ?? randomUUID4(), resumed: opts.resumed === true }
10633
+ );
10634
+ }
10033
10635
  return new TurnExecution(this.opts, supervisor, payload, opts).run();
10034
10636
  }
10035
10637
  // SJ383: cancel a specific (conversation, agent) run if one is in flight in
@@ -10048,6 +10650,176 @@ var Dispatcher = class {
10048
10650
  }
10049
10651
  };
10050
10652
 
10653
+ // src/providers/local-process.ts
10654
+ import { spawn as spawn6 } from "child_process";
10655
+ import { constants } from "os";
10656
+ var DEFAULT_KILL_GRACE_MS = 3e4;
10657
+ var DEFAULT_REAP_GRACE_MS = 3e3;
10658
+ function exitCode(code, signal) {
10659
+ if (code !== null) return code;
10660
+ const signum = signal ? constants.signals[signal] : void 0;
10661
+ return signum !== void 0 ? 128 + signum : 1;
10662
+ }
10663
+ function createLocalProcessProvider(opts = {}) {
10664
+ const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
10665
+ const spawnFn = opts.spawnFn ?? spawn6;
10666
+ const reapGraceMs = opts.reapGraceMs ?? DEFAULT_REAP_GRACE_MS;
10667
+ const killFn = opts.killFn ?? ((pid, signal) => process.kill(pid, signal));
10668
+ const groupAlive = (pgid) => {
10669
+ try {
10670
+ killFn(-pgid, 0);
10671
+ return true;
10672
+ } catch {
10673
+ return false;
10674
+ }
10675
+ };
10676
+ const signalGroup = (pgid, signal) => {
10677
+ try {
10678
+ killFn(-pgid, signal);
10679
+ } catch {
10680
+ }
10681
+ };
10682
+ return {
10683
+ name: "local-process",
10684
+ async acquire(attempt) {
10685
+ return { provider: "local-process", id: attempt.turnId };
10686
+ },
10687
+ exec(_handle, argv, env, { signal, stdin }) {
10688
+ const [command, ...args] = argv;
10689
+ if (!command) throw new Error("local-process: exec needs a command");
10690
+ const child = spawnFn(command, args, {
10691
+ env: { ...env },
10692
+ stdio: ["pipe", "pipe", "pipe"],
10693
+ detached: process.platform !== "win32"
10694
+ });
10695
+ child.stdin?.on("error", () => {
10696
+ });
10697
+ if (stdin) child.stdin?.write(stdin);
10698
+ const exit = new Promise((resolve2) => {
10699
+ let settled = false;
10700
+ const done = (code) => {
10701
+ if (settled) return;
10702
+ settled = true;
10703
+ signal.removeEventListener("abort", stop);
10704
+ if (forceTimer) clearTimeout(forceTimer);
10705
+ resolve2(code);
10706
+ };
10707
+ let forceTimer;
10708
+ const stop = () => {
10709
+ child.kill("SIGTERM");
10710
+ forceTimer = setTimeout(() => child.kill("SIGKILL"), killGraceMs);
10711
+ forceTimer.unref?.();
10712
+ };
10713
+ child.once("error", () => done(1));
10714
+ child.once("close", (code, sig) => done(exitCode(code, sig)));
10715
+ if (signal.aborted) stop();
10716
+ else signal.addEventListener("abort", stop, { once: true });
10717
+ });
10718
+ if (!child.stdout || !child.stderr || !child.stdin)
10719
+ throw new Error("local-process: runner stdio missing");
10720
+ const pid = child.pid;
10721
+ return {
10722
+ exit,
10723
+ ...pid !== void 0 ? { pid } : {},
10724
+ stdout: child.stdout,
10725
+ stderr: child.stderr,
10726
+ stdin: child.stdin,
10727
+ async reap(groups) {
10728
+ if (process.platform === "win32") return;
10729
+ const targets = [.../* @__PURE__ */ new Set([...pid !== void 0 ? [pid] : [], ...groups])].filter(
10730
+ groupAlive
10731
+ );
10732
+ if (!targets.length) return;
10733
+ for (const pgid of targets) signalGroup(pgid, "SIGTERM");
10734
+ await new Promise((resolve2) => setTimeout(resolve2, reapGraceMs));
10735
+ for (const pgid of targets.filter(groupAlive)) signalGroup(pgid, "SIGKILL");
10736
+ }
10737
+ };
10738
+ },
10739
+ async release() {
10740
+ }
10741
+ };
10742
+ }
10743
+
10744
+ // src/runner/device-locks.ts
10745
+ var DeviceLocks = class {
10746
+ holders = /* @__PURE__ */ new Map();
10747
+ queues = /* @__PURE__ */ new Map();
10748
+ // `owner` is unique across the device; `grant` is called once, when it holds.
10749
+ request(key, owner, grant) {
10750
+ if (!this.holders.has(key)) {
10751
+ this.holders.set(key, owner);
10752
+ grant();
10753
+ return;
10754
+ }
10755
+ const queue = this.queues.get(key) ?? [];
10756
+ queue.push({ owner, grant });
10757
+ this.queues.set(key, queue);
10758
+ }
10759
+ // Gives back what `owner` holds and withdraws what it is waiting for.
10760
+ release(owner) {
10761
+ for (const [key, queue] of this.queues) {
10762
+ const rest = queue.filter((w) => w.owner !== owner);
10763
+ if (rest.length) this.queues.set(key, rest);
10764
+ else this.queues.delete(key);
10765
+ }
10766
+ for (const [key, holder] of [...this.holders]) {
10767
+ if (holder !== owner) continue;
10768
+ this.holders.delete(key);
10769
+ this.handOn(key);
10770
+ }
10771
+ }
10772
+ // What owners starting with `prefix` hold, by key — one runner's, when it
10773
+ // exits, so the launcher can decide which it may give back at once.
10774
+ heldBy(prefix) {
10775
+ return [...this.holders].filter(([, owner]) => owner.startsWith(prefix)).map(([key, owner]) => ({ key, owner }));
10776
+ }
10777
+ // Withdraws every request still waiting from owners starting with `prefix`,
10778
+ // leaving what they hold.
10779
+ withdraw(prefix) {
10780
+ for (const [key, queue] of this.queues) {
10781
+ const rest = queue.filter((w) => !w.owner.startsWith(prefix));
10782
+ if (rest.length) this.queues.set(key, rest);
10783
+ else this.queues.delete(key);
10784
+ }
10785
+ }
10786
+ holder(key) {
10787
+ return this.holders.get(key);
10788
+ }
10789
+ handOn(key) {
10790
+ const queue = this.queues.get(key);
10791
+ const next = queue?.shift();
10792
+ if (!queue?.length) this.queues.delete(key);
10793
+ if (!next) return;
10794
+ this.holders.set(key, next.owner);
10795
+ next.grant();
10796
+ }
10797
+ };
10798
+
10799
+ // src/runner/harness-sandbox.ts
10800
+ import { chmodSync as chmodSync5, mkdirSync as mkdirSync9, mkdtempSync, rmSync as rmSync7, writeFileSync as writeFileSync6 } from "fs";
10801
+ import { homedir as homedir3, tmpdir as tmpdir2, userInfo as userInfo2 } from "os";
10802
+ import { join as join15 } from "path";
10803
+ import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
10804
+ function osSandboxUsable(log) {
10805
+ if (!SandboxManager.isSupportedPlatform()) {
10806
+ log.warn(
10807
+ { platform: process.platform },
10808
+ 'runners: sandbox "os" is not available on this platform; turns run unsandboxed'
10809
+ );
10810
+ return false;
10811
+ }
10812
+ const deps = SandboxManager.checkDependencies();
10813
+ if (deps.errors.length) {
10814
+ log.warn(
10815
+ { errors: deps.errors },
10816
+ 'runners: sandbox "os" is missing what it needs on this host; turns run unsandboxed'
10817
+ );
10818
+ return false;
10819
+ }
10820
+ return true;
10821
+ }
10822
+
10051
10823
  // src/opencode-models.ts
10052
10824
  var OPENCODE_RUNTIME = "opencode";
10053
10825
  function mapOpencodeProviders(json) {
@@ -10127,14 +10899,14 @@ var DispatchClaims = class {
10127
10899
  // src/outbox.ts
10128
10900
  import {
10129
10901
  existsSync as existsSync11,
10130
- mkdirSync as mkdirSync9,
10902
+ mkdirSync as mkdirSync10,
10131
10903
  readdirSync as readdirSync3,
10132
10904
  readFileSync as readFileSync8,
10133
10905
  renameSync as renameSync4,
10134
- rmSync as rmSync7,
10135
- writeFileSync as writeFileSync6
10906
+ rmSync as rmSync8,
10907
+ writeFileSync as writeFileSync7
10136
10908
  } from "fs";
10137
- import { join as join15 } from "path";
10909
+ import { join as join16 } from "path";
10138
10910
  var MAX_ENTRIES = 2e3;
10139
10911
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
10140
10912
  var Outbox = class {
@@ -10147,25 +10919,25 @@ var Outbox = class {
10147
10919
  // Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
10148
10920
  // cases route writes at the right tmpdir.
10149
10921
  dir() {
10150
- return join15(cabaneDir(), "outbox", encodeURIComponent(this.workspaceId));
10922
+ return join16(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
10151
10923
  }
10152
10924
  fileFor(turnId, seq) {
10153
- return join15(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
10925
+ return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
10154
10926
  }
10155
10927
  // Persist a commit for later draining. Atomic (temp file + rename) so a
10156
10928
  // concurrent `list()` never reads a half-written entry, then enforces the
10157
10929
  // per-workspace bounds.
10158
10930
  persist(entry) {
10159
10931
  const dir2 = this.dir();
10160
- mkdirSync9(dir2, { recursive: true });
10932
+ mkdirSync10(dir2, { recursive: true });
10161
10933
  const target = this.fileFor(entry.turnId, entry.seq);
10162
10934
  const tmp = `${target}.${process.pid}.tmp`;
10163
10935
  try {
10164
- writeFileSync6(tmp, JSON.stringify(entry) + "\n", "utf8");
10936
+ writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
10165
10937
  renameSync4(tmp, target);
10166
10938
  } catch (err) {
10167
10939
  try {
10168
- rmSync7(tmp, { force: true });
10940
+ rmSync8(tmp, { force: true });
10169
10941
  } catch {
10170
10942
  }
10171
10943
  this.log?.warn(
@@ -10192,7 +10964,7 @@ var Outbox = class {
10192
10964
  const entries = [];
10193
10965
  for (const name of names) {
10194
10966
  if (!name.endsWith(".json")) continue;
10195
- const full = join15(dir2, name);
10967
+ const full = join16(dir2, name);
10196
10968
  try {
10197
10969
  const parsed = JSON.parse(readFileSync8(full, "utf8"));
10198
10970
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
@@ -10242,7 +11014,7 @@ var Outbox = class {
10242
11014
  // Remove a delivered (or terminally-discarded) entry. No-op if already gone.
10243
11015
  remove(turnId, seq) {
10244
11016
  try {
10245
- rmSync7(this.fileFor(turnId, seq), { force: true });
11017
+ rmSync8(this.fileFor(turnId, seq), { force: true });
10246
11018
  } catch {
10247
11019
  }
10248
11020
  }
@@ -10261,7 +11033,7 @@ var Outbox = class {
10261
11033
  "A saved update is unreadable and couldn't be delivered. Check the conversation for missing replies."
10262
11034
  );
10263
11035
  try {
10264
- rmSync7(full, { force: true });
11036
+ rmSync8(full, { force: true });
10265
11037
  } catch {
10266
11038
  }
10267
11039
  }
@@ -10295,53 +11067,6 @@ var Outbox = class {
10295
11067
  }
10296
11068
  };
10297
11069
 
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
11070
  // src/sse.ts
10346
11071
  import { createParser } from "eventsource-parser";
10347
11072
  var MAX_BACKOFF_MS = 3e4;
@@ -10506,6 +11231,8 @@ var CompanionSupervisor = class {
10506
11231
  exitFn;
10507
11232
  reexecFn;
10508
11233
  dispatcherFactory;
11234
+ runnerCommand;
11235
+ providerForRunners;
10509
11236
  unrunnableRetryDelaysMs;
10510
11237
  deviceApi = null;
10511
11238
  deviceSub = null;
@@ -10550,6 +11277,8 @@ var CompanionSupervisor = class {
10550
11277
  this.exitFn = opts.exit ?? ((code) => process.exit(code));
10551
11278
  this.reexecFn = opts.reexec ?? defaultReexec;
10552
11279
  this.dispatcherFactory = opts.dispatcherFactory;
11280
+ if (opts.runnerCommand) this.runnerCommand = opts.runnerCommand;
11281
+ if (opts.runnerProvider) this.providerForRunners = opts.runnerProvider;
10553
11282
  this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
10554
11283
  this.latchBootBundledState();
10555
11284
  }
@@ -10995,6 +11724,21 @@ var CompanionSupervisor = class {
10995
11724
  );
10996
11725
  }
10997
11726
  }
11727
+ // CT1679: the locks every runner on this device shares, held here.
11728
+ runnerLocks = new DeviceLocks();
11729
+ // CT1679: `runners.sandbox`, checked against this host once. A host that
11730
+ // can't make the sandbox says so once and runs its turns unwrapped.
11731
+ sandboxUsable;
11732
+ runnerSandbox() {
11733
+ if (this.config.runners?.sandbox !== "os") return "none";
11734
+ this.sandboxUsable ??= osSandboxUsable(this.log);
11735
+ return this.sandboxUsable ? "os" : "none";
11736
+ }
11737
+ // CT1679: one provider for the device, built on first use.
11738
+ runnerProvider() {
11739
+ this.providerForRunners ??= createLocalProcessProvider();
11740
+ return this.providerForRunners;
11741
+ }
10998
11742
  buildDispatcher(ctx) {
10999
11743
  const local = localAgentConfig(this.config, {
11000
11744
  agentId: ctx.agentId,
@@ -11045,7 +11789,17 @@ var CompanionSupervisor = class {
11045
11789
  observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
11046
11790
  transcriptDir: transcriptsDir(),
11047
11791
  // CT566: the shared connector-health memory the turn records into on settle.
11048
- connectorHealth: this.connectorHealth
11792
+ connectorHealth: this.connectorHealth,
11793
+ ...runnersEnabled(this.config) ? {
11794
+ runner: {
11795
+ provider: this.runnerProvider(),
11796
+ command: this.runnerCommand ?? selfRunnerCommand(),
11797
+ reportRunnerGone: (report) => this.reportRunnerGone(report),
11798
+ connectorHealth: this.connectorHealth,
11799
+ locks: this.runnerLocks,
11800
+ sandbox: this.runnerSandbox()
11801
+ }
11802
+ } : {}
11049
11803
  });
11050
11804
  }
11051
11805
  async removeWorkspace(workspaceId) {
@@ -11230,6 +11984,33 @@ var CompanionSupervisor = class {
11230
11984
  );
11231
11985
  return false;
11232
11986
  }
11987
+ // CT1680: a runner is gone without having settled its turn. Same bounded
11988
+ // retry as the unrunnable report above, on the device credential, which is
11989
+ // the one the launcher still holds whatever became of the runner.
11990
+ async reportRunnerGone(report) {
11991
+ if (!this.deviceApi) return;
11992
+ let lastErr;
11993
+ for (const wait of this.unrunnableRetryDelaysMs) {
11994
+ if (this.stopped) return;
11995
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
11996
+ try {
11997
+ await this.deviceApi.reportDispatchDrop(report);
11998
+ return;
11999
+ } catch (err) {
12000
+ lastErr = err;
12001
+ }
12002
+ }
12003
+ this.log.error(
12004
+ {
12005
+ workspaceId: report.workspaceId,
12006
+ conversationId: report.conversationId,
12007
+ agentId: report.agentId,
12008
+ runnerId: report.runnerId,
12009
+ err: lastErr instanceof Error ? lastErr.message : String(lastErr)
12010
+ },
12011
+ "Couldn't tell Cabane a turn's runner stopped; its credential expires on its own."
12012
+ );
12013
+ }
11233
12014
  // Pull-on-miss for the assignment-change race: a dispatch arrived for an agent
11234
12015
  // we don't yet run. If it's addressed to THIS device, the assignment just
11235
12016
  // changed under us (the poll hasn't caught it) — force a fresh assignments pull
@@ -11336,7 +12117,7 @@ var CompanionSupervisor = class {
11336
12117
  );
11337
12118
  }
11338
12119
  const resumedTurnId = recoveredTurnId ?? (dispatchId ? turnIdForEvent(workspaceId, dispatchId) : null);
11339
- const turnId = resumedTurnId ?? randomUUID4();
12120
+ const turnId = resumedTurnId ?? randomUUID5();
11340
12121
  if (dispatchId) {
11341
12122
  const liveTurnIds = rememberTurnId(workspaceId, dispatchId, turnId);
11342
12123
  if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
@@ -11641,9 +12422,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
11641
12422
  }
11642
12423
  function defaultReexec() {
11643
12424
  clearRuntimeState();
11644
- void import("child_process").then(({ spawn: spawn6 }) => {
12425
+ void import("child_process").then(({ spawn: spawn7 }) => {
11645
12426
  try {
11646
- const child = spawn6(process.execPath, process.argv.slice(1), {
12427
+ const child = spawn7(process.execPath, process.argv.slice(1), {
11647
12428
  stdio: "inherit",
11648
12429
  detached: false
11649
12430
  });
@@ -11713,22 +12494,22 @@ function handleUncaught(log, err, origin) {
11713
12494
  }
11714
12495
 
11715
12496
  // 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";
12497
+ import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync9, writeFileSync as writeFileSync8 } from "fs";
12498
+ import { join as join17 } from "path";
11718
12499
  function crashMarkerPath() {
11719
- return join16(cabaneDir(), "last-error.json");
12500
+ return join17(cabaneDir(), "last-error.json");
11720
12501
  }
11721
12502
  function recordCrash(rec) {
11722
12503
  try {
11723
- mkdirSync10(cabaneDir(), { recursive: true });
11724
- writeFileSync7(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
12504
+ mkdirSync11(cabaneDir(), { recursive: true });
12505
+ writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
11725
12506
  } catch {
11726
12507
  }
11727
12508
  }
11728
12509
  function clearCrash() {
11729
12510
  try {
11730
12511
  const path = crashMarkerPath();
11731
- if (existsSync12(path)) rmSync8(path, { force: true });
12512
+ if (existsSync12(path)) rmSync9(path, { force: true });
11732
12513
  } catch {
11733
12514
  }
11734
12515
  }
@@ -11771,7 +12552,7 @@ async function createCompanionRuntime(opts = {}) {
11771
12552
  opencodeServerUrl: cfg.opencode?.serverUrl,
11772
12553
  codex: isCodexEnabled(cfg)
11773
12554
  });
11774
- const instanceId = randomUUID5();
12555
+ const instanceId = randomUUID6();
11775
12556
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
11776
12557
  const pre = readLiveRuntimeState();
11777
12558
  if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();