@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.
- package/dist/cli.js +383 -27
- package/dist/runtime.js +371 -15
- 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";
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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:
|
|
11888
|
+
void import("child_process").then(({ spawn: spawn6 }) => {
|
|
11533
11889
|
try {
|
|
11534
|
-
const child =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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:
|
|
11311
|
+
void import("child_process").then(({ spawn: spawn5 }) => {
|
|
10956
11312
|
try {
|
|
10957
|
-
const child =
|
|
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
|
|
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.
|
|
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",
|