@cabane/companion 0.6.103 → 0.6.105

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 +383 -27
  2. package/dist/runtime.js +371 -15
  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";
@@ -2858,7 +2858,11 @@ function turnFailureCopy(reason, runtime) {
2858
2858
  var LEASE_REFUSALS = /* @__PURE__ */ new Set([
2859
2859
  "dispatch_not_admitted",
2860
2860
  "turn_already_ended",
2861
- "turn_belongs_elsewhere"
2861
+ "turn_belongs_elsewhere",
2862
+ // Rooms: the context this start was composed from named an attempt the
2863
+ // lease no longer picks (midnight rolled it, a degrade retired it). Nothing
2864
+ // was granted; the turn recomposes its context and starts again, once.
2865
+ "dispatch_context_stale"
2862
2866
  ]);
2863
2867
  function apiErrorCode(err) {
2864
2868
  if (!(err instanceof ApiError)) return null;
@@ -4641,7 +4645,39 @@ function buildClaudeCodeOptions(req, augment) {
4641
4645
  ...cwd ? { cwd } : {},
4642
4646
  // Extra env (a companion prepare hook's tokens/ports; the in-app's debug flags)
4643
4647
  // merged OVER the inherited environment.
4644
- ...req.local.env ? { env: { ...process.env, ...req.local.env } } : {},
4648
+ //
4649
+ // CT1503: and the background-tasks kill switch LAST, so nothing in
4650
+ // `process.env` or `req.local.env` can turn it back on. Claude Code converts
4651
+ // a foreground command that hits its Bash ceiling (600 000 ms) into a
4652
+ // background task instead of erroring; the agent then reads a phantom task
4653
+ // id inside a live turn. A later turn that resumed such a session was then
4654
+ // seen getting every tool call aborted with kind `background` — which the CLI
4655
+ // renders as "The user doesn't want to take this action right now", a
4656
+ // cancellation wearing a refusal's words. The automatic conversion is
4657
+ // REPRODUCED and is what this flag fixes; that the stopped task is what
4658
+ // poisoned the resumed session is an INFERENCE from one incident, consistent
4659
+ // with the transcript but never reproduced, and this change does not rest on
4660
+ // it. `Dl()` reads this variable on the Bash tool's own call
4661
+ // path and sets `canAutoBackground: false` and `turnAbortBackgrounds: false`
4662
+ // together, so a timed-out command comes back as a readable error instead.
4663
+ //
4664
+ // The cost is acknowledged and deliberate: it ALSO removes deliberate
4665
+ // `run_in_background` (the parameter leaves the tool schema). That is the
4666
+ // only switch this harness offers, and there is no operator opt-out here on
4667
+ // purpose — an escape hatch would contradict the guarantee. It is a targeted
4668
+ // mitigation for the automatic conversion, NOT a cleanup mechanism: step 0
4669
+ // measured that this flag changes no process-survival outcome on any turn-end
4670
+ // path. Cleanup is its own change.
4671
+ //
4672
+ // Note `env` REPLACES the subprocess environment rather than merging (the
4673
+ // SDK's own doc on `Options.env`), which is why `process.env` is spread here
4674
+ // and why this key is now always present — previously it appeared only when
4675
+ // `req.local.env` was set, and an absent `env` inherits `process.env`.
4676
+ env: {
4677
+ ...process.env,
4678
+ ...req.local.env,
4679
+ CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
4680
+ },
4645
4681
  ...resume ? { resume } : {}
4646
4682
  };
4647
4683
  let options;
@@ -8730,6 +8766,261 @@ function pruneOld(dir2, retain) {
8730
8766
  }
8731
8767
  }
8732
8768
 
8769
+ // src/turn-containment.ts
8770
+ import { execFile, execFileSync, spawn as spawn4 } from "child_process";
8771
+ import { readFileSync as readFileSync8 } from "fs";
8772
+ import { promisify } from "util";
8773
+ var execFileAsync = promisify(execFile);
8774
+ var asContained = (child) => child;
8775
+ var CONTAINMENT_STOP_GRACE_SEC = 5;
8776
+ var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
8777
+ var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
8778
+ function turnScopeUnit(turnId, attempt) {
8779
+ const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
8780
+ return `cabane-turn-${safe3}-${attempt}`;
8781
+ }
8782
+ function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
8783
+ if (process.platform !== "linux") return false;
8784
+ try {
8785
+ execFileSync("systemd-run", ["--user", "--scope", "--quiet", "--collect", "--", "/bin/true"], {
8786
+ env,
8787
+ timeout: timeoutMs,
8788
+ stdio: "ignore"
8789
+ });
8790
+ return true;
8791
+ } catch {
8792
+ return false;
8793
+ }
8794
+ }
8795
+ function classifyStopError(message) {
8796
+ 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(
8797
+ message
8798
+ )) {
8799
+ return "failure";
8800
+ }
8801
+ if (/not loaded|could not be found|no such unit|unit .* not found/i.test(message))
8802
+ return "absent";
8803
+ return "failure";
8804
+ }
8805
+ function currentSystemdUnit(cgroupText) {
8806
+ for (const line of cgroupText.split("\n")) {
8807
+ const path = line.trim().split(":").pop();
8808
+ if (!path?.startsWith("/")) continue;
8809
+ const leaf = path.split("/").filter(Boolean).pop();
8810
+ if (leaf && (leaf.endsWith(".service") || leaf.endsWith(".scope"))) return leaf;
8811
+ }
8812
+ return null;
8813
+ }
8814
+ function readCurrentSystemdUnit() {
8815
+ try {
8816
+ return currentSystemdUnit(readFileSync8("/proc/self/cgroup", "utf8"));
8817
+ } catch {
8818
+ return null;
8819
+ }
8820
+ }
8821
+ function processGroupMembers(pgid) {
8822
+ try {
8823
+ const out = execFileSync("ps", ["-eo", "pid=,pgid="], { timeout: 5e3 }).toString();
8824
+ return {
8825
+ ok: true,
8826
+ 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]))
8827
+ };
8828
+ } catch (err) {
8829
+ return { ok: false, error: (err instanceof Error ? err.message : String(err)).slice(0, 150) };
8830
+ }
8831
+ }
8832
+ var sleep3 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
8833
+ var MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
8834
+ var managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
8835
+ function createTurnContainment(turnId, deps = {}) {
8836
+ const probe = deps.capabilityProbe ?? scopeCapabilityUsable;
8837
+ const ownUnit = (deps.ownSystemdUnit ?? readCurrentSystemdUnit)();
8838
+ const scopeUnits = [];
8839
+ const groups = [];
8840
+ let kind = null;
8841
+ let probedEnvKey = null;
8842
+ let attempt = 0;
8843
+ function spawnInScope(options) {
8844
+ const unit = turnScopeUnit(turnId, attempt++);
8845
+ scopeUnits.push({ unit, env: options.env });
8846
+ deps.log?.("containment: spawning the turn CLI into a systemd scope", {
8847
+ unit,
8848
+ partOf: ownUnit,
8849
+ command: options.command
8850
+ });
8851
+ return asContained(
8852
+ spawn4(
8853
+ "systemd-run",
8854
+ [
8855
+ "--user",
8856
+ "--scope",
8857
+ "--quiet",
8858
+ "--collect",
8859
+ `--unit=${unit}`,
8860
+ `--property=TimeoutStopSec=${CONTAINMENT_STOP_GRACE_SEC}s`,
8861
+ // Bind the scope's life to the companion's own unit, so stopping or
8862
+ // restarting the companion still takes its in-flight turns with it —
8863
+ // which is what happens today, and what the scope would otherwise undo.
8864
+ // Omitted outside a unit, where there is nothing to bind to.
8865
+ ...ownUnit ? [`--property=PartOf=${ownUnit}`] : [],
8866
+ "--",
8867
+ options.command,
8868
+ ...options.args
8869
+ ],
8870
+ {
8871
+ ...options.cwd ? { cwd: options.cwd } : {},
8872
+ env: options.env,
8873
+ stdio: ["pipe", "pipe", "pipe"]
8874
+ }
8875
+ )
8876
+ );
8877
+ }
8878
+ function spawnInGroup(options) {
8879
+ const child = spawn4(options.command, options.args, {
8880
+ ...options.cwd ? { cwd: options.cwd } : {},
8881
+ env: options.env,
8882
+ stdio: ["pipe", "pipe", "pipe"],
8883
+ detached: process.platform !== "win32"
8884
+ });
8885
+ if (child.pid !== void 0) groups.push({ pgid: child.pid, child });
8886
+ deps.log?.("containment: no host-owned containment here \u2014 best effort only", {
8887
+ platform: process.platform,
8888
+ pid: child.pid
8889
+ });
8890
+ return asContained(child);
8891
+ }
8892
+ async function reapScopes(result) {
8893
+ for (const { unit, env } of scopeUnits) {
8894
+ try {
8895
+ await execFileAsync("systemctl", ["--user", "stop", `${unit}.scope`], {
8896
+ env,
8897
+ timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
8898
+ });
8899
+ } catch (err) {
8900
+ const message = err instanceof Error ? err.message : String(err);
8901
+ if (classifyStopError(message) === "failure") {
8902
+ result.failures.push({ target: unit, error: message.slice(0, 200) });
8903
+ continue;
8904
+ }
8905
+ result.reaped.push(unit);
8906
+ continue;
8907
+ }
8908
+ try {
8909
+ const { stdout } = await execFileAsync(
8910
+ "systemctl",
8911
+ ["--user", "show", `${unit}.scope`, "--property=ActiveState", "--value"],
8912
+ { env, timeout: 1e4 }
8913
+ );
8914
+ const state = stdout.trim();
8915
+ if (state === "" || state === "inactive" || state === "failed") result.reaped.push(unit);
8916
+ else result.failures.push({ target: unit, error: `scope still ${state} after stop` });
8917
+ } catch (err) {
8918
+ result.failures.push({
8919
+ target: unit,
8920
+ error: `could not confirm the scope stopped: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`
8921
+ });
8922
+ }
8923
+ }
8924
+ }
8925
+ async function reapGroups(result) {
8926
+ for (const { pgid, child } of groups) {
8927
+ const target = String(pgid);
8928
+ if (process.platform === "win32") {
8929
+ try {
8930
+ if (child.exitCode === null) child.kill();
8931
+ result.unverified.push(target);
8932
+ } catch (err) {
8933
+ result.failures.push({
8934
+ target,
8935
+ error: (err instanceof Error ? err.message : String(err)).slice(0, 200)
8936
+ });
8937
+ }
8938
+ continue;
8939
+ }
8940
+ const first = processGroupMembers(pgid);
8941
+ if (!first.ok) {
8942
+ result.failures.push({
8943
+ target,
8944
+ error: `could not enumerate the process group: ${first.error}`
8945
+ });
8946
+ continue;
8947
+ }
8948
+ if (first.members.length === 0) {
8949
+ result.reaped.push(target);
8950
+ continue;
8951
+ }
8952
+ for (const pid of first.members) {
8953
+ try {
8954
+ process.kill(pid, "SIGTERM");
8955
+ } catch {
8956
+ }
8957
+ }
8958
+ await sleep3(FALLBACK_GRACE_MS);
8959
+ const afterTerm = processGroupMembers(pgid);
8960
+ if (!afterTerm.ok) {
8961
+ result.failures.push({
8962
+ target,
8963
+ error: `could not re-enumerate the process group after SIGTERM: ${afterTerm.error}`
8964
+ });
8965
+ continue;
8966
+ }
8967
+ for (const pid of afterTerm.members) {
8968
+ try {
8969
+ process.kill(pid, "SIGKILL");
8970
+ } catch {
8971
+ }
8972
+ }
8973
+ const afterKill = processGroupMembers(pgid);
8974
+ if (!afterKill.ok) {
8975
+ result.failures.push({
8976
+ target,
8977
+ error: `could not confirm the process group is empty after SIGKILL: ${afterKill.error}`
8978
+ });
8979
+ continue;
8980
+ }
8981
+ if (afterKill.members.length)
8982
+ result.failures.push({
8983
+ target,
8984
+ error: `${afterKill.members.length} process(es) still in the group after SIGKILL`
8985
+ });
8986
+ else result.reaped.push(target);
8987
+ }
8988
+ }
8989
+ return {
8990
+ get kind() {
8991
+ return kind;
8992
+ },
8993
+ spawn(options) {
8994
+ const envKey = managerEnvKey(options.env);
8995
+ if (kind === null || envKey !== probedEnvKey) {
8996
+ kind = probe(options.env) ? "systemd-scope" : "process-group";
8997
+ probedEnvKey = envKey;
8998
+ deps.log?.("containment: resolved strategy for this turn", {
8999
+ kind,
9000
+ platform: process.platform
9001
+ });
9002
+ }
9003
+ return kind === "systemd-scope" ? spawnInScope(options) : spawnInGroup(options);
9004
+ },
9005
+ async reap() {
9006
+ const result = {
9007
+ kind: kind ?? "unused",
9008
+ reaped: [],
9009
+ failures: [],
9010
+ unverified: []
9011
+ };
9012
+ await reapScopes(result);
9013
+ await reapGroups(result);
9014
+ deps.log?.("containment: reap complete", {
9015
+ kind: result.kind,
9016
+ reaped: result.reaped,
9017
+ failures: result.failures
9018
+ });
9019
+ return result;
9020
+ }
9021
+ };
9022
+ }
9023
+
8733
9024
  // src/turn-committer.ts
8734
9025
  var TurnCommitter = class {
8735
9026
  constructor(deps) {
@@ -8949,7 +9240,10 @@ function missingSecretReason(missing) {
8949
9240
  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
9241
  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
9242
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
8952
- var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
9243
+ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 15 * 6e4;
9244
+ function resolveIdleTimeoutMs(override) {
9245
+ return override ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
9246
+ }
8953
9247
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
8954
9248
  var DEFAULT_LEASE_RENEWAL_MS = 3e4;
8955
9249
  function initialOutcome() {
@@ -9007,6 +9301,11 @@ var TurnExecution = class {
9007
9301
  turnLog;
9008
9302
  abortController = new AbortController();
9009
9303
  outcome = initialOutcome();
9304
+ // CT1503: this turn's shell containment, once the claude-code adapter is built.
9305
+ // Null before that and on a turn that never reached adapter construction — a
9306
+ // startup failure has nothing to reap, and reaping is a no-op rather than a
9307
+ // special case.
9308
+ containment = null;
9010
9309
  seqCounter = 0;
9011
9310
  // CT11: per-turn monotonic counter mirroring the in-process dispatcher's.
9012
9311
  //
@@ -9041,11 +9340,43 @@ var TurnExecution = class {
9041
9340
  };
9042
9341
  disarmWatchdogs = () => {
9043
9342
  };
9343
+ // CT1503: reap this turn's shell containment. Idempotent (the containment drops
9344
+ // what it has already stopped) and total — a reap that fails is reported and
9345
+ // swallowed, because a turn that produced a good answer must not be recorded as
9346
+ // failed on account of a leftover process, and the failure is visible in the log
9347
+ // either way.
9348
+ async reapContainment() {
9349
+ const containment = this.containment;
9350
+ if (!containment) return;
9351
+ this.containment = null;
9352
+ try {
9353
+ const result = await containment.reap();
9354
+ if (result.failures.length) {
9355
+ this.turnLog.warn(
9356
+ { kind: result.kind, failures: result.failures },
9357
+ "turn containment: some of this turn\u2019s shell work could not be reaped"
9358
+ );
9359
+ }
9360
+ if (result.unverified.length) {
9361
+ this.turnLog.debug(
9362
+ { kind: result.kind, unverified: result.unverified },
9363
+ "turn containment: cleanup requested but not confirmable on this platform"
9364
+ );
9365
+ }
9366
+ } catch (err) {
9367
+ this.turnLog.warn(
9368
+ { err: err instanceof Error ? err.message : String(err) },
9369
+ "turn containment: reap threw"
9370
+ );
9371
+ }
9372
+ }
9044
9373
  // Codo's stack review, blocking finding #2: set the moment `acquireLease`'s
9045
9374
  // PATCH returns — the lease is granted and this is the pair's running span,
9046
9375
  // so every exit after this point must settle THE SPAN, not just clear the
9047
9376
  // participant flag.
9048
9377
  admitted = false;
9378
+ // Rooms: one recompose per turn when the lease refuses a stale context.
9379
+ recomposedForLease = false;
9049
9380
  concluded(reason, errorReason) {
9050
9381
  return new TurnConcluded(reason, errorReason);
9051
9382
  }
@@ -9364,9 +9695,18 @@ var TurnExecution = class {
9364
9695
  // and settle moves the cursor to it. Omitted when the server sent
9365
9696
  // none (an older API), so the cursor stays where it was.
9366
9697
  ...this.turnContext.readThrough !== void 0 ? { readThrough: this.turnContext.readThrough } : {},
9367
- ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {}
9698
+ ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {},
9699
+ // Rooms: the attempt this request's session belongs to (null = fresh).
9700
+ ...this.turnContext.attemptId !== void 0 ? { attemptId: this.turnContext.attemptId } : {}
9368
9701
  });
9369
9702
  } catch (err) {
9703
+ if (leaseRefusal(err) === "dispatch_context_stale" && !this.recomposedForLease) {
9704
+ this.recomposedForLease = true;
9705
+ turnLog.info({ turnId }, "dispatcher: lease found the context stale; recomposing");
9706
+ await this.fetchContext();
9707
+ this.buildRequest();
9708
+ return this.acquireLease();
9709
+ }
9370
9710
  const refusal = leaseRefusal(err);
9371
9711
  if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
9372
9712
  turnLog.debug(
@@ -9431,7 +9771,20 @@ var TurnExecution = class {
9431
9771
  const onWarn = (msg, meta) => turnLog.debug(meta ?? {}, msg);
9432
9772
  const adapters = [];
9433
9773
  if (this.opts.claudeCodeAvailable?.() ?? true) {
9434
- adapters.push(createClaudeCodeAdapter({ queryFn: this.opts.queryFn, onWarn }));
9774
+ this.containment = createTurnContainment(this.turnId, {
9775
+ log: (msg, meta) => turnLog.debug(meta ?? {}, msg)
9776
+ });
9777
+ const containment = this.containment;
9778
+ adapters.push(
9779
+ createClaudeCodeAdapter({
9780
+ queryFn: this.opts.queryFn,
9781
+ onWarn,
9782
+ augmentOptions: (options) => ({
9783
+ ...options,
9784
+ spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(spawnOptions)
9785
+ })
9786
+ })
9787
+ );
9435
9788
  }
9436
9789
  if (this.opts.opencodeServerUrl) {
9437
9790
  adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
@@ -9573,7 +9926,7 @@ var TurnExecution = class {
9573
9926
  );
9574
9927
  }
9575
9928
  };
9576
- const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
9929
+ const idleTimeoutMs = resolveIdleTimeoutMs(this.opts.idleTimeoutMs);
9577
9930
  const fireTimeout = (reason) => {
9578
9931
  if (abortController.signal.aborted) return;
9579
9932
  o.timeoutReason = reason;
@@ -9726,6 +10079,7 @@ var TurnExecution = class {
9726
10079
  );
9727
10080
  } finally {
9728
10081
  this.disarmWatchdogs();
10082
+ await this.reapContainment();
9729
10083
  if (o.leaseLost) {
9730
10084
  o.resultReason = "lease_lost";
9731
10085
  o.okResult = false;
@@ -10031,7 +10385,7 @@ import {
10031
10385
  existsSync as existsSync12,
10032
10386
  mkdirSync as mkdirSync10,
10033
10387
  readdirSync as readdirSync3,
10034
- readFileSync as readFileSync8,
10388
+ readFileSync as readFileSync9,
10035
10389
  renameSync as renameSync4,
10036
10390
  rmSync as rmSync7,
10037
10391
  writeFileSync as writeFileSync7
@@ -10096,7 +10450,7 @@ var Outbox = class {
10096
10450
  if (!name.endsWith(".json")) continue;
10097
10451
  const full = join16(dir2, name);
10098
10452
  try {
10099
- const parsed = JSON.parse(readFileSync8(full, "utf8"));
10453
+ const parsed = JSON.parse(readFileSync9(full, "utf8"));
10100
10454
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
10101
10455
  entries.push(parsed);
10102
10456
  } else {
@@ -10277,7 +10631,7 @@ var SseSubscriber = class {
10277
10631
  backoff = 500;
10278
10632
  if (!this.aborted) {
10279
10633
  this.opts.log.warn("Lost the connection to Cabane; reconnecting");
10280
- await sleep3(backoff);
10634
+ await sleep4(backoff);
10281
10635
  }
10282
10636
  } catch (err) {
10283
10637
  if (this.aborted) return;
@@ -10297,7 +10651,7 @@ var SseSubscriber = class {
10297
10651
  },
10298
10652
  "Lost the connection to Cabane; reconnecting"
10299
10653
  );
10300
- await sleep3(backoff);
10654
+ await sleep4(backoff);
10301
10655
  backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
10302
10656
  }
10303
10657
  }
@@ -10349,7 +10703,7 @@ var SseSubscriber = class {
10349
10703
  }
10350
10704
  }
10351
10705
  };
10352
- function sleep3(ms) {
10706
+ function sleep4(ms) {
10353
10707
  return new Promise((resolve2) => setTimeout(resolve2, ms));
10354
10708
  }
10355
10709
 
@@ -10917,7 +11271,9 @@ var CompanionSupervisor = class {
10917
11271
  codexAvailable: () => this.codexOffered(),
10918
11272
  // CT556: per-turn timeout watchdog windows, from the companion's own env
10919
11273
  // (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
10920
- // dispatcher's baked-in defaults (10 min idle / 6h total).
11274
+ // dispatcher's baked-in defaults (CT1503: 15 min idle / 6h total). An
11275
+ // override set here still wins, and is deliberately not validated against
11276
+ // the tool ceilings it may reorder — see DEFAULT_AGENT_IDLE_TIMEOUT_MS.
10921
11277
  ...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
10922
11278
  ...positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") !== void 0 ? { totalTimeoutMs: positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") } : {},
10923
11279
  observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
@@ -11529,9 +11885,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
11529
11885
  }
11530
11886
  function defaultReexec() {
11531
11887
  clearRuntimeState();
11532
- void import("child_process").then(({ spawn: spawn5 }) => {
11888
+ void import("child_process").then(({ spawn: spawn6 }) => {
11533
11889
  try {
11534
- const child = spawn5(process.execPath, process.argv.slice(1), {
11890
+ const child = spawn6(process.execPath, process.argv.slice(1), {
11535
11891
  stdio: "inherit",
11536
11892
  detached: false
11537
11893
  });
@@ -11601,7 +11957,7 @@ function handleUncaught(log, err, origin) {
11601
11957
  }
11602
11958
 
11603
11959
  // src/crash-marker.ts
11604
- import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
11960
+ import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync10, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
11605
11961
  import { join as join17 } from "path";
11606
11962
  function crashMarkerPath() {
11607
11963
  return join17(cabaneDir(), "last-error.json");
@@ -11786,7 +12142,7 @@ async function closeSurfaces(control, dashboard) {
11786
12142
  }
11787
12143
 
11788
12144
  // src/commands/daemon.ts
11789
- import { spawn as spawn4 } from "child_process";
12145
+ import { spawn as spawn5 } from "child_process";
11790
12146
  import { closeSync as closeSync3, mkdirSync as mkdirSync12, openSync as openSync3 } from "fs";
11791
12147
 
11792
12148
  // src/cli-entry.ts
@@ -11812,7 +12168,7 @@ async function startDaemon(opts = {}, deps = {}) {
11812
12168
  const readState = deps.readState ?? readLiveRuntimeState;
11813
12169
  const verify = deps.verify ?? ((s) => verifyRuntime(s));
11814
12170
  const spawnDetached = deps.spawnDetached ?? defaultSpawnDetached;
11815
- const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
12171
+ const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
11816
12172
  const now = deps.now ?? (() => Date.now());
11817
12173
  const quiet = opts.report === "failures";
11818
12174
  const { cfg, claudeOnPath: claudeOnPath2 } = await requireStartConfig({
@@ -11867,7 +12223,7 @@ Connect a harness to the running companion: cabane-companion connect claude-code
11867
12223
  const deadline = now() + STARTUP_TIMEOUT_MS;
11868
12224
  let seen = readState();
11869
12225
  while (!ready(seen) && now() < deadline) {
11870
- await sleep4(POLL_INTERVAL_MS);
12226
+ await sleep5(POLL_INTERVAL_MS);
11871
12227
  seen = readState();
11872
12228
  }
11873
12229
  return ready(seen) ? seen : null;
@@ -11898,7 +12254,7 @@ function defaultSpawnDetached(args) {
11898
12254
  mkdirSync12(cabaneDir(), { recursive: true });
11899
12255
  const logFd = openSync3(companionLogPath(), "a");
11900
12256
  try {
11901
- return spawn4(process.execPath, [cliPath, ...args], {
12257
+ return spawn5(process.execPath, [cliPath, ...args], {
11902
12258
  detached: true,
11903
12259
  stdio: ["ignore", logFd, logFd],
11904
12260
  env: { ...process.env, CABANE_COMPANION_DAEMON: "1" }
@@ -12273,7 +12629,7 @@ async function stop(deps = {}) {
12273
12629
  const readState = deps.readState ?? readLiveRuntimeState;
12274
12630
  const verify = deps.verify ?? ((s) => verifyRuntime(s));
12275
12631
  const kill = deps.kill ?? ((pid2, signal) => process.kill(pid2, signal));
12276
- const sleep4 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
12632
+ const sleep5 = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
12277
12633
  const now = deps.now ?? (() => Date.now());
12278
12634
  const state = readState();
12279
12635
  if (!state) {
@@ -12301,7 +12657,7 @@ async function stop(deps = {}) {
12301
12657
  process.stdout.write("companion: stopped.\n");
12302
12658
  return;
12303
12659
  }
12304
- await sleep4(POLL_INTERVAL_MS2);
12660
+ await sleep5(POLL_INTERVAL_MS2);
12305
12661
  }
12306
12662
  process.stdout.write(
12307
12663
  `companion: didn't exit within ${TERM_GRACE_MS / 1e3}s, sending SIGKILL.
@@ -12324,7 +12680,7 @@ function isAlive(kill, pid) {
12324
12680
  }
12325
12681
 
12326
12682
  // src/commands/transcript.ts
12327
- import { existsSync as existsSync15, readFileSync as readFileSync10, readdirSync as readdirSync5 } from "fs";
12683
+ import { existsSync as existsSync15, readFileSync as readFileSync11, readdirSync as readdirSync5 } from "fs";
12328
12684
  import { isAbsolute, join as join18 } from "path";
12329
12685
  async function transcript(opts = {}) {
12330
12686
  const dir2 = transcriptsDir();
@@ -12425,7 +12781,7 @@ function isComplete(content) {
12425
12781
  async function followTranscripts(dir2) {
12426
12782
  const follower = new TranscriptFollower({
12427
12783
  listFiles: () => listFiles(dir2),
12428
- read: (f) => readFileSync10(join18(dir2, f), "utf8"),
12784
+ read: (f) => readFileSync11(join18(dir2, f), "utf8"),
12429
12785
  write: (s) => process.stdout.write(s),
12430
12786
  // CSI: cursor up `n` lines, then erase from cursor to end of screen.
12431
12787
  clearLines: (n) => process.stdout.write(`\x1B[${n}A\x1B[0J`),
@@ -12486,7 +12842,7 @@ function peek(path) {
12486
12842
  let meta;
12487
12843
  let outcome;
12488
12844
  try {
12489
- for (const line of readFileSync10(path, "utf8").split("\n")) {
12845
+ for (const line of readFileSync11(path, "utf8").split("\n")) {
12490
12846
  if (!line.trim()) continue;
12491
12847
  const o = safeParse(line);
12492
12848
  const t = str2(rec(o)?.type);
@@ -12519,7 +12875,7 @@ function resolveTarget(dir2, target) {
12519
12875
  function renderFile(path) {
12520
12876
  let content;
12521
12877
  try {
12522
- content = readFileSync10(path, "utf8");
12878
+ content = readFileSync11(path, "utf8");
12523
12879
  } catch (err) {
12524
12880
  throw new CompanionError(
12525
12881
  `couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -12679,7 +13035,7 @@ program.command("pair").description(
12679
13035
  });
12680
13036
  });
12681
13037
  program.command("write-paired-config", { hidden: true }).description("persist an already-completed device enrollment payload from stdin.").action(() => {
12682
- writeCompletedPairing(readFileSync11(0, "utf8"));
13038
+ writeCompletedPairing(readFileSync12(0, "utf8"));
12683
13039
  });
12684
13040
  program.command("start").description(
12685
13041
  "pair this device if needed, connect a coding agent on it, and run in the background."
package/dist/runtime.js CHANGED
@@ -2192,7 +2192,11 @@ function turnFailureCopy(reason, runtime) {
2192
2192
  var LEASE_REFUSALS = /* @__PURE__ */ new Set([
2193
2193
  "dispatch_not_admitted",
2194
2194
  "turn_already_ended",
2195
- "turn_belongs_elsewhere"
2195
+ "turn_belongs_elsewhere",
2196
+ // Rooms: the context this start was composed from named an attempt the
2197
+ // lease no longer picks (midnight rolled it, a degrade retired it). Nothing
2198
+ // was granted; the turn recomposes its context and starts again, once.
2199
+ "dispatch_context_stale"
2196
2200
  ]);
2197
2201
  function apiErrorCode(err) {
2198
2202
  if (!(err instanceof ApiError)) return null;
@@ -4054,7 +4058,39 @@ function buildClaudeCodeOptions(req, augment) {
4054
4058
  ...cwd ? { cwd } : {},
4055
4059
  // Extra env (a companion prepare hook's tokens/ports; the in-app's debug flags)
4056
4060
  // merged OVER the inherited environment.
4057
- ...req.local.env ? { env: { ...process.env, ...req.local.env } } : {},
4061
+ //
4062
+ // CT1503: and the background-tasks kill switch LAST, so nothing in
4063
+ // `process.env` or `req.local.env` can turn it back on. Claude Code converts
4064
+ // a foreground command that hits its Bash ceiling (600 000 ms) into a
4065
+ // background task instead of erroring; the agent then reads a phantom task
4066
+ // id inside a live turn. A later turn that resumed such a session was then
4067
+ // seen getting every tool call aborted with kind `background` — which the CLI
4068
+ // renders as "The user doesn't want to take this action right now", a
4069
+ // cancellation wearing a refusal's words. The automatic conversion is
4070
+ // REPRODUCED and is what this flag fixes; that the stopped task is what
4071
+ // poisoned the resumed session is an INFERENCE from one incident, consistent
4072
+ // with the transcript but never reproduced, and this change does not rest on
4073
+ // it. `Dl()` reads this variable on the Bash tool's own call
4074
+ // path and sets `canAutoBackground: false` and `turnAbortBackgrounds: false`
4075
+ // together, so a timed-out command comes back as a readable error instead.
4076
+ //
4077
+ // The cost is acknowledged and deliberate: it ALSO removes deliberate
4078
+ // `run_in_background` (the parameter leaves the tool schema). That is the
4079
+ // only switch this harness offers, and there is no operator opt-out here on
4080
+ // purpose — an escape hatch would contradict the guarantee. It is a targeted
4081
+ // mitigation for the automatic conversion, NOT a cleanup mechanism: step 0
4082
+ // measured that this flag changes no process-survival outcome on any turn-end
4083
+ // path. Cleanup is its own change.
4084
+ //
4085
+ // Note `env` REPLACES the subprocess environment rather than merging (the
4086
+ // SDK's own doc on `Options.env`), which is why `process.env` is spread here
4087
+ // and why this key is now always present — previously it appeared only when
4088
+ // `req.local.env` was set, and an absent `env` inherits `process.env`.
4089
+ env: {
4090
+ ...process.env,
4091
+ ...req.local.env,
4092
+ CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
4093
+ },
4058
4094
  ...resume ? { resume } : {}
4059
4095
  };
4060
4096
  let options;
@@ -8153,6 +8189,261 @@ function pruneOld(dir2, retain) {
8153
8189
  }
8154
8190
  }
8155
8191
 
8192
+ // src/turn-containment.ts
8193
+ import { execFile, execFileSync, spawn as spawn4 } from "child_process";
8194
+ import { readFileSync as readFileSync8 } from "fs";
8195
+ import { promisify } from "util";
8196
+ var execFileAsync = promisify(execFile);
8197
+ var asContained = (child) => child;
8198
+ var CONTAINMENT_STOP_GRACE_SEC = 5;
8199
+ var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
8200
+ var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
8201
+ function turnScopeUnit(turnId, attempt) {
8202
+ const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
8203
+ return `cabane-turn-${safe3}-${attempt}`;
8204
+ }
8205
+ function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
8206
+ if (process.platform !== "linux") return false;
8207
+ try {
8208
+ execFileSync("systemd-run", ["--user", "--scope", "--quiet", "--collect", "--", "/bin/true"], {
8209
+ env,
8210
+ timeout: timeoutMs,
8211
+ stdio: "ignore"
8212
+ });
8213
+ return true;
8214
+ } catch {
8215
+ return false;
8216
+ }
8217
+ }
8218
+ function classifyStopError(message) {
8219
+ 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(
8220
+ message
8221
+ )) {
8222
+ return "failure";
8223
+ }
8224
+ if (/not loaded|could not be found|no such unit|unit .* not found/i.test(message))
8225
+ return "absent";
8226
+ return "failure";
8227
+ }
8228
+ function currentSystemdUnit(cgroupText) {
8229
+ for (const line of cgroupText.split("\n")) {
8230
+ const path = line.trim().split(":").pop();
8231
+ if (!path?.startsWith("/")) continue;
8232
+ const leaf = path.split("/").filter(Boolean).pop();
8233
+ if (leaf && (leaf.endsWith(".service") || leaf.endsWith(".scope"))) return leaf;
8234
+ }
8235
+ return null;
8236
+ }
8237
+ function readCurrentSystemdUnit() {
8238
+ try {
8239
+ return currentSystemdUnit(readFileSync8("/proc/self/cgroup", "utf8"));
8240
+ } catch {
8241
+ return null;
8242
+ }
8243
+ }
8244
+ function processGroupMembers(pgid) {
8245
+ try {
8246
+ const out = execFileSync("ps", ["-eo", "pid=,pgid="], { timeout: 5e3 }).toString();
8247
+ return {
8248
+ ok: true,
8249
+ 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]))
8250
+ };
8251
+ } catch (err) {
8252
+ return { ok: false, error: (err instanceof Error ? err.message : String(err)).slice(0, 150) };
8253
+ }
8254
+ }
8255
+ var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
8256
+ var MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
8257
+ var managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
8258
+ function createTurnContainment(turnId, deps = {}) {
8259
+ const probe = deps.capabilityProbe ?? scopeCapabilityUsable;
8260
+ const ownUnit = (deps.ownSystemdUnit ?? readCurrentSystemdUnit)();
8261
+ const scopeUnits = [];
8262
+ const groups = [];
8263
+ let kind = null;
8264
+ let probedEnvKey = null;
8265
+ let attempt = 0;
8266
+ function spawnInScope(options) {
8267
+ const unit = turnScopeUnit(turnId, attempt++);
8268
+ scopeUnits.push({ unit, env: options.env });
8269
+ deps.log?.("containment: spawning the turn CLI into a systemd scope", {
8270
+ unit,
8271
+ partOf: ownUnit,
8272
+ command: options.command
8273
+ });
8274
+ return asContained(
8275
+ spawn4(
8276
+ "systemd-run",
8277
+ [
8278
+ "--user",
8279
+ "--scope",
8280
+ "--quiet",
8281
+ "--collect",
8282
+ `--unit=${unit}`,
8283
+ `--property=TimeoutStopSec=${CONTAINMENT_STOP_GRACE_SEC}s`,
8284
+ // Bind the scope's life to the companion's own unit, so stopping or
8285
+ // restarting the companion still takes its in-flight turns with it —
8286
+ // which is what happens today, and what the scope would otherwise undo.
8287
+ // Omitted outside a unit, where there is nothing to bind to.
8288
+ ...ownUnit ? [`--property=PartOf=${ownUnit}`] : [],
8289
+ "--",
8290
+ options.command,
8291
+ ...options.args
8292
+ ],
8293
+ {
8294
+ ...options.cwd ? { cwd: options.cwd } : {},
8295
+ env: options.env,
8296
+ stdio: ["pipe", "pipe", "pipe"]
8297
+ }
8298
+ )
8299
+ );
8300
+ }
8301
+ function spawnInGroup(options) {
8302
+ const child = spawn4(options.command, options.args, {
8303
+ ...options.cwd ? { cwd: options.cwd } : {},
8304
+ env: options.env,
8305
+ stdio: ["pipe", "pipe", "pipe"],
8306
+ detached: process.platform !== "win32"
8307
+ });
8308
+ if (child.pid !== void 0) groups.push({ pgid: child.pid, child });
8309
+ deps.log?.("containment: no host-owned containment here \u2014 best effort only", {
8310
+ platform: process.platform,
8311
+ pid: child.pid
8312
+ });
8313
+ return asContained(child);
8314
+ }
8315
+ async function reapScopes(result) {
8316
+ for (const { unit, env } of scopeUnits) {
8317
+ try {
8318
+ await execFileAsync("systemctl", ["--user", "stop", `${unit}.scope`], {
8319
+ env,
8320
+ timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
8321
+ });
8322
+ } catch (err) {
8323
+ const message = err instanceof Error ? err.message : String(err);
8324
+ if (classifyStopError(message) === "failure") {
8325
+ result.failures.push({ target: unit, error: message.slice(0, 200) });
8326
+ continue;
8327
+ }
8328
+ result.reaped.push(unit);
8329
+ continue;
8330
+ }
8331
+ try {
8332
+ const { stdout } = await execFileAsync(
8333
+ "systemctl",
8334
+ ["--user", "show", `${unit}.scope`, "--property=ActiveState", "--value"],
8335
+ { env, timeout: 1e4 }
8336
+ );
8337
+ const state = stdout.trim();
8338
+ if (state === "" || state === "inactive" || state === "failed") result.reaped.push(unit);
8339
+ else result.failures.push({ target: unit, error: `scope still ${state} after stop` });
8340
+ } catch (err) {
8341
+ result.failures.push({
8342
+ target: unit,
8343
+ error: `could not confirm the scope stopped: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`
8344
+ });
8345
+ }
8346
+ }
8347
+ }
8348
+ async function reapGroups(result) {
8349
+ for (const { pgid, child } of groups) {
8350
+ const target = String(pgid);
8351
+ if (process.platform === "win32") {
8352
+ try {
8353
+ if (child.exitCode === null) child.kill();
8354
+ result.unverified.push(target);
8355
+ } catch (err) {
8356
+ result.failures.push({
8357
+ target,
8358
+ error: (err instanceof Error ? err.message : String(err)).slice(0, 200)
8359
+ });
8360
+ }
8361
+ continue;
8362
+ }
8363
+ const first = processGroupMembers(pgid);
8364
+ if (!first.ok) {
8365
+ result.failures.push({
8366
+ target,
8367
+ error: `could not enumerate the process group: ${first.error}`
8368
+ });
8369
+ continue;
8370
+ }
8371
+ if (first.members.length === 0) {
8372
+ result.reaped.push(target);
8373
+ continue;
8374
+ }
8375
+ for (const pid of first.members) {
8376
+ try {
8377
+ process.kill(pid, "SIGTERM");
8378
+ } catch {
8379
+ }
8380
+ }
8381
+ await sleep2(FALLBACK_GRACE_MS);
8382
+ const afterTerm = processGroupMembers(pgid);
8383
+ if (!afterTerm.ok) {
8384
+ result.failures.push({
8385
+ target,
8386
+ error: `could not re-enumerate the process group after SIGTERM: ${afterTerm.error}`
8387
+ });
8388
+ continue;
8389
+ }
8390
+ for (const pid of afterTerm.members) {
8391
+ try {
8392
+ process.kill(pid, "SIGKILL");
8393
+ } catch {
8394
+ }
8395
+ }
8396
+ const afterKill = processGroupMembers(pgid);
8397
+ if (!afterKill.ok) {
8398
+ result.failures.push({
8399
+ target,
8400
+ error: `could not confirm the process group is empty after SIGKILL: ${afterKill.error}`
8401
+ });
8402
+ continue;
8403
+ }
8404
+ if (afterKill.members.length)
8405
+ result.failures.push({
8406
+ target,
8407
+ error: `${afterKill.members.length} process(es) still in the group after SIGKILL`
8408
+ });
8409
+ else result.reaped.push(target);
8410
+ }
8411
+ }
8412
+ return {
8413
+ get kind() {
8414
+ return kind;
8415
+ },
8416
+ spawn(options) {
8417
+ const envKey = managerEnvKey(options.env);
8418
+ if (kind === null || envKey !== probedEnvKey) {
8419
+ kind = probe(options.env) ? "systemd-scope" : "process-group";
8420
+ probedEnvKey = envKey;
8421
+ deps.log?.("containment: resolved strategy for this turn", {
8422
+ kind,
8423
+ platform: process.platform
8424
+ });
8425
+ }
8426
+ return kind === "systemd-scope" ? spawnInScope(options) : spawnInGroup(options);
8427
+ },
8428
+ async reap() {
8429
+ const result = {
8430
+ kind: kind ?? "unused",
8431
+ reaped: [],
8432
+ failures: [],
8433
+ unverified: []
8434
+ };
8435
+ await reapScopes(result);
8436
+ await reapGroups(result);
8437
+ deps.log?.("containment: reap complete", {
8438
+ kind: result.kind,
8439
+ reaped: result.reaped,
8440
+ failures: result.failures
8441
+ });
8442
+ return result;
8443
+ }
8444
+ };
8445
+ }
8446
+
8156
8447
  // src/turn-committer.ts
8157
8448
  var TurnCommitter = class {
8158
8449
  constructor(deps) {
@@ -8372,7 +8663,10 @@ function missingSecretReason(missing) {
8372
8663
  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
8664
  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
8665
  var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
8375
- var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
8666
+ var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 15 * 6e4;
8667
+ function resolveIdleTimeoutMs(override) {
8668
+ return override ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
8669
+ }
8376
8670
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
8377
8671
  var DEFAULT_LEASE_RENEWAL_MS = 3e4;
8378
8672
  function initialOutcome() {
@@ -8430,6 +8724,11 @@ var TurnExecution = class {
8430
8724
  turnLog;
8431
8725
  abortController = new AbortController();
8432
8726
  outcome = initialOutcome();
8727
+ // CT1503: this turn's shell containment, once the claude-code adapter is built.
8728
+ // Null before that and on a turn that never reached adapter construction — a
8729
+ // startup failure has nothing to reap, and reaping is a no-op rather than a
8730
+ // special case.
8731
+ containment = null;
8433
8732
  seqCounter = 0;
8434
8733
  // CT11: per-turn monotonic counter mirroring the in-process dispatcher's.
8435
8734
  //
@@ -8464,11 +8763,43 @@ var TurnExecution = class {
8464
8763
  };
8465
8764
  disarmWatchdogs = () => {
8466
8765
  };
8766
+ // CT1503: reap this turn's shell containment. Idempotent (the containment drops
8767
+ // what it has already stopped) and total — a reap that fails is reported and
8768
+ // swallowed, because a turn that produced a good answer must not be recorded as
8769
+ // failed on account of a leftover process, and the failure is visible in the log
8770
+ // either way.
8771
+ async reapContainment() {
8772
+ const containment = this.containment;
8773
+ if (!containment) return;
8774
+ this.containment = null;
8775
+ try {
8776
+ const result = await containment.reap();
8777
+ if (result.failures.length) {
8778
+ this.turnLog.warn(
8779
+ { kind: result.kind, failures: result.failures },
8780
+ "turn containment: some of this turn\u2019s shell work could not be reaped"
8781
+ );
8782
+ }
8783
+ if (result.unverified.length) {
8784
+ this.turnLog.debug(
8785
+ { kind: result.kind, unverified: result.unverified },
8786
+ "turn containment: cleanup requested but not confirmable on this platform"
8787
+ );
8788
+ }
8789
+ } catch (err) {
8790
+ this.turnLog.warn(
8791
+ { err: err instanceof Error ? err.message : String(err) },
8792
+ "turn containment: reap threw"
8793
+ );
8794
+ }
8795
+ }
8467
8796
  // Codo's stack review, blocking finding #2: set the moment `acquireLease`'s
8468
8797
  // PATCH returns — the lease is granted and this is the pair's running span,
8469
8798
  // so every exit after this point must settle THE SPAN, not just clear the
8470
8799
  // participant flag.
8471
8800
  admitted = false;
8801
+ // Rooms: one recompose per turn when the lease refuses a stale context.
8802
+ recomposedForLease = false;
8472
8803
  concluded(reason, errorReason) {
8473
8804
  return new TurnConcluded(reason, errorReason);
8474
8805
  }
@@ -8787,9 +9118,18 @@ var TurnExecution = class {
8787
9118
  // and settle moves the cursor to it. Omitted when the server sent
8788
9119
  // none (an older API), so the cursor stays where it was.
8789
9120
  ...this.turnContext.readThrough !== void 0 ? { readThrough: this.turnContext.readThrough } : {},
8790
- ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {}
9121
+ ...this.turnContext.readRevision !== void 0 ? { readRevision: this.turnContext.readRevision } : {},
9122
+ // Rooms: the attempt this request's session belongs to (null = fresh).
9123
+ ...this.turnContext.attemptId !== void 0 ? { attemptId: this.turnContext.attemptId } : {}
8791
9124
  });
8792
9125
  } catch (err) {
9126
+ if (leaseRefusal(err) === "dispatch_context_stale" && !this.recomposedForLease) {
9127
+ this.recomposedForLease = true;
9128
+ turnLog.info({ turnId }, "dispatcher: lease found the context stale; recomposing");
9129
+ await this.fetchContext();
9130
+ this.buildRequest();
9131
+ return this.acquireLease();
9132
+ }
8793
9133
  const refusal = leaseRefusal(err);
8794
9134
  if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
8795
9135
  turnLog.debug(
@@ -8854,7 +9194,20 @@ var TurnExecution = class {
8854
9194
  const onWarn = (msg, meta) => turnLog.debug(meta ?? {}, msg);
8855
9195
  const adapters = [];
8856
9196
  if (this.opts.claudeCodeAvailable?.() ?? true) {
8857
- adapters.push(createClaudeCodeAdapter({ queryFn: this.opts.queryFn, onWarn }));
9197
+ this.containment = createTurnContainment(this.turnId, {
9198
+ log: (msg, meta) => turnLog.debug(meta ?? {}, msg)
9199
+ });
9200
+ const containment = this.containment;
9201
+ adapters.push(
9202
+ createClaudeCodeAdapter({
9203
+ queryFn: this.opts.queryFn,
9204
+ onWarn,
9205
+ augmentOptions: (options) => ({
9206
+ ...options,
9207
+ spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(spawnOptions)
9208
+ })
9209
+ })
9210
+ );
8858
9211
  }
8859
9212
  if (this.opts.opencodeServerUrl) {
8860
9213
  adapters.push(createOpencodeAdapter({ serverUrl: this.opts.opencodeServerUrl, onWarn }));
@@ -8996,7 +9349,7 @@ var TurnExecution = class {
8996
9349
  );
8997
9350
  }
8998
9351
  };
8999
- const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
9352
+ const idleTimeoutMs = resolveIdleTimeoutMs(this.opts.idleTimeoutMs);
9000
9353
  const fireTimeout = (reason) => {
9001
9354
  if (abortController.signal.aborted) return;
9002
9355
  o.timeoutReason = reason;
@@ -9149,6 +9502,7 @@ var TurnExecution = class {
9149
9502
  );
9150
9503
  } finally {
9151
9504
  this.disarmWatchdogs();
9505
+ await this.reapContainment();
9152
9506
  if (o.leaseLost) {
9153
9507
  o.resultReason = "lease_lost";
9154
9508
  o.okResult = false;
@@ -9454,7 +9808,7 @@ import {
9454
9808
  existsSync as existsSync12,
9455
9809
  mkdirSync as mkdirSync10,
9456
9810
  readdirSync as readdirSync3,
9457
- readFileSync as readFileSync8,
9811
+ readFileSync as readFileSync9,
9458
9812
  renameSync as renameSync4,
9459
9813
  rmSync as rmSync7,
9460
9814
  writeFileSync as writeFileSync7
@@ -9519,7 +9873,7 @@ var Outbox = class {
9519
9873
  if (!name.endsWith(".json")) continue;
9520
9874
  const full = join16(dir2, name);
9521
9875
  try {
9522
- const parsed = JSON.parse(readFileSync8(full, "utf8"));
9876
+ const parsed = JSON.parse(readFileSync9(full, "utf8"));
9523
9877
  if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
9524
9878
  entries.push(parsed);
9525
9879
  } else {
@@ -9700,7 +10054,7 @@ var SseSubscriber = class {
9700
10054
  backoff = 500;
9701
10055
  if (!this.aborted) {
9702
10056
  this.opts.log.warn("Lost the connection to Cabane; reconnecting");
9703
- await sleep2(backoff);
10057
+ await sleep3(backoff);
9704
10058
  }
9705
10059
  } catch (err) {
9706
10060
  if (this.aborted) return;
@@ -9720,7 +10074,7 @@ var SseSubscriber = class {
9720
10074
  },
9721
10075
  "Lost the connection to Cabane; reconnecting"
9722
10076
  );
9723
- await sleep2(backoff);
10077
+ await sleep3(backoff);
9724
10078
  backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
9725
10079
  }
9726
10080
  }
@@ -9772,7 +10126,7 @@ var SseSubscriber = class {
9772
10126
  }
9773
10127
  }
9774
10128
  };
9775
- function sleep2(ms) {
10129
+ function sleep3(ms) {
9776
10130
  return new Promise((resolve2) => setTimeout(resolve2, ms));
9777
10131
  }
9778
10132
 
@@ -10340,7 +10694,9 @@ var CompanionSupervisor = class {
10340
10694
  codexAvailable: () => this.codexOffered(),
10341
10695
  // CT556: per-turn timeout watchdog windows, from the companion's own env
10342
10696
  // (`AGENT_IDLE_TIMEOUT_MS` / `AGENT_TOTAL_TIMEOUT_MS`). Unset → the
10343
- // dispatcher's baked-in defaults (10 min idle / 6h total).
10697
+ // dispatcher's baked-in defaults (CT1503: 15 min idle / 6h total). An
10698
+ // override set here still wins, and is deliberately not validated against
10699
+ // the tool ceilings it may reorder — see DEFAULT_AGENT_IDLE_TIMEOUT_MS.
10344
10700
  ...positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") !== void 0 ? { idleTimeoutMs: positiveIntEnv("AGENT_IDLE_TIMEOUT_MS") } : {},
10345
10701
  ...positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") !== void 0 ? { totalTimeoutMs: positiveIntEnv("AGENT_TOTAL_TIMEOUT_MS") } : {},
10346
10702
  observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
@@ -10952,9 +11308,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
10952
11308
  }
10953
11309
  function defaultReexec() {
10954
11310
  clearRuntimeState();
10955
- void import("child_process").then(({ spawn: spawn4 }) => {
11311
+ void import("child_process").then(({ spawn: spawn5 }) => {
10956
11312
  try {
10957
- const child = spawn4(process.execPath, process.argv.slice(1), {
11313
+ const child = spawn5(process.execPath, process.argv.slice(1), {
10958
11314
  stdio: "inherit",
10959
11315
  detached: false
10960
11316
  });
@@ -11024,7 +11380,7 @@ function handleUncaught(log, err, origin) {
11024
11380
  }
11025
11381
 
11026
11382
  // src/crash-marker.ts
11027
- import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
11383
+ import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync10, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
11028
11384
  import { join as join17 } from "path";
11029
11385
  function crashMarkerPath() {
11030
11386
  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.105",
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",