@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.
- package/dist/cli.js +366 -25
- package/dist/runtime.js +354 -13
- 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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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:
|
|
11873
|
+
void import("child_process").then(({ spawn: spawn6 }) => {
|
|
11533
11874
|
try {
|
|
11534
|
-
const child =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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:
|
|
11296
|
+
void import("child_process").then(({ spawn: spawn5 }) => {
|
|
10956
11297
|
try {
|
|
10957
|
-
const child =
|
|
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
|
|
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.
|
|
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",
|