@cabane/companion 0.6.103 → 0.6.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +366 -25
  2. package/dist/runtime.js +354 -13
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1896,7 +1896,7 @@ async function pair(opts = {}) {
1896
1896
  }
1897
1897
 
1898
1898
  // src/cli.ts
1899
- import { readFileSync as readFileSync11 } from "fs";
1899
+ import { readFileSync as readFileSync12 } from "fs";
1900
1900
 
1901
1901
  // src/commands/logs.ts
1902
1902
  import { once } from "events";
@@ -4641,7 +4641,39 @@ function buildClaudeCodeOptions(req, augment) {
4641
4641
  ...cwd ? { cwd } : {},
4642
4642
  // Extra env (a companion prepare hook's tokens/ports; the in-app's debug flags)
4643
4643
  // merged OVER the inherited environment.
4644
- ...req.local.env ? { env: { ...process.env, ...req.local.env } } : {},
4644
+ //
4645
+ // CT1503: and the background-tasks kill switch LAST, so nothing in
4646
+ // `process.env` or `req.local.env` can turn it back on. Claude Code converts
4647
+ // a foreground command that hits its Bash ceiling (600 000 ms) into a
4648
+ // background task instead of erroring; the agent then reads a phantom task
4649
+ // id inside a live turn. A later turn that resumed such a session was then
4650
+ // seen getting every tool call aborted with kind `background` — which the CLI
4651
+ // renders as "The user doesn't want to take this action right now", a
4652
+ // cancellation wearing a refusal's words. The automatic conversion is
4653
+ // REPRODUCED and is what this flag fixes; that the stopped task is what
4654
+ // poisoned the resumed session is an INFERENCE from one incident, consistent
4655
+ // with the transcript but never reproduced, and this change does not rest on
4656
+ // it. `Dl()` reads this variable on the Bash tool's own call
4657
+ // path and sets `canAutoBackground: false` and `turnAbortBackgrounds: false`
4658
+ // together, so a timed-out command comes back as a readable error instead.
4659
+ //
4660
+ // The cost is acknowledged and deliberate: it ALSO removes deliberate
4661
+ // `run_in_background` (the parameter leaves the tool schema). That is the
4662
+ // only switch this harness offers, and there is no operator opt-out here on
4663
+ // purpose — an escape hatch would contradict the guarantee. It is a targeted
4664
+ // mitigation for the automatic conversion, NOT a cleanup mechanism: step 0
4665
+ // measured that this flag changes no process-survival outcome on any turn-end
4666
+ // path. Cleanup is its own change.
4667
+ //
4668
+ // Note `env` REPLACES the subprocess environment rather than merging (the
4669
+ // SDK's own doc on `Options.env`), which is why `process.env` is spread here
4670
+ // and why this key is now always present — previously it appeared only when
4671
+ // `req.local.env` was set, and an absent `env` inherits `process.env`.
4672
+ env: {
4673
+ ...process.env,
4674
+ ...req.local.env,
4675
+ CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
4676
+ },
4645
4677
  ...resume ? { resume } : {}
4646
4678
  };
4647
4679
  let options;
@@ -8730,6 +8762,261 @@ function pruneOld(dir2, retain) {
8730
8762
  }
8731
8763
  }
8732
8764
 
8765
+ // src/turn-containment.ts
8766
+ import { execFile, execFileSync, spawn as spawn4 } from "child_process";
8767
+ import { readFileSync as readFileSync8 } from "fs";
8768
+ import { promisify } from "util";
8769
+ var execFileAsync = promisify(execFile);
8770
+ var asContained = (child) => child;
8771
+ var CONTAINMENT_STOP_GRACE_SEC = 5;
8772
+ var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
8773
+ var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
8774
+ function turnScopeUnit(turnId, attempt) {
8775
+ const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
8776
+ return `cabane-turn-${safe3}-${attempt}`;
8777
+ }
8778
+ function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
8779
+ if (process.platform !== "linux") return false;
8780
+ try {
8781
+ execFileSync("systemd-run", ["--user", "--scope", "--quiet", "--collect", "--", "/bin/true"], {
8782
+ env,
8783
+ timeout: timeoutMs,
8784
+ stdio: "ignore"
8785
+ });
8786
+ return true;
8787
+ } catch {
8788
+ return false;
8789
+ }
8790
+ }
8791
+ function classifyStopError(message) {
8792
+ 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(
8793
+ message
8794
+ )) {
8795
+ return "failure";
8796
+ }
8797
+ if (/not loaded|could not be found|no such unit|unit .* not found/i.test(message))
8798
+ return "absent";
8799
+ return "failure";
8800
+ }
8801
+ function currentSystemdUnit(cgroupText) {
8802
+ for (const line of cgroupText.split("\n")) {
8803
+ const path = line.trim().split(":").pop();
8804
+ if (!path?.startsWith("/")) continue;
8805
+ const leaf = path.split("/").filter(Boolean).pop();
8806
+ if (leaf && (leaf.endsWith(".service") || leaf.endsWith(".scope"))) return leaf;
8807
+ }
8808
+ return null;
8809
+ }
8810
+ function readCurrentSystemdUnit() {
8811
+ try {
8812
+ return currentSystemdUnit(readFileSync8("/proc/self/cgroup", "utf8"));
8813
+ } catch {
8814
+ return null;
8815
+ }
8816
+ }
8817
+ function processGroupMembers(pgid) {
8818
+ try {
8819
+ const out = execFileSync("ps", ["-eo", "pid=,pgid="], { timeout: 5e3 }).toString();
8820
+ return {
8821
+ ok: true,
8822
+ 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]))
8823
+ };
8824
+ } catch (err) {
8825
+ return { ok: false, error: (err instanceof Error ? err.message : String(err)).slice(0, 150) };
8826
+ }
8827
+ }
8828
+ var sleep3 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
8829
+ var MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
8830
+ var managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
8831
+ function createTurnContainment(turnId, deps = {}) {
8832
+ const probe = deps.capabilityProbe ?? scopeCapabilityUsable;
8833
+ const ownUnit = (deps.ownSystemdUnit ?? readCurrentSystemdUnit)();
8834
+ const scopeUnits = [];
8835
+ const groups = [];
8836
+ let kind = null;
8837
+ let probedEnvKey = null;
8838
+ let attempt = 0;
8839
+ function spawnInScope(options) {
8840
+ const unit = turnScopeUnit(turnId, attempt++);
8841
+ scopeUnits.push({ unit, env: options.env });
8842
+ deps.log?.("containment: spawning the turn CLI into a systemd scope", {
8843
+ unit,
8844
+ partOf: ownUnit,
8845
+ command: options.command
8846
+ });
8847
+ return asContained(
8848
+ spawn4(
8849
+ "systemd-run",
8850
+ [
8851
+ "--user",
8852
+ "--scope",
8853
+ "--quiet",
8854
+ "--collect",
8855
+ `--unit=${unit}`,
8856
+ `--property=TimeoutStopSec=${CONTAINMENT_STOP_GRACE_SEC}s`,
8857
+ // Bind the scope's life to the companion's own unit, so stopping or
8858
+ // restarting the companion still takes its in-flight turns with it —
8859
+ // which is what happens today, and what the scope would otherwise undo.
8860
+ // Omitted outside a unit, where there is nothing to bind to.
8861
+ ...ownUnit ? [`--property=PartOf=${ownUnit}`] : [],
8862
+ "--",
8863
+ options.command,
8864
+ ...options.args
8865
+ ],
8866
+ {
8867
+ ...options.cwd ? { cwd: options.cwd } : {},
8868
+ env: options.env,
8869
+ stdio: ["pipe", "pipe", "pipe"]
8870
+ }
8871
+ )
8872
+ );
8873
+ }
8874
+ function spawnInGroup(options) {
8875
+ const child = spawn4(options.command, options.args, {
8876
+ ...options.cwd ? { cwd: options.cwd } : {},
8877
+ env: options.env,
8878
+ stdio: ["pipe", "pipe", "pipe"],
8879
+ detached: process.platform !== "win32"
8880
+ });
8881
+ if (child.pid !== void 0) groups.push({ pgid: child.pid, child });
8882
+ deps.log?.("containment: no host-owned containment here \u2014 best effort only", {
8883
+ platform: process.platform,
8884
+ pid: child.pid
8885
+ });
8886
+ return asContained(child);
8887
+ }
8888
+ async function reapScopes(result) {
8889
+ for (const { unit, env } of scopeUnits) {
8890
+ try {
8891
+ await execFileAsync("systemctl", ["--user", "stop", `${unit}.scope`], {
8892
+ env,
8893
+ timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
8894
+ });
8895
+ } catch (err) {
8896
+ const message = err instanceof Error ? err.message : String(err);
8897
+ if (classifyStopError(message) === "failure") {
8898
+ result.failures.push({ target: unit, error: message.slice(0, 200) });
8899
+ continue;
8900
+ }
8901
+ result.reaped.push(unit);
8902
+ continue;
8903
+ }
8904
+ try {
8905
+ const { stdout } = await execFileAsync(
8906
+ "systemctl",
8907
+ ["--user", "show", `${unit}.scope`, "--property=ActiveState", "--value"],
8908
+ { env, timeout: 1e4 }
8909
+ );
8910
+ const state = stdout.trim();
8911
+ if (state === "" || state === "inactive" || state === "failed") result.reaped.push(unit);
8912
+ else result.failures.push({ target: unit, error: `scope still ${state} after stop` });
8913
+ } catch (err) {
8914
+ result.failures.push({
8915
+ target: unit,
8916
+ error: `could not confirm the scope stopped: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`
8917
+ });
8918
+ }
8919
+ }
8920
+ }
8921
+ async function reapGroups(result) {
8922
+ for (const { pgid, child } of groups) {
8923
+ const target = String(pgid);
8924
+ if (process.platform === "win32") {
8925
+ try {
8926
+ if (child.exitCode === null) child.kill();
8927
+ result.unverified.push(target);
8928
+ } catch (err) {
8929
+ result.failures.push({
8930
+ target,
8931
+ error: (err instanceof Error ? err.message : String(err)).slice(0, 200)
8932
+ });
8933
+ }
8934
+ continue;
8935
+ }
8936
+ const first = processGroupMembers(pgid);
8937
+ if (!first.ok) {
8938
+ result.failures.push({
8939
+ target,
8940
+ error: `could not enumerate the process group: ${first.error}`
8941
+ });
8942
+ continue;
8943
+ }
8944
+ if (first.members.length === 0) {
8945
+ result.reaped.push(target);
8946
+ continue;
8947
+ }
8948
+ for (const pid of first.members) {
8949
+ try {
8950
+ process.kill(pid, "SIGTERM");
8951
+ } catch {
8952
+ }
8953
+ }
8954
+ await sleep3(FALLBACK_GRACE_MS);
8955
+ const afterTerm = processGroupMembers(pgid);
8956
+ if (!afterTerm.ok) {
8957
+ result.failures.push({
8958
+ target,
8959
+ error: `could not re-enumerate the process group after SIGTERM: ${afterTerm.error}`
8960
+ });
8961
+ continue;
8962
+ }
8963
+ for (const pid of afterTerm.members) {
8964
+ try {
8965
+ process.kill(pid, "SIGKILL");
8966
+ } catch {
8967
+ }
8968
+ }
8969
+ const afterKill = processGroupMembers(pgid);
8970
+ if (!afterKill.ok) {
8971
+ result.failures.push({
8972
+ target,
8973
+ error: `could not confirm the process group is empty after SIGKILL: ${afterKill.error}`
8974
+ });
8975
+ continue;
8976
+ }
8977
+ if (afterKill.members.length)
8978
+ result.failures.push({
8979
+ target,
8980
+ error: `${afterKill.members.length} process(es) still in the group after SIGKILL`
8981
+ });
8982
+ else result.reaped.push(target);
8983
+ }
8984
+ }
8985
+ return {
8986
+ get kind() {
8987
+ return kind;
8988
+ },
8989
+ spawn(options) {
8990
+ const envKey = managerEnvKey(options.env);
8991
+ if (kind === null || envKey !== probedEnvKey) {
8992
+ kind = probe(options.env) ? "systemd-scope" : "process-group";
8993
+ probedEnvKey = envKey;
8994
+ deps.log?.("containment: resolved strategy for this turn", {
8995
+ kind,
8996
+ platform: process.platform
8997
+ });
8998
+ }
8999
+ return kind === "systemd-scope" ? spawnInScope(options) : spawnInGroup(options);
9000
+ },
9001
+ async reap() {
9002
+ const result = {
9003
+ kind: kind ?? "unused",
9004
+ reaped: [],
9005
+ failures: [],
9006
+ unverified: []
9007
+ };
9008
+ await reapScopes(result);
9009
+ await reapGroups(result);
9010
+ deps.log?.("containment: reap complete", {
9011
+ kind: result.kind,
9012
+ reaped: result.reaped,
9013
+ failures: result.failures
9014
+ });
9015
+ return result;
9016
+ }
9017
+ };
9018
+ }
9019
+
8733
9020
  // src/turn-committer.ts
8734
9021
  var TurnCommitter = class {
8735
9022
  constructor(deps) {
@@ -8949,7 +9236,10 @@ function missingSecretReason(missing) {
8949
9236
  var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
8950
9237
  var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
8951
9238
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
8952
- var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
9239
+ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 15 * 6e4;
9240
+ function resolveIdleTimeoutMs(override) {
9241
+ return override ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
9242
+ }
8953
9243
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
8954
9244
  var DEFAULT_LEASE_RENEWAL_MS = 3e4;
8955
9245
  function initialOutcome() {
@@ -9007,6 +9297,11 @@ var TurnExecution = class {
9007
9297
  turnLog;
9008
9298
  abortController = new AbortController();
9009
9299
  outcome = initialOutcome();
9300
+ // CT1503: this turn's shell containment, once the claude-code adapter is built.
9301
+ // Null before that and on a turn that never reached adapter construction — a
9302
+ // startup failure has nothing to reap, and reaping is a no-op rather than a
9303
+ // special case.
9304
+ containment = null;
9010
9305
  seqCounter = 0;
9011
9306
  // CT11: per-turn monotonic counter mirroring the in-process dispatcher's.
9012
9307
  //
@@ -9041,6 +9336,36 @@ var TurnExecution = class {
9041
9336
  };
9042
9337
  disarmWatchdogs = () => {
9043
9338
  };
9339
+ // CT1503: reap this turn's shell containment. Idempotent (the containment drops
9340
+ // what it has already stopped) and total — a reap that fails is reported and
9341
+ // swallowed, because a turn that produced a good answer must not be recorded as
9342
+ // failed on account of a leftover process, and the failure is visible in the log
9343
+ // either way.
9344
+ async reapContainment() {
9345
+ const containment = this.containment;
9346
+ if (!containment) return;
9347
+ this.containment = null;
9348
+ try {
9349
+ const result = await containment.reap();
9350
+ if (result.failures.length) {
9351
+ this.turnLog.warn(
9352
+ { kind: result.kind, failures: result.failures },
9353
+ "turn containment: some of this turn\u2019s shell work could not be reaped"
9354
+ );
9355
+ }
9356
+ if (result.unverified.length) {
9357
+ this.turnLog.debug(
9358
+ { kind: result.kind, unverified: result.unverified },
9359
+ "turn containment: cleanup requested but not confirmable on this platform"
9360
+ );
9361
+ }
9362
+ } catch (err) {
9363
+ this.turnLog.warn(
9364
+ { err: err instanceof Error ? err.message : String(err) },
9365
+ "turn containment: reap threw"
9366
+ );
9367
+ }
9368
+ }
9044
9369
  // Codo's stack review, blocking finding #2: set the moment `acquireLease`'s
9045
9370
  // PATCH returns — the lease is granted and this is the pair's running span,
9046
9371
  // so every exit after this point must settle THE SPAN, not just clear the
@@ -9431,7 +9756,20 @@ var TurnExecution = class {
9431
9756
  const onWarn = (msg, meta) => turnLog.debug(meta ?? {}, msg);
9432
9757
  const adapters = [];
9433
9758
  if (this.opts.claudeCodeAvailable?.() ?? true) {
9434
- adapters.push(createClaudeCodeAdapter({ queryFn: this.opts.queryFn, onWarn }));
9759
+ this.containment = createTurnContainment(this.turnId, {
9760
+ log: (msg, meta) => turnLog.debug(meta ?? {}, msg)
9761
+ });
9762
+ const containment = this.containment;
9763
+ adapters.push(
9764
+ createClaudeCodeAdapter({
9765
+ queryFn: this.opts.queryFn,
9766
+ onWarn,
9767
+ augmentOptions: (options) => ({
9768
+ ...options,
9769
+ spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(spawnOptions)
9770
+ })
9771
+ })
9772
+ );
9435
9773
  }
9436
9774
  if (this.opts.opencodeServerUrl) {
9437
9775
  adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
@@ -9573,7 +9911,7 @@ var TurnExecution = class {
9573
9911
  );
9574
9912
  }
9575
9913
  };
9576
- const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
9914
+ const idleTimeoutMs = resolveIdleTimeoutMs(this.opts.idleTimeoutMs);
9577
9915
  const fireTimeout = (reason) => {
9578
9916
  if (abortController.signal.aborted) return;
9579
9917
  o.timeoutReason = reason;
@@ -9726,6 +10064,7 @@ var TurnExecution = class {
9726
10064
  );
9727
10065
  } finally {
9728
10066
  this.disarmWatchdogs();
10067
+ await this.reapContainment();
9729
10068
  if (o.leaseLost) {
9730
10069
  o.resultReason = "lease_lost";
9731
10070
  o.okResult = false;
@@ -10031,7 +10370,7 @@ import {
10031
10370
  existsSync as existsSync12,
10032
10371
  mkdirSync as mkdirSync10,
10033
10372
  readdirSync as readdirSync3,
10034
- readFileSync as readFileSync8,
10373
+ readFileSync as readFileSync9,
10035
10374
  renameSync as renameSync4,
10036
10375
  rmSync as rmSync7,
10037
10376
  writeFileSync as writeFileSync7
@@ -10096,7 +10435,7 @@ var Outbox = class {
10096
10435
  if (!name.endsWith(".json")) continue;
10097
10436
  const full = join16(dir2, name);
10098
10437
  try {
10099
- const parsed = JSON.parse(readFileSync8(full, "utf8"));
10438
+ const parsed = JSON.parse(readFileSync9(full, "utf8"));
10100
10439
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
10101
10440
  entries.push(parsed);
10102
10441
  } else {
@@ -10277,7 +10616,7 @@ var SseSubscriber = class {
10277
10616
  backoff = 500;
10278
10617
  if (!this.aborted) {
10279
10618
  this.opts.log.warn("Lost the connection to Cabane; reconnecting");
10280
- await sleep3(backoff);
10619
+ await sleep4(backoff);
10281
10620
  }
10282
10621
  } catch (err) {
10283
10622
  if (this.aborted) return;
@@ -10297,7 +10636,7 @@ var SseSubscriber = class {
10297
10636
  },
10298
10637
  "Lost the connection to Cabane; reconnecting"
10299
10638
  );
10300
- await sleep3(backoff);
10639
+ await sleep4(backoff);
10301
10640
  backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
10302
10641
  }
10303
10642
  }
@@ -10349,7 +10688,7 @@ var SseSubscriber = class {
10349
10688
  }
10350
10689
  }
10351
10690
  };
10352
- function sleep3(ms) {
10691
+ function sleep4(ms) {
10353
10692
  return new Promise((resolve2) => setTimeout(resolve2, ms));
10354
10693
  }
10355
10694
 
@@ -10917,7 +11256,9 @@ var CompanionSupervisor = class {
10917
11256
  codexAvailable: () => this.codexOffered(),
10918
11257
  // CT556: per-turn timeout watchdog windows, from the companion's own env
10919
11258
  // (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
10920
- // dispatcher's baked-in defaults (10 min idle / 6h total).
11259
+ // dispatcher's baked-in defaults (CT1503: 15 min idle / 6h total). An
11260
+ // override set here still wins, and is deliberately not validated against
11261
+ // the tool ceilings it may reorder — see DEFAULT_AGENT_IDLE_TIMEOUT_MS.
10921
11262
  ...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
10922
11263
  ...positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") !== void 0 ? { totalTimeoutMs: positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") } : {},
10923
11264
  observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
@@ -11529,9 +11870,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
11529
11870
  }
11530
11871
  function defaultReexec() {
11531
11872
  clearRuntimeState();
11532
- void import("child_process").then(({ spawn: spawn5 }) => {
11873
+ void import("child_process").then(({ spawn: spawn6 }) => {
11533
11874
  try {
11534
- const child = spawn5(process.execPath, process.argv.slice(1), {
11875
+ const child = spawn6(process.execPath, process.argv.slice(1), {
11535
11876
  stdio: "inherit",
11536
11877
  detached: false
11537
11878
  });
@@ -11601,7 +11942,7 @@ function handleUncaught(log, err, origin) {
11601
11942
  }
11602
11943
 
11603
11944
  // src/crash-marker.ts
11604
- import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
11945
+ import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync10, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
11605
11946
  import { join as join17 } from "path";
11606
11947
  function crashMarkerPath() {
11607
11948
  return join17(cabaneDir(), "last-error.json");
@@ -11786,7 +12127,7 @@ async function closeSurfaces(control, dashboard) {
11786
12127
  }
11787
12128
 
11788
12129
  // src/commands/daemon.ts
11789
- import { spawn as spawn4 } from "child_process";
12130
+ import { spawn as spawn5 } from "child_process";
11790
12131
  import { closeSync as closeSync3, mkdirSync as mkdirSync12, openSync as openSync3 } from "fs";
11791
12132
 
11792
12133
  // src/cli-entry.ts
@@ -11812,7 +12153,7 @@ async function startDaemon(opts = {}, deps = {}) {
11812
12153
  const readState = deps.readState ?? readLiveRuntimeState;
11813
12154
  const verify = deps.verify ?? ((s) => verifyRuntime(s));
11814
12155
  const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
11815
- const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
12156
+ const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
11816
12157
  const now = deps.now ?? (() => Date.now());
11817
12158
  const quiet = opts.report === "failures";
11818
12159
  const { cfg, claudeOnPath: claudeOnPath2 } = await requireStartConfig({
@@ -11867,7 +12208,7 @@ Connect a harness to the running companion: cabane-companion connect claude-code
11867
12208
  const deadline = now() + STARTUP_TIMEOUT_MS;
11868
12209
  let seen = readState();
11869
12210
  while (!ready(seen) && now() < deadline) {
11870
- await sleep4(POLL_INTERVAL_MS);
12211
+ await sleep5(POLL_INTERVAL_MS);
11871
12212
  seen = readState();
11872
12213
  }
11873
12214
  return ready(seen) ? seen : null;
@@ -11898,7 +12239,7 @@ function defaultSpawnDetached(args) {
11898
12239
  mkdirSync12(cabaneDir(), { recursive: true });
11899
12240
  const logFd = openSync3(companionLogPath(), "a");
11900
12241
  try {
11901
- return spawn4(process.execPath, [cliPath, ...args], {
12242
+ return spawn5(process.execPath, [cliPath, ...args], {
11902
12243
  detached: true,
11903
12244
  stdio: ["ignore", logFd, logFd],
11904
12245
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -12273,7 +12614,7 @@ async function stop(deps = {}) {
12273
12614
  const readState = deps.readState ?? readLiveRuntimeState;
12274
12615
  const verify = deps.verify ?? ((s) => verifyRuntime(s));
12275
12616
  const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
12276
- const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
12617
+ const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
12277
12618
  const now = deps.now ?? (() => Date.now());
12278
12619
  const state = readState();
12279
12620
  if (!state) {
@@ -12301,7 +12642,7 @@ async function stop(deps = {}) {
12301
12642
  process.stdout.write("companion: stopped.\n");
12302
12643
  return;
12303
12644
  }
12304
- await sleep4(POLL_INTERVAL_MS2);
12645
+ await sleep5(POLL_INTERVAL_MS2);
12305
12646
  }
12306
12647
  process.stdout.write(
12307
12648
  `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
@@ -12324,7 +12665,7 @@ function isAlive(kill, pid) {
12324
12665
  }
12325
12666
 
12326
12667
  // src/commands/transcript.ts
12327
- import { existsSync as existsSync15, readFileSync as readFileSync10, readdirSync as readdirSync5 } from "fs";
12668
+ import { existsSync as existsSync15, readFileSync as readFileSync11, readdirSync as readdirSync5 } from "fs";
12328
12669
  import { isAbsolute, join as join18 } from "path";
12329
12670
  async function transcript(opts = {}) {
12330
12671
  const dir2 = transcriptsDir();
@@ -12425,7 +12766,7 @@ function isComplete(content) {
12425
12766
  async function followTranscripts(dir2) {
12426
12767
  const follower = new TranscriptFollower({
12427
12768
  listFiles: () => listFiles(dir2),
12428
- read: (f) => readFileSync10(join18(dir2, f), "utf8"),
12769
+ read: (f) => readFileSync11(join18(dir2, f), "utf8"),
12429
12770
  write: (s) => process.stdout.write(s),
12430
12771
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
12431
12772
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -12486,7 +12827,7 @@ function peek(path) {
12486
12827
  let meta;
12487
12828
  let outcome;
12488
12829
  try {
12489
- for (const line of readFileSync10(path, "utf8").split("\n")) {
12830
+ for (const line of readFileSync11(path, "utf8").split("\n")) {
12490
12831
  if (!line.trim()) continue;
12491
12832
  const o = safeParse(line);
12492
12833
  const t = str2(rec(o)?.type);
@@ -12519,7 +12860,7 @@ function resolveTarget(dir2, target) {
12519
12860
  function renderFile(path) {
12520
12861
  let content;
12521
12862
  try {
12522
- content = readFileSync10(path, "utf8");
12863
+ content = readFileSync11(path, "utf8");
12523
12864
  } catch (err) {
12524
12865
  throw new CompanionError(
12525
12866
  `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -12679,7 +13020,7 @@ program.command("pair").description(
12679
13020
  });
12680
13021
  });
12681
13022
  program.command("write-paired-config", { hidden: true }).description("persist an already-completed device enrollment payload from stdin.").action(() => {
12682
- writeCompletedPairing(readFileSync11(0, "utf8"));
13023
+ writeCompletedPairing(readFileSync12(0, "utf8"));
12683
13024
  });
12684
13025
  program.command("start").description(
12685
13026
  "pair this device if needed, connect a coding agent on it, and run in the background."
package/dist/runtime.js CHANGED
@@ -4054,7 +4054,39 @@ function buildClaudeCodeOptions(req, augment) {
4054
4054
  ...cwd ? { cwd } : {},
4055
4055
  // Extra env (a companion prepare hook's tokens/ports; the in-app's debug flags)
4056
4056
  // merged OVER the inherited environment.
4057
- ...req.local.env ? { env: { ...process.env, ...req.local.env } } : {},
4057
+ //
4058
+ // CT1503: and the background-tasks kill switch LAST, so nothing in
4059
+ // `process.env` or `req.local.env` can turn it back on. Claude Code converts
4060
+ // a foreground command that hits its Bash ceiling (600 000 ms) into a
4061
+ // background task instead of erroring; the agent then reads a phantom task
4062
+ // id inside a live turn. A later turn that resumed such a session was then
4063
+ // seen getting every tool call aborted with kind `background` — which the CLI
4064
+ // renders as "The user doesn't want to take this action right now", a
4065
+ // cancellation wearing a refusal's words. The automatic conversion is
4066
+ // REPRODUCED and is what this flag fixes; that the stopped task is what
4067
+ // poisoned the resumed session is an INFERENCE from one incident, consistent
4068
+ // with the transcript but never reproduced, and this change does not rest on
4069
+ // it. `Dl()` reads this variable on the Bash tool's own call
4070
+ // path and sets `canAutoBackground: false` and `turnAbortBackgrounds: false`
4071
+ // together, so a timed-out command comes back as a readable error instead.
4072
+ //
4073
+ // The cost is acknowledged and deliberate: it ALSO removes deliberate
4074
+ // `run_in_background` (the parameter leaves the tool schema). That is the
4075
+ // only switch this harness offers, and there is no operator opt-out here on
4076
+ // purpose — an escape hatch would contradict the guarantee. It is a targeted
4077
+ // mitigation for the automatic conversion, NOT a cleanup mechanism: step 0
4078
+ // measured that this flag changes no process-survival outcome on any turn-end
4079
+ // path. Cleanup is its own change.
4080
+ //
4081
+ // Note `env` REPLACES the subprocess environment rather than merging (the
4082
+ // SDK's own doc on `Options.env`), which is why `process.env` is spread here
4083
+ // and why this key is now always present — previously it appeared only when
4084
+ // `req.local.env` was set, and an absent `env` inherits `process.env`.
4085
+ env: {
4086
+ ...process.env,
4087
+ ...req.local.env,
4088
+ CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
4089
+ },
4058
4090
  ...resume ? { resume } : {}
4059
4091
  };
4060
4092
  let options;
@@ -8153,6 +8185,261 @@ function pruneOld(dir2, retain) {
8153
8185
  }
8154
8186
  }
8155
8187
 
8188
+ // src/turn-containment.ts
8189
+ import { execFile, execFileSync, spawn as spawn4 } from "child_process";
8190
+ import { readFileSync as readFileSync8 } from "fs";
8191
+ import { promisify } from "util";
8192
+ var execFileAsync = promisify(execFile);
8193
+ var asContained = (child) => child;
8194
+ var CONTAINMENT_STOP_GRACE_SEC = 5;
8195
+ var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
8196
+ var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
8197
+ function turnScopeUnit(turnId, attempt) {
8198
+ const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
8199
+ return `cabane-turn-${safe3}-${attempt}`;
8200
+ }
8201
+ function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
8202
+ if (process.platform !== "linux") return false;
8203
+ try {
8204
+ execFileSync("systemd-run", ["--user", "--scope", "--quiet", "--collect", "--", "/bin/true"], {
8205
+ env,
8206
+ timeout: timeoutMs,
8207
+ stdio: "ignore"
8208
+ });
8209
+ return true;
8210
+ } catch {
8211
+ return false;
8212
+ }
8213
+ }
8214
+ function classifyStopError(message) {
8215
+ 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(
8216
+ message
8217
+ )) {
8218
+ return "failure";
8219
+ }
8220
+ if (/not loaded|could not be found|no such unit|unit .* not found/i.test(message))
8221
+ return "absent";
8222
+ return "failure";
8223
+ }
8224
+ function currentSystemdUnit(cgroupText) {
8225
+ for (const line of cgroupText.split("\n")) {
8226
+ const path = line.trim().split(":").pop();
8227
+ if (!path?.startsWith("/")) continue;
8228
+ const leaf = path.split("/").filter(Boolean).pop();
8229
+ if (leaf && (leaf.endsWith(".service") || leaf.endsWith(".scope"))) return leaf;
8230
+ }
8231
+ return null;
8232
+ }
8233
+ function readCurrentSystemdUnit() {
8234
+ try {
8235
+ return currentSystemdUnit(readFileSync8("/proc/self/cgroup", "utf8"));
8236
+ } catch {
8237
+ return null;
8238
+ }
8239
+ }
8240
+ function processGroupMembers(pgid) {
8241
+ try {
8242
+ const out = execFileSync("ps", ["-eo", "pid=,pgid="], { timeout: 5e3 }).toString();
8243
+ return {
8244
+ ok: true,
8245
+ 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]))
8246
+ };
8247
+ } catch (err) {
8248
+ return { ok: false, error: (err instanceof Error ? err.message : String(err)).slice(0, 150) };
8249
+ }
8250
+ }
8251
+ var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
8252
+ var MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
8253
+ var managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
8254
+ function createTurnContainment(turnId, deps = {}) {
8255
+ const probe = deps.capabilityProbe ?? scopeCapabilityUsable;
8256
+ const ownUnit = (deps.ownSystemdUnit ?? readCurrentSystemdUnit)();
8257
+ const scopeUnits = [];
8258
+ const groups = [];
8259
+ let kind = null;
8260
+ let probedEnvKey = null;
8261
+ let attempt = 0;
8262
+ function spawnInScope(options) {
8263
+ const unit = turnScopeUnit(turnId, attempt++);
8264
+ scopeUnits.push({ unit, env: options.env });
8265
+ deps.log?.("containment: spawning the turn CLI into a systemd scope", {
8266
+ unit,
8267
+ partOf: ownUnit,
8268
+ command: options.command
8269
+ });
8270
+ return asContained(
8271
+ spawn4(
8272
+ "systemd-run",
8273
+ [
8274
+ "--user",
8275
+ "--scope",
8276
+ "--quiet",
8277
+ "--collect",
8278
+ `--unit=${unit}`,
8279
+ `--property=TimeoutStopSec=${CONTAINMENT_STOP_GRACE_SEC}s`,
8280
+ // Bind the scope's life to the companion's own unit, so stopping or
8281
+ // restarting the companion still takes its in-flight turns with it —
8282
+ // which is what happens today, and what the scope would otherwise undo.
8283
+ // Omitted outside a unit, where there is nothing to bind to.
8284
+ ...ownUnit ? [`--property=PartOf=${ownUnit}`] : [],
8285
+ "--",
8286
+ options.command,
8287
+ ...options.args
8288
+ ],
8289
+ {
8290
+ ...options.cwd ? { cwd: options.cwd } : {},
8291
+ env: options.env,
8292
+ stdio: ["pipe", "pipe", "pipe"]
8293
+ }
8294
+ )
8295
+ );
8296
+ }
8297
+ function spawnInGroup(options) {
8298
+ const child = spawn4(options.command, options.args, {
8299
+ ...options.cwd ? { cwd: options.cwd } : {},
8300
+ env: options.env,
8301
+ stdio: ["pipe", "pipe", "pipe"],
8302
+ detached: process.platform !== "win32"
8303
+ });
8304
+ if (child.pid !== void 0) groups.push({ pgid: child.pid, child });
8305
+ deps.log?.("containment: no host-owned containment here \u2014 best effort only", {
8306
+ platform: process.platform,
8307
+ pid: child.pid
8308
+ });
8309
+ return asContained(child);
8310
+ }
8311
+ async function reapScopes(result) {
8312
+ for (const { unit, env } of scopeUnits) {
8313
+ try {
8314
+ await execFileAsync("systemctl", ["--user", "stop", `${unit}.scope`], {
8315
+ env,
8316
+ timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
8317
+ });
8318
+ } catch (err) {
8319
+ const message = err instanceof Error ? err.message : String(err);
8320
+ if (classifyStopError(message) === "failure") {
8321
+ result.failures.push({ target: unit, error: message.slice(0, 200) });
8322
+ continue;
8323
+ }
8324
+ result.reaped.push(unit);
8325
+ continue;
8326
+ }
8327
+ try {
8328
+ const { stdout } = await execFileAsync(
8329
+ "systemctl",
8330
+ ["--user", "show", `${unit}.scope`, "--property=ActiveState", "--value"],
8331
+ { env, timeout: 1e4 }
8332
+ );
8333
+ const state = stdout.trim();
8334
+ if (state === "" || state === "inactive" || state === "failed") result.reaped.push(unit);
8335
+ else result.failures.push({ target: unit, error: `scope still ${state} after stop` });
8336
+ } catch (err) {
8337
+ result.failures.push({
8338
+ target: unit,
8339
+ error: `could not confirm the scope stopped: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`
8340
+ });
8341
+ }
8342
+ }
8343
+ }
8344
+ async function reapGroups(result) {
8345
+ for (const { pgid, child } of groups) {
8346
+ const target = String(pgid);
8347
+ if (process.platform === "win32") {
8348
+ try {
8349
+ if (child.exitCode === null) child.kill();
8350
+ result.unverified.push(target);
8351
+ } catch (err) {
8352
+ result.failures.push({
8353
+ target,
8354
+ error: (err instanceof Error ? err.message : String(err)).slice(0, 200)
8355
+ });
8356
+ }
8357
+ continue;
8358
+ }
8359
+ const first = processGroupMembers(pgid);
8360
+ if (!first.ok) {
8361
+ result.failures.push({
8362
+ target,
8363
+ error: `could not enumerate the process group: ${first.error}`
8364
+ });
8365
+ continue;
8366
+ }
8367
+ if (first.members.length === 0) {
8368
+ result.reaped.push(target);
8369
+ continue;
8370
+ }
8371
+ for (const pid of first.members) {
8372
+ try {
8373
+ process.kill(pid, "SIGTERM");
8374
+ } catch {
8375
+ }
8376
+ }
8377
+ await sleep2(FALLBACK_GRACE_MS);
8378
+ const afterTerm = processGroupMembers(pgid);
8379
+ if (!afterTerm.ok) {
8380
+ result.failures.push({
8381
+ target,
8382
+ error: `could not re-enumerate the process group after SIGTERM: ${afterTerm.error}`
8383
+ });
8384
+ continue;
8385
+ }
8386
+ for (const pid of afterTerm.members) {
8387
+ try {
8388
+ process.kill(pid, "SIGKILL");
8389
+ } catch {
8390
+ }
8391
+ }
8392
+ const afterKill = processGroupMembers(pgid);
8393
+ if (!afterKill.ok) {
8394
+ result.failures.push({
8395
+ target,
8396
+ error: `could not confirm the process group is empty after SIGKILL: ${afterKill.error}`
8397
+ });
8398
+ continue;
8399
+ }
8400
+ if (afterKill.members.length)
8401
+ result.failures.push({
8402
+ target,
8403
+ error: `${afterKill.members.length} process(es) still in the group after SIGKILL`
8404
+ });
8405
+ else result.reaped.push(target);
8406
+ }
8407
+ }
8408
+ return {
8409
+ get kind() {
8410
+ return kind;
8411
+ },
8412
+ spawn(options) {
8413
+ const envKey = managerEnvKey(options.env);
8414
+ if (kind === null || envKey !== probedEnvKey) {
8415
+ kind = probe(options.env) ? "systemd-scope" : "process-group";
8416
+ probedEnvKey = envKey;
8417
+ deps.log?.("containment: resolved strategy for this turn", {
8418
+ kind,
8419
+ platform: process.platform
8420
+ });
8421
+ }
8422
+ return kind === "systemd-scope" ? spawnInScope(options) : spawnInGroup(options);
8423
+ },
8424
+ async reap() {
8425
+ const result = {
8426
+ kind: kind ?? "unused",
8427
+ reaped: [],
8428
+ failures: [],
8429
+ unverified: []
8430
+ };
8431
+ await reapScopes(result);
8432
+ await reapGroups(result);
8433
+ deps.log?.("containment: reap complete", {
8434
+ kind: result.kind,
8435
+ reaped: result.reaped,
8436
+ failures: result.failures
8437
+ });
8438
+ return result;
8439
+ }
8440
+ };
8441
+ }
8442
+
8156
8443
  // src/turn-committer.ts
8157
8444
  var TurnCommitter = class {
8158
8445
  constructor(deps) {
@@ -8372,7 +8659,10 @@ function missingSecretReason(missing) {
8372
8659
  var RUNTIME_UNAVAILABLE_PREFIX = "**This agent's runtime isn't available on this companion.** The model this agent uses needs a runtime this device isn't running, so I can't run this turn here. Details:";
8373
8660
  var UNEXPECTED_ROLE_REASON = `refused the wake trigger (role "system" isn't dispatchable on this companion) \u2014 the companion is likely running outdated code; refresh it, then re-address the agent`;
8374
8661
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
8375
- var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
8662
+ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 15 * 6e4;
8663
+ function resolveIdleTimeoutMs(override) {
8664
+ return override ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
8665
+ }
8376
8666
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
8377
8667
  var DEFAULT_LEASE_RENEWAL_MS = 3e4;
8378
8668
  function initialOutcome() {
@@ -8430,6 +8720,11 @@ var TurnExecution = class {
8430
8720
  turnLog;
8431
8721
  abortController = new AbortController();
8432
8722
  outcome = initialOutcome();
8723
+ // CT1503: this turn's shell containment, once the claude-code adapter is built.
8724
+ // Null before that and on a turn that never reached adapter construction — a
8725
+ // startup failure has nothing to reap, and reaping is a no-op rather than a
8726
+ // special case.
8727
+ containment = null;
8433
8728
  seqCounter = 0;
8434
8729
  // CT11: per-turn monotonic counter mirroring the in-process dispatcher's.
8435
8730
  //
@@ -8464,6 +8759,36 @@ var TurnExecution = class {
8464
8759
  };
8465
8760
  disarmWatchdogs = () => {
8466
8761
  };
8762
+ // CT1503: reap this turn's shell containment. Idempotent (the containment drops
8763
+ // what it has already stopped) and total — a reap that fails is reported and
8764
+ // swallowed, because a turn that produced a good answer must not be recorded as
8765
+ // failed on account of a leftover process, and the failure is visible in the log
8766
+ // either way.
8767
+ async reapContainment() {
8768
+ const containment = this.containment;
8769
+ if (!containment) return;
8770
+ this.containment = null;
8771
+ try {
8772
+ const result = await containment.reap();
8773
+ if (result.failures.length) {
8774
+ this.turnLog.warn(
8775
+ { kind: result.kind, failures: result.failures },
8776
+ "turn containment: some of this turn\u2019s shell work could not be reaped"
8777
+ );
8778
+ }
8779
+ if (result.unverified.length) {
8780
+ this.turnLog.debug(
8781
+ { kind: result.kind, unverified: result.unverified },
8782
+ "turn containment: cleanup requested but not confirmable on this platform"
8783
+ );
8784
+ }
8785
+ } catch (err) {
8786
+ this.turnLog.warn(
8787
+ { err: err instanceof Error ? err.message : String(err) },
8788
+ "turn containment: reap threw"
8789
+ );
8790
+ }
8791
+ }
8467
8792
  // Codo's stack review, blocking finding #2: set the moment `acquireLease`'s
8468
8793
  // PATCH returns — the lease is granted and this is the pair's running span,
8469
8794
  // so every exit after this point must settle THE SPAN, not just clear the
@@ -8854,7 +9179,20 @@ var TurnExecution = class {
8854
9179
  const onWarn = (msg, meta) => turnLog.debug(meta ?? {}, msg);
8855
9180
  const adapters = [];
8856
9181
  if (this.opts.claudeCodeAvailable?.() ?? true) {
8857
- adapters.push(createClaudeCodeAdapter({ queryFn: this.opts.queryFn, onWarn }));
9182
+ this.containment = createTurnContainment(this.turnId, {
9183
+ log: (msg, meta) => turnLog.debug(meta ?? {}, msg)
9184
+ });
9185
+ const containment = this.containment;
9186
+ adapters.push(
9187
+ createClaudeCodeAdapter({
9188
+ queryFn: this.opts.queryFn,
9189
+ onWarn,
9190
+ augmentOptions: (options) => ({
9191
+ ...options,
9192
+ spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(spawnOptions)
9193
+ })
9194
+ })
9195
+ );
8858
9196
  }
8859
9197
  if (this.opts.opencodeServerUrl) {
8860
9198
  adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
@@ -8996,7 +9334,7 @@ var TurnExecution = class {
8996
9334
  );
8997
9335
  }
8998
9336
  };
8999
- const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
9337
+ const idleTimeoutMs = resolveIdleTimeoutMs(this.opts.idleTimeoutMs);
9000
9338
  const fireTimeout = (reason) => {
9001
9339
  if (abortController.signal.aborted) return;
9002
9340
  o.timeoutReason = reason;
@@ -9149,6 +9487,7 @@ var TurnExecution = class {
9149
9487
  );
9150
9488
  } finally {
9151
9489
  this.disarmWatchdogs();
9490
+ await this.reapContainment();
9152
9491
  if (o.leaseLost) {
9153
9492
  o.resultReason = "lease_lost";
9154
9493
  o.okResult = false;
@@ -9454,7 +9793,7 @@ import {
9454
9793
  existsSync as existsSync12,
9455
9794
  mkdirSync as mkdirSync10,
9456
9795
  readdirSync as readdirSync3,
9457
- readFileSync as readFileSync8,
9796
+ readFileSync as readFileSync9,
9458
9797
  renameSync as renameSync4,
9459
9798
  rmSync as rmSync7,
9460
9799
  writeFileSync as writeFileSync7
@@ -9519,7 +9858,7 @@ var Outbox = class {
9519
9858
  if (!name.endsWith(".json")) continue;
9520
9859
  const full = join16(dir2, name);
9521
9860
  try {
9522
- const parsed = JSON.parse(readFileSync8(full, "utf8"));
9861
+ const parsed = JSON.parse(readFileSync9(full, "utf8"));
9523
9862
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
9524
9863
  entries.push(parsed);
9525
9864
  } else {
@@ -9700,7 +10039,7 @@ var SseSubscriber = class {
9700
10039
  backoff = 500;
9701
10040
  if (!this.aborted) {
9702
10041
  this.opts.log.warn("Lost the connection to Cabane; reconnecting");
9703
- await sleep2(backoff);
10042
+ await sleep3(backoff);
9704
10043
  }
9705
10044
  } catch (err) {
9706
10045
  if (this.aborted) return;
@@ -9720,7 +10059,7 @@ var SseSubscriber = class {
9720
10059
  },
9721
10060
  "Lost the connection to Cabane; reconnecting"
9722
10061
  );
9723
- await sleep2(backoff);
10062
+ await sleep3(backoff);
9724
10063
  backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
9725
10064
  }
9726
10065
  }
@@ -9772,7 +10111,7 @@ var SseSubscriber = class {
9772
10111
  }
9773
10112
  }
9774
10113
  };
9775
- function sleep2(ms) {
10114
+ function sleep3(ms) {
9776
10115
  return new Promise((resolve2) => setTimeout(resolve2, ms));
9777
10116
  }
9778
10117
 
@@ -10340,7 +10679,9 @@ var CompanionSupervisor = class {
10340
10679
  codexAvailable: () => this.codexOffered(),
10341
10680
  // CT556: per-turn timeout watchdog windows, from the companion's own env
10342
10681
  // (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
10343
- // dispatcher's baked-in defaults (10 min idle / 6h total).
10682
+ // dispatcher's baked-in defaults (CT1503: 15 min idle / 6h total). An
10683
+ // override set here still wins, and is deliberately not validated against
10684
+ // the tool ceilings it may reorder — see DEFAULT_AGENT_IDLE_TIMEOUT_MS.
10344
10685
  ...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
10345
10686
  ...positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") !== void 0 ? { totalTimeoutMs: positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") } : {},
10346
10687
  observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
@@ -10952,9 +11293,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
10952
11293
  }
10953
11294
  function defaultReexec() {
10954
11295
  clearRuntimeState();
10955
- void import("child_process").then(({ spawn: spawn4 }) => {
11296
+ void import("child_process").then(({ spawn: spawn5 }) => {
10956
11297
  try {
10957
- const child = spawn4(process.execPath, process.argv.slice(1), {
11298
+ const child = spawn5(process.execPath, process.argv.slice(1), {
10958
11299
  stdio: "inherit",
10959
11300
  detached: false
10960
11301
  });
@@ -11024,7 +11365,7 @@ function handleUncaught(log, err, origin) {
11024
11365
  }
11025
11366
 
11026
11367
  // src/crash-marker.ts
11027
- import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
11368
+ import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync10, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
11028
11369
  import { join as join17 } from "path";
11029
11370
  function crashMarkerPath() {
11030
11371
  return join17(cabaneDir(), "last-error.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabane/companion",
3
- "version": "0.6.103",
3
+ "version": "0.6.104",
4
4
  "type": "module",
5
5
  "description": "The Cabane Companion (headless): connect a coding agent on your machine to your Cabane workspace as a responder — drive work against your own codebase, files, and MCP servers without putting any of it in Cabane.",
6
6
  "license": "UNLICENSED",