@cabane/companion 0.6.121 → 0.6.122
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 +11671 -9831
- package/dist/pairing-config.js +10 -0
- package/dist/runtime.js +1350 -659
- package/package.json +2 -1
package/dist/runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/runtime.ts
|
|
2
|
-
import { randomUUID as
|
|
2
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
3
3
|
|
|
4
4
|
// src/config.ts
|
|
5
5
|
import {
|
|
@@ -352,8 +352,24 @@ var companionConfigSchema = z2.object({
|
|
|
352
352
|
// half-installed platform package could not start.
|
|
353
353
|
codex: z2.object({
|
|
354
354
|
enabled: z2.boolean().optional()
|
|
355
|
+
}).strict().optional(),
|
|
356
|
+
// CT1679: run each turn in a child runner process instead of in this one.
|
|
357
|
+
// Absent, or `enabled: false`, is the in-process path, untouched. `sandbox`
|
|
358
|
+
// wraps the runner in an OS sandbox where the host supports one; `'none'`
|
|
359
|
+
// (the default) runs it as a plain child beside this process, a supported
|
|
360
|
+
// configuration in its own right rather than a degraded one.
|
|
361
|
+
runners: z2.object({
|
|
362
|
+
enabled: z2.boolean().optional(),
|
|
363
|
+
provider: z2.literal("local-process").optional(),
|
|
364
|
+
sandbox: z2.enum(["os", "none"]).optional()
|
|
355
365
|
}).strict().optional()
|
|
356
366
|
});
|
|
367
|
+
function runnersEnabled(cfg) {
|
|
368
|
+
return cfg.runners?.enabled === true;
|
|
369
|
+
}
|
|
370
|
+
function runnerStateDir() {
|
|
371
|
+
return process.env.CABANE_RUNNER_STATE_DIR || cabaneDir();
|
|
372
|
+
}
|
|
357
373
|
function isCodexEnabled(cfg) {
|
|
358
374
|
return !!cfg.codex && cfg.codex.enabled !== false;
|
|
359
375
|
}
|
|
@@ -1462,8 +1478,8 @@ function registerRoutes(app, deps) {
|
|
|
1462
1478
|
return c.json({ dispatches: hub.recentDispatches(limit).map(dispatchToWire) });
|
|
1463
1479
|
});
|
|
1464
1480
|
app.get("/api/logs", async (c) => {
|
|
1465
|
-
const
|
|
1466
|
-
return c.json({ lines: tailFile(companionLogPath(),
|
|
1481
|
+
const lines2 = clampLimit(c.req.query("lines"), 200, 1e3);
|
|
1482
|
+
return c.json({ lines: tailFile(companionLogPath(), lines2) });
|
|
1467
1483
|
});
|
|
1468
1484
|
app.post("/api/settings", async (c) => {
|
|
1469
1485
|
const body = await readJson(c);
|
|
@@ -1582,7 +1598,7 @@ function clampLimit(raw, fallback, max = 200) {
|
|
|
1582
1598
|
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
1583
1599
|
return Math.min(Math.floor(n), max);
|
|
1584
1600
|
}
|
|
1585
|
-
function tailFile(path,
|
|
1601
|
+
function tailFile(path, lines2) {
|
|
1586
1602
|
if (!existsSync2(path)) return [];
|
|
1587
1603
|
const MAX_BYTES = 256 * 1024;
|
|
1588
1604
|
let fd;
|
|
@@ -1597,7 +1613,7 @@ function tailFile(path, lines) {
|
|
|
1597
1613
|
const text = buf.toString("utf8");
|
|
1598
1614
|
const all = text.split("\n");
|
|
1599
1615
|
if (start > 0 && all.length > 0) all.shift();
|
|
1600
|
-
return all.filter((l) => l.length > 0).slice(-
|
|
1616
|
+
return all.filter((l) => l.length > 0).slice(-lines2);
|
|
1601
1617
|
} catch {
|
|
1602
1618
|
return [];
|
|
1603
1619
|
} finally {
|
|
@@ -2278,7 +2294,7 @@ import { randomUUID } from "crypto";
|
|
|
2278
2294
|
var bootId = randomUUID();
|
|
2279
2295
|
|
|
2280
2296
|
// src/supervisor.ts
|
|
2281
|
-
import { randomUUID as
|
|
2297
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2282
2298
|
|
|
2283
2299
|
// src/api.ts
|
|
2284
2300
|
var DEVICE_HEADER = "x-cabane-device";
|
|
@@ -7811,258 +7827,990 @@ var ConnectorHealthStore = class {
|
|
|
7811
7827
|
};
|
|
7812
7828
|
|
|
7813
7829
|
// src/dispatcher.ts
|
|
7830
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
7814
7831
|
import { existsSync as existsSync10, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
7815
7832
|
import { join as join14 } from "path";
|
|
7816
7833
|
|
|
7817
|
-
// src/
|
|
7834
|
+
// src/runner/launch.ts
|
|
7818
7835
|
import { createInterface } from "readline";
|
|
7819
|
-
import { homedir as homedir2 } from "os";
|
|
7820
|
-
import pc from "picocolors";
|
|
7821
|
-
function tildePath(path) {
|
|
7822
|
-
const home = homedir2();
|
|
7823
|
-
if (home && path.startsWith(home + "/")) return `~${path.slice(home.length)}`;
|
|
7824
|
-
return path;
|
|
7825
|
-
}
|
|
7826
|
-
|
|
7827
|
-
// src/turn-execution.ts
|
|
7828
|
-
import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
|
|
7829
|
-
import { existsSync as existsSync9 } from "fs";
|
|
7830
7836
|
|
|
7831
|
-
// src/
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
|
|
7835
|
-
var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
|
|
7836
|
-
var SKIP_TURN_TOOL = "skip_turn";
|
|
7837
|
-
var ASK_TOOL = "ask";
|
|
7838
|
-
var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
|
|
7839
|
-
var WITHDRAW_ASK_TOOL = "withdraw_ask";
|
|
7840
|
-
var WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
|
|
7841
|
-
var WAKE_ME_TOOL = "wake_me";
|
|
7842
|
-
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
7843
|
-
var CANCEL_WAKE_TOOL = "cancel_wake";
|
|
7844
|
-
var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
|
|
7845
|
-
var TURN_ACT_TIMEOUT_MS = 45e3;
|
|
7846
|
-
function createSkipState() {
|
|
7847
|
-
return { skipped: false, reason: null };
|
|
7837
|
+
// src/runner/opencode-lock.ts
|
|
7838
|
+
function opencodeLockKey(serverUrl) {
|
|
7839
|
+
return `opencode:${serverUrl}`;
|
|
7848
7840
|
}
|
|
7849
|
-
function
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
let result;
|
|
7841
|
+
async function stopRemoteOpencodeSession(serverUrl, sessionId, opts = {}) {
|
|
7842
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
7843
|
+
const base = serverUrl.replace(/\/+$/, "");
|
|
7844
|
+
const id = encodeURIComponent(sessionId);
|
|
7854
7845
|
try {
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
|
|
7865
|
-
|
|
7866
|
-
|
|
7867
|
-
|
|
7868
|
-
|
|
7869
|
-
);
|
|
7846
|
+
await doFetch(`${base}/session/${id}/abort`, {
|
|
7847
|
+
method: "POST",
|
|
7848
|
+
signal: AbortSignal.timeout(5e3)
|
|
7849
|
+
});
|
|
7850
|
+
} catch {
|
|
7851
|
+
}
|
|
7852
|
+
const attempts = opts.attempts ?? 20;
|
|
7853
|
+
for (let i = 0; i < attempts; i++) {
|
|
7854
|
+
try {
|
|
7855
|
+
const res = await doFetch(`${base}/session/status`, { signal: AbortSignal.timeout(5e3) });
|
|
7856
|
+
if (res.ok) {
|
|
7857
|
+
const status = await res.json();
|
|
7858
|
+
const state = status[sessionId];
|
|
7859
|
+
if (!state || state.type === "idle") return true;
|
|
7870
7860
|
}
|
|
7871
|
-
|
|
7861
|
+
} catch {
|
|
7872
7862
|
}
|
|
7873
|
-
|
|
7863
|
+
await new Promise((resolve2) => setTimeout(resolve2, opts.pollMs ?? 500));
|
|
7874
7864
|
}
|
|
7875
|
-
|
|
7876
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
7865
|
+
return false;
|
|
7877
7866
|
}
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
|
|
7881
|
-
|
|
7867
|
+
|
|
7868
|
+
// src/turn-containment.ts
|
|
7869
|
+
import { execFile, execFileSync, spawn as spawn4 } from "child_process";
|
|
7870
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
7871
|
+
import { promisify } from "util";
|
|
7872
|
+
var execFileAsync = promisify(execFile);
|
|
7873
|
+
var asContained = (child) => child;
|
|
7874
|
+
var CONTAINMENT_STOP_GRACE_SEC = 5;
|
|
7875
|
+
var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
|
|
7876
|
+
var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
|
|
7877
|
+
function turnScopeUnit(turnId, attempt) {
|
|
7878
|
+
const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
7879
|
+
return `cabane-turn-${safe3}-${attempt}`;
|
|
7882
7880
|
}
|
|
7883
|
-
function
|
|
7884
|
-
|
|
7885
|
-
const
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
{
|
|
7896
|
-
reason: z13.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
|
|
7897
|
-
},
|
|
7898
|
-
async (args) => {
|
|
7899
|
-
skipState.skipped = true;
|
|
7900
|
-
skipState.reason = args.reason;
|
|
7901
|
-
return {
|
|
7902
|
-
content: [{ type: "text", text: JSON.stringify({ skipped: true }) }]
|
|
7903
|
-
};
|
|
7904
|
-
},
|
|
7905
|
-
{ annotations: RECORDER_TOOL, alwaysLoad: true }
|
|
7906
|
-
)
|
|
7907
|
-
] : [],
|
|
7908
|
-
tool(
|
|
7909
|
-
ASK_TOOL,
|
|
7910
|
-
TURN_CONTROL_TOOL_DESCRIPTIONS.ask,
|
|
7911
|
-
{
|
|
7912
|
-
targetUserId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
|
|
7913
|
-
question: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
|
|
7914
|
-
headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
|
|
7915
|
-
options: z13.array(
|
|
7916
|
-
z13.union([
|
|
7917
|
-
z13.string().min(1).max(MAX_ASK_OPTION_BYTES),
|
|
7918
|
-
z13.object({
|
|
7919
|
-
label: z13.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
|
|
7920
|
-
move: z13.object({
|
|
7921
|
-
task: z13.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
|
|
7922
|
-
status: z13.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
|
|
7923
|
-
}).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
|
|
7924
|
-
})
|
|
7925
|
-
])
|
|
7926
|
-
).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
|
|
7927
|
-
questions: z13.array(
|
|
7928
|
-
z13.object({
|
|
7929
|
-
headline: z13.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
|
|
7930
|
-
body: z13.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
|
|
7931
|
-
options: z13.array(
|
|
7932
|
-
z13.union([
|
|
7933
|
-
z13.string().min(1).max(MAX_ASK_OPTION_BYTES),
|
|
7934
|
-
z13.object({
|
|
7935
|
-
label: z13.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
|
|
7936
|
-
move: z13.object({
|
|
7937
|
-
task: z13.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
|
|
7938
|
-
status: z13.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
|
|
7939
|
-
}).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
|
|
7940
|
-
})
|
|
7941
|
-
])
|
|
7942
|
-
).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
|
|
7943
|
-
})
|
|
7944
|
-
).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
|
|
7945
|
-
replaces: z13.object({
|
|
7946
|
-
askId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
|
|
7947
|
-
reason: z13.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
|
|
7948
|
-
}).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.replaces)
|
|
7949
|
-
},
|
|
7950
|
-
async (args) => {
|
|
7951
|
-
const hasSingle = args.question !== void 0;
|
|
7952
|
-
const hasArray = args.questions !== void 0 && args.questions.length > 0;
|
|
7953
|
-
if (hasSingle && hasArray) {
|
|
7954
|
-
return toolError(
|
|
7955
|
-
"Provide either `question` (single) or `questions` (array), not both."
|
|
7956
|
-
);
|
|
7957
|
-
}
|
|
7958
|
-
if (!hasSingle && !hasArray) {
|
|
7959
|
-
return toolError("Provide `question` (single) or `questions` (array).");
|
|
7960
|
-
}
|
|
7961
|
-
return performAct(acts, {
|
|
7962
|
-
kind: "ask",
|
|
7963
|
-
callId: randomUUID2(),
|
|
7964
|
-
// CT1350: the swap rides the ask, so the server commits both halves
|
|
7965
|
-
// in one transaction — the person never sees neither or both.
|
|
7966
|
-
...args.replaces ? { replaces: args.replaces } : {},
|
|
7967
|
-
ask: hasArray ? {
|
|
7968
|
-
targetUserId: args.targetUserId,
|
|
7969
|
-
questions: args.questions.map((q) => ({
|
|
7970
|
-
headline: q.headline,
|
|
7971
|
-
...q.body ? { body: q.body } : {},
|
|
7972
|
-
...q.options && q.options.length > 0 ? { options: q.options } : {}
|
|
7973
|
-
}))
|
|
7974
|
-
} : {
|
|
7975
|
-
targetUserId: args.targetUserId,
|
|
7976
|
-
question: args.question,
|
|
7977
|
-
...args.headline ? { headline: args.headline } : {},
|
|
7978
|
-
...args.options && args.options.length > 0 ? { options: args.options } : {}
|
|
7979
|
-
}
|
|
7980
|
-
});
|
|
7981
|
-
},
|
|
7982
|
-
{ annotations: ACT_TOOL, alwaysLoad: true }
|
|
7983
|
-
),
|
|
7984
|
-
tool(
|
|
7985
|
-
WAKE_ME_TOOL,
|
|
7986
|
-
TURN_CONTROL_TOOL_DESCRIPTIONS.wake_me,
|
|
7987
|
-
{
|
|
7988
|
-
afterSeconds: z13.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
|
|
7989
|
-
at: z13.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
|
|
7990
|
-
note: z13.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
|
|
7991
|
-
},
|
|
7992
|
-
async (args) => {
|
|
7993
|
-
const hasAfter = args.afterSeconds !== void 0;
|
|
7994
|
-
const hasAt = args.at !== void 0;
|
|
7995
|
-
if (hasAfter && hasAt) {
|
|
7996
|
-
return toolError(
|
|
7997
|
-
"Provide either `afterSeconds` (relative) or `at` (absolute), not both."
|
|
7998
|
-
);
|
|
7999
|
-
}
|
|
8000
|
-
if (!hasAfter && !hasAt) {
|
|
8001
|
-
return toolError("Provide `afterSeconds` or `at`.");
|
|
8002
|
-
}
|
|
8003
|
-
return performAct(acts, {
|
|
8004
|
-
kind: "wake",
|
|
8005
|
-
callId: randomUUID2(),
|
|
8006
|
-
wake: {
|
|
8007
|
-
...hasAfter ? { afterSeconds: args.afterSeconds } : {},
|
|
8008
|
-
...hasAt ? { at: args.at } : {},
|
|
8009
|
-
note: args.note
|
|
8010
|
-
}
|
|
8011
|
-
});
|
|
8012
|
-
},
|
|
8013
|
-
{ annotations: ACT_TOOL, alwaysLoad: true }
|
|
8014
|
-
),
|
|
8015
|
-
// CT1350: the standalone withdrawal. Not annotated destructive — an ask
|
|
8016
|
-
// is a question, not data, and a harness that gated this on a live
|
|
8017
|
-
// operator would hang a turn nobody is watching (the `cancel_wake`
|
|
8018
|
-
// reasoning). Not idempotent either: a second call with the same id is
|
|
8019
|
-
// refused with `ask_not_open`, which is information, not a no-op.
|
|
8020
|
-
tool(
|
|
8021
|
-
WITHDRAW_ASK_TOOL,
|
|
8022
|
-
TURN_CONTROL_TOOL_DESCRIPTIONS.withdraw_ask,
|
|
8023
|
-
{
|
|
8024
|
-
askId: z13.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
|
|
8025
|
-
reason: z13.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
|
|
8026
|
-
},
|
|
8027
|
-
async (args) => performAct(acts, {
|
|
8028
|
-
kind: "withdraw_ask",
|
|
8029
|
-
callId: randomUUID2(),
|
|
8030
|
-
withdraw: { askId: args.askId, reason: args.reason }
|
|
8031
|
-
}),
|
|
8032
|
-
{ annotations: ACT_TOOL, alwaysLoad: true }
|
|
8033
|
-
),
|
|
8034
|
-
// CT990: the inverse. No arguments — it can only ever stand down the
|
|
8035
|
-
// caller's own wake in the conversation it's holding a turn in.
|
|
8036
|
-
tool(
|
|
8037
|
-
CANCEL_WAKE_TOOL,
|
|
8038
|
-
TURN_CONTROL_TOOL_DESCRIPTIONS.cancel_wake,
|
|
8039
|
-
{},
|
|
8040
|
-
async () => performAct(acts, { kind: "cancel_wake", callId: randomUUID2() }),
|
|
8041
|
-
{ annotations: CANCEL_TOOL, alwaysLoad: true }
|
|
8042
|
-
)
|
|
8043
|
-
]
|
|
8044
|
-
});
|
|
7881
|
+
async function reapTurnScopes(turnId) {
|
|
7882
|
+
if (process.platform !== "linux") return { failures: [] };
|
|
7883
|
+
const pattern = `${turnScopeUnit(turnId, 0).replace(/-0$/, "")}-*.scope`;
|
|
7884
|
+
try {
|
|
7885
|
+
await execFileAsync("systemctl", ["--user", "stop", pattern], {
|
|
7886
|
+
timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
|
|
7887
|
+
});
|
|
7888
|
+
return { failures: [] };
|
|
7889
|
+
} catch (err) {
|
|
7890
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7891
|
+
return classifyStopError(message) === "absent" ? { failures: [] } : { failures: [message.slice(0, 200)] };
|
|
7892
|
+
}
|
|
8045
7893
|
}
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
7894
|
+
function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
|
|
7895
|
+
if (process.platform !== "linux") return false;
|
|
7896
|
+
try {
|
|
7897
|
+
execFileSync("systemd-run", ["--user", "--scope", "--quiet", "--collect", "--", "/bin/true"], {
|
|
7898
|
+
env,
|
|
7899
|
+
timeout: timeoutMs,
|
|
7900
|
+
stdio: "ignore"
|
|
7901
|
+
});
|
|
7902
|
+
return true;
|
|
7903
|
+
} catch {
|
|
7904
|
+
return false;
|
|
7905
|
+
}
|
|
8050
7906
|
}
|
|
8051
|
-
function
|
|
8052
|
-
|
|
7907
|
+
function classifyStopError(message) {
|
|
7908
|
+
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(
|
|
7909
|
+
message
|
|
7910
|
+
)) {
|
|
7911
|
+
return "failure";
|
|
7912
|
+
}
|
|
7913
|
+
if (/not loaded|could not be found|no such unit|unit .* not found/i.test(message))
|
|
7914
|
+
return "absent";
|
|
7915
|
+
return "failure";
|
|
8053
7916
|
}
|
|
8054
|
-
function
|
|
8055
|
-
const
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
|
|
8060
|
-
|
|
8061
|
-
|
|
8062
|
-
|
|
8063
|
-
|
|
8064
|
-
|
|
8065
|
-
|
|
7917
|
+
function currentSystemdUnit(cgroupText) {
|
|
7918
|
+
for (const line of cgroupText.split("\n")) {
|
|
7919
|
+
const path = line.trim().split(":").pop();
|
|
7920
|
+
if (!path?.startsWith("/")) continue;
|
|
7921
|
+
const leaf = path.split("/").filter(Boolean).pop();
|
|
7922
|
+
if (leaf && (leaf.endsWith(".service") || leaf.endsWith(".scope"))) return leaf;
|
|
7923
|
+
}
|
|
7924
|
+
return null;
|
|
7925
|
+
}
|
|
7926
|
+
function readCurrentSystemdUnit() {
|
|
7927
|
+
try {
|
|
7928
|
+
return currentSystemdUnit(readFileSync5("/proc/self/cgroup", "utf8"));
|
|
7929
|
+
} catch {
|
|
7930
|
+
return null;
|
|
7931
|
+
}
|
|
7932
|
+
}
|
|
7933
|
+
function processGroupMembers(pgid) {
|
|
7934
|
+
try {
|
|
7935
|
+
const out = execFileSync("ps", ["-eo", "pid=,pgid="], { timeout: 5e3 }).toString();
|
|
7936
|
+
return {
|
|
7937
|
+
ok: true,
|
|
7938
|
+
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]))
|
|
7939
|
+
};
|
|
7940
|
+
} catch (err) {
|
|
7941
|
+
return { ok: false, error: (err instanceof Error ? err.message : String(err)).slice(0, 150) };
|
|
7942
|
+
}
|
|
7943
|
+
}
|
|
7944
|
+
var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
7945
|
+
var MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
|
|
7946
|
+
var managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
|
|
7947
|
+
function createTurnContainment(turnId, deps = {}) {
|
|
7948
|
+
const probe = deps.capabilityProbe ?? scopeCapabilityUsable;
|
|
7949
|
+
const fallbackGraceMs = deps.fallbackGraceMs ?? FALLBACK_GRACE_MS;
|
|
7950
|
+
const ownUnit = (deps.ownSystemdUnit ?? readCurrentSystemdUnit)();
|
|
7951
|
+
const scopeUnits = [];
|
|
7952
|
+
const groups = [];
|
|
7953
|
+
let kind = null;
|
|
7954
|
+
let probedEnvKey = null;
|
|
7955
|
+
let attempt = 0;
|
|
7956
|
+
function scopeArgs(unit) {
|
|
7957
|
+
return [
|
|
7958
|
+
"--user",
|
|
7959
|
+
"--scope",
|
|
7960
|
+
"--quiet",
|
|
7961
|
+
"--collect",
|
|
7962
|
+
`--unit=${unit}`,
|
|
7963
|
+
`--property=TimeoutStopSec=${CONTAINMENT_STOP_GRACE_SEC}s`,
|
|
7964
|
+
// Bind the scope's life to the companion's own unit, so stopping or
|
|
7965
|
+
// restarting the companion still takes its in-flight turns with it —
|
|
7966
|
+
// which is what happens today, and what the scope would otherwise undo.
|
|
7967
|
+
// Omitted outside a unit, where there is nothing to bind to.
|
|
7968
|
+
...ownUnit ? [`--property=PartOf=${ownUnit}`] : [],
|
|
7969
|
+
"--"
|
|
7970
|
+
];
|
|
7971
|
+
}
|
|
7972
|
+
function resolveKind(env) {
|
|
7973
|
+
const envKey = managerEnvKey(env);
|
|
7974
|
+
if (kind === null || envKey !== probedEnvKey) {
|
|
7975
|
+
kind = probe(env) ? "systemd-scope" : "process-group";
|
|
7976
|
+
probedEnvKey = envKey;
|
|
7977
|
+
deps.log?.("containment: resolved strategy for this turn", {
|
|
7978
|
+
kind,
|
|
7979
|
+
platform: process.platform
|
|
7980
|
+
});
|
|
7981
|
+
}
|
|
7982
|
+
return kind;
|
|
7983
|
+
}
|
|
7984
|
+
function spawnInScope(options) {
|
|
7985
|
+
const unit = turnScopeUnit(turnId, attempt++);
|
|
7986
|
+
scopeUnits.push({ unit, env: options.env });
|
|
7987
|
+
deps.log?.("containment: spawning the turn CLI into a systemd scope", {
|
|
7988
|
+
unit,
|
|
7989
|
+
partOf: ownUnit,
|
|
7990
|
+
command: options.command
|
|
7991
|
+
});
|
|
7992
|
+
return asContained(
|
|
7993
|
+
spawn4("systemd-run", [...scopeArgs(unit), options.command, ...options.args], {
|
|
7994
|
+
...options.cwd ? { cwd: options.cwd } : {},
|
|
7995
|
+
env: options.env,
|
|
7996
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
7997
|
+
})
|
|
7998
|
+
);
|
|
7999
|
+
}
|
|
8000
|
+
function spawnInGroup(options) {
|
|
8001
|
+
const child = spawn4(options.command, options.args, {
|
|
8002
|
+
...options.cwd ? { cwd: options.cwd } : {},
|
|
8003
|
+
env: options.env,
|
|
8004
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
8005
|
+
detached: process.platform !== "win32"
|
|
8006
|
+
});
|
|
8007
|
+
if (child.pid !== void 0) {
|
|
8008
|
+
groups.push({ pgid: child.pid, child });
|
|
8009
|
+
deps.onProcessGroup?.(child.pid);
|
|
8010
|
+
}
|
|
8011
|
+
deps.log?.("containment: no host-owned containment here \u2014 best effort only", {
|
|
8012
|
+
platform: process.platform,
|
|
8013
|
+
pid: child.pid
|
|
8014
|
+
});
|
|
8015
|
+
return asContained(child);
|
|
8016
|
+
}
|
|
8017
|
+
async function reapScopes(result) {
|
|
8018
|
+
for (const { unit, env } of scopeUnits) {
|
|
8019
|
+
try {
|
|
8020
|
+
await execFileAsync("systemctl", ["--user", "stop", `${unit}.scope`], {
|
|
8021
|
+
env,
|
|
8022
|
+
timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
|
|
8023
|
+
});
|
|
8024
|
+
} catch (err) {
|
|
8025
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
8026
|
+
if (classifyStopError(message) === "failure") {
|
|
8027
|
+
result.failures.push({ target: unit, error: message.slice(0, 200) });
|
|
8028
|
+
continue;
|
|
8029
|
+
}
|
|
8030
|
+
result.reaped.push(unit);
|
|
8031
|
+
continue;
|
|
8032
|
+
}
|
|
8033
|
+
try {
|
|
8034
|
+
const { stdout } = await execFileAsync(
|
|
8035
|
+
"systemctl",
|
|
8036
|
+
["--user", "show", `${unit}.scope`, "--property=ActiveState", "--value"],
|
|
8037
|
+
{ env, timeout: 1e4 }
|
|
8038
|
+
);
|
|
8039
|
+
const state = stdout.trim();
|
|
8040
|
+
if (state === "" || state === "inactive" || state === "failed") result.reaped.push(unit);
|
|
8041
|
+
else result.failures.push({ target: unit, error: `scope still ${state} after stop` });
|
|
8042
|
+
} catch (err) {
|
|
8043
|
+
result.failures.push({
|
|
8044
|
+
target: unit,
|
|
8045
|
+
error: `could not confirm the scope stopped: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`
|
|
8046
|
+
});
|
|
8047
|
+
}
|
|
8048
|
+
}
|
|
8049
|
+
}
|
|
8050
|
+
async function reapGroups(result) {
|
|
8051
|
+
for (const { pgid, child } of groups) {
|
|
8052
|
+
const target = String(pgid);
|
|
8053
|
+
if (process.platform === "win32") {
|
|
8054
|
+
try {
|
|
8055
|
+
if (child.exitCode === null) child.kill();
|
|
8056
|
+
result.unverified.push(target);
|
|
8057
|
+
} catch (err) {
|
|
8058
|
+
result.failures.push({
|
|
8059
|
+
target,
|
|
8060
|
+
error: (err instanceof Error ? err.message : String(err)).slice(0, 200)
|
|
8061
|
+
});
|
|
8062
|
+
}
|
|
8063
|
+
continue;
|
|
8064
|
+
}
|
|
8065
|
+
const first = processGroupMembers(pgid);
|
|
8066
|
+
if (!first.ok) {
|
|
8067
|
+
result.failures.push({
|
|
8068
|
+
target,
|
|
8069
|
+
error: `could not enumerate the process group: ${first.error}`
|
|
8070
|
+
});
|
|
8071
|
+
continue;
|
|
8072
|
+
}
|
|
8073
|
+
if (first.members.length === 0) {
|
|
8074
|
+
result.reaped.push(target);
|
|
8075
|
+
continue;
|
|
8076
|
+
}
|
|
8077
|
+
for (const pid of first.members) {
|
|
8078
|
+
try {
|
|
8079
|
+
process.kill(pid, "SIGTERM");
|
|
8080
|
+
} catch {
|
|
8081
|
+
}
|
|
8082
|
+
}
|
|
8083
|
+
await sleep2(fallbackGraceMs);
|
|
8084
|
+
const afterTerm = processGroupMembers(pgid);
|
|
8085
|
+
if (!afterTerm.ok) {
|
|
8086
|
+
result.failures.push({
|
|
8087
|
+
target,
|
|
8088
|
+
error: `could not re-enumerate the process group after SIGTERM: ${afterTerm.error}`
|
|
8089
|
+
});
|
|
8090
|
+
continue;
|
|
8091
|
+
}
|
|
8092
|
+
for (const pid of afterTerm.members) {
|
|
8093
|
+
try {
|
|
8094
|
+
process.kill(pid, "SIGKILL");
|
|
8095
|
+
} catch {
|
|
8096
|
+
}
|
|
8097
|
+
}
|
|
8098
|
+
const afterKill = processGroupMembers(pgid);
|
|
8099
|
+
if (!afterKill.ok) {
|
|
8100
|
+
result.failures.push({
|
|
8101
|
+
target,
|
|
8102
|
+
error: `could not confirm the process group is empty after SIGKILL: ${afterKill.error}`
|
|
8103
|
+
});
|
|
8104
|
+
continue;
|
|
8105
|
+
}
|
|
8106
|
+
if (afterKill.members.length)
|
|
8107
|
+
result.failures.push({
|
|
8108
|
+
target,
|
|
8109
|
+
error: `${afterKill.members.length} process(es) still in the group after SIGKILL`
|
|
8110
|
+
});
|
|
8111
|
+
else result.reaped.push(target);
|
|
8112
|
+
}
|
|
8113
|
+
}
|
|
8114
|
+
return {
|
|
8115
|
+
get kind() {
|
|
8116
|
+
return kind;
|
|
8117
|
+
},
|
|
8118
|
+
spawn(options) {
|
|
8119
|
+
return resolveKind(options.env) === "systemd-scope" ? spawnInScope(options) : spawnInGroup(options);
|
|
8120
|
+
},
|
|
8121
|
+
scopePrefix(env) {
|
|
8122
|
+
if (resolveKind(env) !== "systemd-scope") return null;
|
|
8123
|
+
const unit = turnScopeUnit(turnId, attempt++);
|
|
8124
|
+
scopeUnits.push({ unit, env });
|
|
8125
|
+
deps.log?.("containment: a harness will start in a systemd scope", { unit });
|
|
8126
|
+
return ["systemd-run", ...scopeArgs(unit)];
|
|
8127
|
+
},
|
|
8128
|
+
async reap() {
|
|
8129
|
+
const result = {
|
|
8130
|
+
kind: kind ?? "unused",
|
|
8131
|
+
reaped: [],
|
|
8132
|
+
failures: [],
|
|
8133
|
+
unverified: []
|
|
8134
|
+
};
|
|
8135
|
+
await reapScopes(result);
|
|
8136
|
+
await reapGroups(result);
|
|
8137
|
+
deps.log?.("containment: reap complete", {
|
|
8138
|
+
kind: result.kind,
|
|
8139
|
+
reaped: result.reaped,
|
|
8140
|
+
failures: result.failures
|
|
8141
|
+
});
|
|
8142
|
+
return result;
|
|
8143
|
+
}
|
|
8144
|
+
};
|
|
8145
|
+
}
|
|
8146
|
+
|
|
8147
|
+
// src/runner/protocol.ts
|
|
8148
|
+
import { z as z14 } from "zod";
|
|
8149
|
+
|
|
8150
|
+
// src/run-config.ts
|
|
8151
|
+
import { z as z13 } from "zod";
|
|
8152
|
+
var mcpStdioServerSchema = z13.object({
|
|
8153
|
+
type: z13.literal("stdio").optional(),
|
|
8154
|
+
command: z13.string().min(1),
|
|
8155
|
+
args: z13.array(z13.string()).optional(),
|
|
8156
|
+
env: z13.record(z13.string(), z13.string()).optional()
|
|
8157
|
+
});
|
|
8158
|
+
var mcpHttpServerSchema = z13.object({
|
|
8159
|
+
type: z13.literal("http"),
|
|
8160
|
+
url: z13.string().url(),
|
|
8161
|
+
headers: z13.record(z13.string(), z13.string()).optional()
|
|
8162
|
+
});
|
|
8163
|
+
var mcpSseServerSchema = z13.object({
|
|
8164
|
+
type: z13.literal("sse"),
|
|
8165
|
+
url: z13.string().url(),
|
|
8166
|
+
headers: z13.record(z13.string(), z13.string()).optional()
|
|
8167
|
+
});
|
|
8168
|
+
var mcpServerDefSchema = z13.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
|
|
8169
|
+
var thinkingConfigSchema = z13.discriminatedUnion("type", [
|
|
8170
|
+
z13.object({ type: z13.literal("adaptive") }),
|
|
8171
|
+
z13.object({ type: z13.literal("enabled"), budgetTokens: z13.number().int().positive().optional() }),
|
|
8172
|
+
z13.object({ type: z13.literal("disabled") })
|
|
8173
|
+
]);
|
|
8174
|
+
var effortSchema = z13.enum(["low", "medium", "high", "xhigh", "max"]);
|
|
8175
|
+
var runConfigSchema = z13.object({
|
|
8176
|
+
// CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
|
|
8177
|
+
// trio + its custom tool lists — `true` grants the host filesystem/shell, absent
|
|
8178
|
+
// is the locked surface. Kept in lockstep with `@cabane/shared`'s
|
|
8179
|
+
// `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
|
|
8180
|
+
// stripped, so an older companion riding a newer server never rejects the config).
|
|
8181
|
+
hostAccess: z13.boolean().optional(),
|
|
8182
|
+
mcpServers: z13.record(z13.string(), mcpServerDefSchema).optional(),
|
|
8183
|
+
model: z13.string().min(1).optional(),
|
|
8184
|
+
thinking: thinkingConfigSchema.optional(),
|
|
8185
|
+
effort: effortSchema.optional()
|
|
8186
|
+
});
|
|
8187
|
+
var EMPTY_RUN_CONFIG = {};
|
|
8188
|
+
function parseRunConfig(raw, onWarn) {
|
|
8189
|
+
const result = runConfigSchema.safeParse(raw ?? {});
|
|
8190
|
+
if (result.success) return result.data;
|
|
8191
|
+
onWarn?.(
|
|
8192
|
+
`run-config from Cabane didn't parse (${result.error.issues.map((i) => i.path.join(".") || "(root)").join(", ")}) \u2014 falling back to the locked assistant default`
|
|
8193
|
+
);
|
|
8194
|
+
return EMPTY_RUN_CONFIG;
|
|
8195
|
+
}
|
|
8196
|
+
|
|
8197
|
+
// src/runner/protocol.ts
|
|
8198
|
+
var RUNNER_ENV = {
|
|
8199
|
+
// The wire the runner speaks.
|
|
8200
|
+
url: "CABANE_URL",
|
|
8201
|
+
// The dispatch as the device stream delivered it.
|
|
8202
|
+
dispatch: "CABANE_DISPATCH",
|
|
8203
|
+
// The turn id the launcher resolved, and whether this is a replay of a turn
|
|
8204
|
+
// an earlier process started. The seq floor for a replay is derived inside
|
|
8205
|
+
// the turn from the outbox and the server, as it always was.
|
|
8206
|
+
turnId: "CABANE_TURN_ID",
|
|
8207
|
+
resumed: "CABANE_RESUMED",
|
|
8208
|
+
// Everything else the turn is constructed from, as JSON: see `RunnerSpec`.
|
|
8209
|
+
spec: "CABANE_RUNNER_SPEC"
|
|
8210
|
+
};
|
|
8211
|
+
var runtimeHealthSchema = z14.object({
|
|
8212
|
+
quotaState: z14.enum(["ok", "limited", "auth_expired", "unknown"]),
|
|
8213
|
+
limitedUntil: z14.string().nullable(),
|
|
8214
|
+
lastError: z14.string().nullable()
|
|
8215
|
+
});
|
|
8216
|
+
var runnerSpecSchema = z14.object({
|
|
8217
|
+
deviceId: z14.string().optional(),
|
|
8218
|
+
// The launcher's boot id: the one its heartbeat carries, so a runner's lease
|
|
8219
|
+
// matches the device that holds it.
|
|
8220
|
+
bootId: z14.string(),
|
|
8221
|
+
workspaceId: z14.string(),
|
|
8222
|
+
workspaceSlug: z14.string(),
|
|
8223
|
+
agentId: z14.string(),
|
|
8224
|
+
agentUsername: z14.string(),
|
|
8225
|
+
agentName: z14.string(),
|
|
8226
|
+
local: localAgentConfigSchema,
|
|
8227
|
+
runConfig: runConfigSchema,
|
|
8228
|
+
opencodeServerUrl: z14.string().optional(),
|
|
8229
|
+
claudeCodeAvailable: z14.boolean(),
|
|
8230
|
+
codexAvailable: z14.boolean(),
|
|
8231
|
+
idleTimeoutMs: z14.number().int().positive().optional(),
|
|
8232
|
+
totalTimeoutMs: z14.number().int().positive().optional(),
|
|
8233
|
+
leaseRenewalMs: z14.number().int().positive().optional(),
|
|
8234
|
+
preparingRowDelayMs: z14.number().int().nonnegative().optional(),
|
|
8235
|
+
// Whether the turn writes a transcript. The directory is the runner's own
|
|
8236
|
+
// (`transcriptsDir()`), so `CABANE_RUNNER_STATE_DIR` moves it with the outbox.
|
|
8237
|
+
transcripts: z14.boolean(),
|
|
8238
|
+
// Whether the harness starts under the OS sandbox. The launcher sends 'os'
|
|
8239
|
+
// only when this host can make one.
|
|
8240
|
+
sandbox: z14.enum(["os", "none"]),
|
|
8241
|
+
logLevel: z14.string(),
|
|
8242
|
+
// The launcher's connector-health observations, by runtime, as of the start.
|
|
8243
|
+
connectorHealth: z14.record(z14.string(), runtimeHealthSchema),
|
|
8244
|
+
// True when this (conversation, agent) pair was already told its session
|
|
8245
|
+
// could not be saved: the notice is once per pair, not once per process.
|
|
8246
|
+
sessionWriteNoticeClaimed: z14.boolean()
|
|
8247
|
+
});
|
|
8248
|
+
var dispatchSchema = z14.object({
|
|
8249
|
+
type: z14.literal("device:dispatch_requested"),
|
|
8250
|
+
agentId: z14.string(),
|
|
8251
|
+
deviceId: z14.string(),
|
|
8252
|
+
conversationId: z14.string(),
|
|
8253
|
+
messageId: z14.string(),
|
|
8254
|
+
episode: z14.number().int().optional()
|
|
8255
|
+
});
|
|
8256
|
+
var credentialsSchema = z14.object({
|
|
8257
|
+
agentToken: z14.string().min(1),
|
|
8258
|
+
deviceToken: z14.string().min(1).optional()
|
|
8259
|
+
});
|
|
8260
|
+
function runnerEnv(input) {
|
|
8261
|
+
return {
|
|
8262
|
+
[RUNNER_ENV.url]: input.baseUrl,
|
|
8263
|
+
[RUNNER_ENV.dispatch]: JSON.stringify(input.dispatch),
|
|
8264
|
+
[RUNNER_ENV.turnId]: input.turnId,
|
|
8265
|
+
...input.resumed ? { [RUNNER_ENV.resumed]: "1" } : {},
|
|
8266
|
+
[RUNNER_ENV.spec]: JSON.stringify(input.spec)
|
|
8267
|
+
};
|
|
8268
|
+
}
|
|
8269
|
+
function runnerStdin(input) {
|
|
8270
|
+
return `${JSON.stringify({
|
|
8271
|
+
// Phase 1 (CT1680) replaces the agent PAT with a runner credential bound to
|
|
8272
|
+
// this one dispatch; until then the child holds what the in-process turn
|
|
8273
|
+
// held, on the same machine.
|
|
8274
|
+
agentToken: input.agentToken,
|
|
8275
|
+
...input.deviceToken ? { deviceToken: input.deviceToken } : {}
|
|
8276
|
+
})}
|
|
8277
|
+
`;
|
|
8278
|
+
}
|
|
8279
|
+
function encodeLauncherMessage(message) {
|
|
8280
|
+
return `${JSON.stringify(message)}
|
|
8281
|
+
`;
|
|
8282
|
+
}
|
|
8283
|
+
function decodeRunnerEvent(line) {
|
|
8284
|
+
if (!line.startsWith("{")) return null;
|
|
8285
|
+
let value;
|
|
8286
|
+
try {
|
|
8287
|
+
value = JSON.parse(line);
|
|
8288
|
+
} catch {
|
|
8289
|
+
return null;
|
|
8290
|
+
}
|
|
8291
|
+
if (!value || typeof value !== "object" || !("cabaneRunner" in value)) return null;
|
|
8292
|
+
return value;
|
|
8293
|
+
}
|
|
8294
|
+
|
|
8295
|
+
// src/runner/launch.ts
|
|
8296
|
+
function selfRunnerCommand() {
|
|
8297
|
+
const script = process.argv[1];
|
|
8298
|
+
if (!script) throw new Error("runners: cannot tell which script started this companion");
|
|
8299
|
+
return [process.execPath, ...process.execArgv, script, "run-turn"];
|
|
8300
|
+
}
|
|
8301
|
+
function healthSnapshot(store) {
|
|
8302
|
+
const out = {};
|
|
8303
|
+
for (const r of store?.reports() ?? []) {
|
|
8304
|
+
out[r.runtime] = {
|
|
8305
|
+
quotaState: r.quotaState ?? "unknown",
|
|
8306
|
+
limitedUntil: r.limitedUntil ?? null,
|
|
8307
|
+
lastError: r.lastError ?? null
|
|
8308
|
+
};
|
|
8309
|
+
}
|
|
8310
|
+
return out;
|
|
8311
|
+
}
|
|
8312
|
+
function runnerSpec(opts, launch, duties) {
|
|
8313
|
+
return {
|
|
8314
|
+
...opts.deviceId ? { deviceId: opts.deviceId } : {},
|
|
8315
|
+
bootId,
|
|
8316
|
+
workspaceId: opts.workspaceId,
|
|
8317
|
+
workspaceSlug: opts.workspaceSlug,
|
|
8318
|
+
agentId: opts.agentId,
|
|
8319
|
+
agentUsername: opts.agentUsername,
|
|
8320
|
+
agentName: String(opts.log.bindings().agentName ?? opts.agentUsername),
|
|
8321
|
+
local: opts.local,
|
|
8322
|
+
runConfig: opts.runConfig,
|
|
8323
|
+
...opts.opencodeServerUrl ? { opencodeServerUrl: opts.opencodeServerUrl } : {},
|
|
8324
|
+
// Read now, per turn, as the in-process path reads them at adapter selection.
|
|
8325
|
+
claudeCodeAvailable: opts.claudeCodeAvailable?.() ?? true,
|
|
8326
|
+
codexAvailable: opts.codexAvailable?.() ?? false,
|
|
8327
|
+
...opts.idleTimeoutMs !== void 0 ? { idleTimeoutMs: opts.idleTimeoutMs } : {},
|
|
8328
|
+
...opts.totalTimeoutMs !== void 0 ? { totalTimeoutMs: opts.totalTimeoutMs } : {},
|
|
8329
|
+
...opts.leaseRenewalMs !== void 0 ? { leaseRenewalMs: opts.leaseRenewalMs } : {},
|
|
8330
|
+
...opts.preparingRowDelayMs !== void 0 ? { preparingRowDelayMs: opts.preparingRowDelayMs } : {},
|
|
8331
|
+
transcripts: opts.transcriptDir !== void 0,
|
|
8332
|
+
sandbox: launch.sandbox ?? "none",
|
|
8333
|
+
logLevel: opts.log.level,
|
|
8334
|
+
connectorHealth: healthSnapshot(launch.connectorHealth),
|
|
8335
|
+
sessionWriteNoticeClaimed: duties.sessionWriteNoticeClaimed()
|
|
8336
|
+
};
|
|
8337
|
+
}
|
|
8338
|
+
var PINO_LEVELS = {
|
|
8339
|
+
10: "trace",
|
|
8340
|
+
20: "debug",
|
|
8341
|
+
30: "info",
|
|
8342
|
+
40: "warn",
|
|
8343
|
+
50: "error",
|
|
8344
|
+
60: "fatal"
|
|
8345
|
+
};
|
|
8346
|
+
function relayRunnerLog(line, log) {
|
|
8347
|
+
if (!line.trim()) return;
|
|
8348
|
+
let record = null;
|
|
8349
|
+
try {
|
|
8350
|
+
const parsed = JSON.parse(line);
|
|
8351
|
+
if (parsed && typeof parsed === "object") record = parsed;
|
|
8352
|
+
} catch {
|
|
8353
|
+
record = null;
|
|
8354
|
+
}
|
|
8355
|
+
if (!record || typeof record.level !== "number") {
|
|
8356
|
+
log.debug({ runnerStderr: line }, "runner: output");
|
|
8357
|
+
return;
|
|
8358
|
+
}
|
|
8359
|
+
const {
|
|
8360
|
+
level,
|
|
8361
|
+
msg,
|
|
8362
|
+
time: _time,
|
|
8363
|
+
pid: _pid,
|
|
8364
|
+
hostname: _hostname,
|
|
8365
|
+
agentName: _agentName,
|
|
8366
|
+
...fields
|
|
8367
|
+
} = record;
|
|
8368
|
+
const method = PINO_LEVELS[level] ?? "info";
|
|
8369
|
+
log[method](fields, typeof msg === "string" ? msg : "");
|
|
8370
|
+
}
|
|
8371
|
+
function lines(stream, onLine) {
|
|
8372
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
8373
|
+
rl.on("line", onLine);
|
|
8374
|
+
return new Promise((resolve2) => rl.once("close", resolve2));
|
|
8375
|
+
}
|
|
8376
|
+
function giveBackLocks(locks, prefix, remote, launch, log) {
|
|
8377
|
+
locks.withdraw(prefix);
|
|
8378
|
+
const stop = launch.stopRemoteSession ?? ((url, id) => stopRemoteOpencodeSession(url, id));
|
|
8379
|
+
for (const { key, owner } of locks.heldBy(prefix)) {
|
|
8380
|
+
const left = remote.filter((r) => opencodeLockKey(r.serverUrl) === key);
|
|
8381
|
+
if (!left.length) {
|
|
8382
|
+
locks.release(owner);
|
|
8383
|
+
continue;
|
|
8384
|
+
}
|
|
8385
|
+
void (async () => {
|
|
8386
|
+
for (let attempt = 1; ; attempt++) {
|
|
8387
|
+
const stopped = await Promise.all(left.map((r) => stop(r.serverUrl, r.sessionId))).catch(
|
|
8388
|
+
() => [false]
|
|
8389
|
+
);
|
|
8390
|
+
if (stopped.every(Boolean)) break;
|
|
8391
|
+
if (attempt === 1)
|
|
8392
|
+
log.warn(
|
|
8393
|
+
{ key, sessions: left.map((r) => r.sessionId) },
|
|
8394
|
+
"runner: a dead runner left work on a shared server that could not be confirmed stopped; holding the server until it is"
|
|
8395
|
+
);
|
|
8396
|
+
await new Promise((resolve2) => setTimeout(resolve2, launch.reconcileRetryMs ?? 5e3));
|
|
8397
|
+
}
|
|
8398
|
+
locks.release(owner);
|
|
8399
|
+
})();
|
|
8400
|
+
}
|
|
8401
|
+
}
|
|
8402
|
+
async function runTurnInRunner(opts, launch, duties, payload, handleOpts) {
|
|
8403
|
+
const startedAt = Date.now();
|
|
8404
|
+
const { turnId } = handleOpts;
|
|
8405
|
+
const log = opts.log.child({
|
|
8406
|
+
workspaceId: opts.workspaceId,
|
|
8407
|
+
conversationId: payload.conversationId,
|
|
8408
|
+
agentId: payload.agentId,
|
|
8409
|
+
messageId: payload.messageId
|
|
8410
|
+
});
|
|
8411
|
+
const controller = new AbortController();
|
|
8412
|
+
duties.registerAbort(turnId, controller);
|
|
8413
|
+
const stopRunner = new AbortController();
|
|
8414
|
+
let ready = false;
|
|
8415
|
+
const forwardStop = () => {
|
|
8416
|
+
if (ready) stopRunner.abort();
|
|
8417
|
+
};
|
|
8418
|
+
controller.signal.addEventListener("abort", forwardStop, { once: true });
|
|
8419
|
+
const attempt = {
|
|
8420
|
+
workspaceId: opts.workspaceId,
|
|
8421
|
+
conversationId: payload.conversationId,
|
|
8422
|
+
agentId: payload.agentId,
|
|
8423
|
+
turnId
|
|
8424
|
+
};
|
|
8425
|
+
let result;
|
|
8426
|
+
let thrown;
|
|
8427
|
+
let code;
|
|
8428
|
+
let handle;
|
|
8429
|
+
let child;
|
|
8430
|
+
const groups = [];
|
|
8431
|
+
const remote = [];
|
|
8432
|
+
const lockOwner = (id) => `${turnId}#${id}`;
|
|
8433
|
+
const locks = launch.locks;
|
|
8434
|
+
try {
|
|
8435
|
+
handle = await launch.provider.acquire(attempt);
|
|
8436
|
+
const env = {};
|
|
8437
|
+
for (const [k, v] of Object.entries(launch.env ?? process.env)) {
|
|
8438
|
+
if (v !== void 0) env[k] = v;
|
|
8439
|
+
}
|
|
8440
|
+
const input = {
|
|
8441
|
+
baseUrl: opts.baseUrl,
|
|
8442
|
+
agentToken: opts.credential,
|
|
8443
|
+
...launch.deviceToken ? { deviceToken: launch.deviceToken } : {},
|
|
8444
|
+
dispatch: payload,
|
|
8445
|
+
turnId,
|
|
8446
|
+
resumed: handleOpts.resumed,
|
|
8447
|
+
spec: runnerSpec(opts, launch, duties)
|
|
8448
|
+
};
|
|
8449
|
+
Object.assign(env, runnerEnv(input));
|
|
8450
|
+
const running = launch.provider.exec(handle, launch.command, env, {
|
|
8451
|
+
signal: stopRunner.signal,
|
|
8452
|
+
stdin: runnerStdin(input)
|
|
8453
|
+
});
|
|
8454
|
+
child = running;
|
|
8455
|
+
log.debug(
|
|
8456
|
+
{ turnId, runnerPid: running.pid, provider: launch.provider.name },
|
|
8457
|
+
"runner: started"
|
|
8458
|
+
);
|
|
8459
|
+
const stdoutDone = lines(running.stdout, (line) => {
|
|
8460
|
+
const event = decodeRunnerEvent(line);
|
|
8461
|
+
if (!event) {
|
|
8462
|
+
log.debug({ runnerStdout: line }, "runner: output");
|
|
8463
|
+
return;
|
|
8464
|
+
}
|
|
8465
|
+
switch (event.cabaneRunner) {
|
|
8466
|
+
case "ready":
|
|
8467
|
+
ready = true;
|
|
8468
|
+
if (controller.signal.aborted) stopRunner.abort();
|
|
8469
|
+
return;
|
|
8470
|
+
case "start":
|
|
8471
|
+
duties.notifyStart({
|
|
8472
|
+
id: event.id,
|
|
8473
|
+
conversationId: event.conversationId,
|
|
8474
|
+
message: event.message
|
|
8475
|
+
});
|
|
8476
|
+
return;
|
|
8477
|
+
case "end": {
|
|
8478
|
+
const { cabaneRunner: _kind, ...info } = event;
|
|
8479
|
+
duties.notifyEnd(info);
|
|
8480
|
+
return;
|
|
8481
|
+
}
|
|
8482
|
+
case "session_write_notice":
|
|
8483
|
+
duties.markSessionWriteNotice();
|
|
8484
|
+
return;
|
|
8485
|
+
case "connector_health":
|
|
8486
|
+
launch.connectorHealth?.recordSettle(event.runtime, {
|
|
8487
|
+
ok: event.ok,
|
|
8488
|
+
errorReason: event.errorReason
|
|
8489
|
+
});
|
|
8490
|
+
return;
|
|
8491
|
+
case "result":
|
|
8492
|
+
result = event.result;
|
|
8493
|
+
return;
|
|
8494
|
+
case "error":
|
|
8495
|
+
thrown = event.message;
|
|
8496
|
+
return;
|
|
8497
|
+
case "lock":
|
|
8498
|
+
if (!locks) {
|
|
8499
|
+
log.warn(
|
|
8500
|
+
{ key: event.key },
|
|
8501
|
+
"runner: asked for a device lock this launcher has none of"
|
|
8502
|
+
);
|
|
8503
|
+
return;
|
|
8504
|
+
}
|
|
8505
|
+
locks.request(
|
|
8506
|
+
event.key,
|
|
8507
|
+
lockOwner(event.id),
|
|
8508
|
+
() => running.stdin.write(encodeLauncherMessage({ cabaneLauncher: "granted", id: event.id }))
|
|
8509
|
+
);
|
|
8510
|
+
return;
|
|
8511
|
+
case "unlock":
|
|
8512
|
+
locks?.release(lockOwner(event.id));
|
|
8513
|
+
return;
|
|
8514
|
+
case "process_group":
|
|
8515
|
+
groups.push(event.pgid);
|
|
8516
|
+
return;
|
|
8517
|
+
case "remote_session":
|
|
8518
|
+
remote.push({ serverUrl: event.serverUrl, sessionId: event.sessionId });
|
|
8519
|
+
return;
|
|
8520
|
+
}
|
|
8521
|
+
});
|
|
8522
|
+
const stderrDone = lines(running.stderr, (line) => relayRunnerLog(line, opts.log));
|
|
8523
|
+
code = await running.exit;
|
|
8524
|
+
await Promise.all([stdoutDone, stderrDone]);
|
|
8525
|
+
} finally {
|
|
8526
|
+
if (locks) giveBackLocks(locks, `${turnId}#`, remote, launch, log);
|
|
8527
|
+
child?.stdin.end();
|
|
8528
|
+
controller.signal.removeEventListener("abort", forwardStop);
|
|
8529
|
+
duties.releaseAbort(turnId, controller);
|
|
8530
|
+
if (handle) await launch.provider.release(handle, { destroy: false });
|
|
8531
|
+
}
|
|
8532
|
+
if (result) return result;
|
|
8533
|
+
const durationMs = Date.now() - startedAt;
|
|
8534
|
+
const reason = thrown !== void 0 ? `runner_failed: ${thrown}` : `runner_exited: ${code}`;
|
|
8535
|
+
await child?.reap(groups);
|
|
8536
|
+
const reaped = await (launch.reapTurn ?? reapTurnScopes)(turnId).catch((err) => ({
|
|
8537
|
+
failures: [err instanceof Error ? err.message : String(err)]
|
|
8538
|
+
}));
|
|
8539
|
+
if (reaped.failures.length) {
|
|
8540
|
+
log.warn(
|
|
8541
|
+
{ turnId, failures: reaped.failures },
|
|
8542
|
+
"runner: couldn't stop what the dead runner's turn left running"
|
|
8543
|
+
);
|
|
8544
|
+
}
|
|
8545
|
+
try {
|
|
8546
|
+
await opts.api.setActiveRun(opts.workspaceId, payload.conversationId, payload.agentId, {
|
|
8547
|
+
activeRunStartedAt: null,
|
|
8548
|
+
turnId,
|
|
8549
|
+
outcome: "failed",
|
|
8550
|
+
errorReason: reason.slice(0, 200),
|
|
8551
|
+
readThrough: payload.messageId
|
|
8552
|
+
});
|
|
8553
|
+
} catch (err) {
|
|
8554
|
+
log.warn(
|
|
8555
|
+
{ ...apiErrorLogFields(err), turnId },
|
|
8556
|
+
"Couldn't update the turn's status in Cabane. Check the conversation before trying again."
|
|
8557
|
+
);
|
|
8558
|
+
}
|
|
8559
|
+
if (thrown !== void 0) throw new Error(thrown);
|
|
8560
|
+
log.warn({ turnId, code }, "runner: exited without reporting a result");
|
|
8561
|
+
duties.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
|
|
8562
|
+
return { ok: false, durationMs, reason };
|
|
8563
|
+
}
|
|
8564
|
+
|
|
8565
|
+
// src/term.ts
|
|
8566
|
+
import { createInterface as createInterface2 } from "readline";
|
|
8567
|
+
import { homedir as homedir2 } from "os";
|
|
8568
|
+
import pc from "picocolors";
|
|
8569
|
+
function tildePath(path) {
|
|
8570
|
+
const home = homedir2();
|
|
8571
|
+
if (home && path.startsWith(home + "/")) return `~${path.slice(home.length)}`;
|
|
8572
|
+
return path;
|
|
8573
|
+
}
|
|
8574
|
+
|
|
8575
|
+
// src/turn-execution.ts
|
|
8576
|
+
import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
|
|
8577
|
+
import { existsSync as existsSync9 } from "fs";
|
|
8578
|
+
|
|
8579
|
+
// src/turn-control-tools.ts
|
|
8580
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
8581
|
+
import { z as z15 } from "zod";
|
|
8582
|
+
var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
|
|
8583
|
+
var COMPANION_LOCAL_TOOL_GLOB = `mcp__${COMPANION_LOCAL_MCP_SERVER}__*`;
|
|
8584
|
+
var SKIP_TURN_TOOL = "skip_turn";
|
|
8585
|
+
var ASK_TOOL = "ask";
|
|
8586
|
+
var ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${ASK_TOOL}`;
|
|
8587
|
+
var WITHDRAW_ASK_TOOL = "withdraw_ask";
|
|
8588
|
+
var WITHDRAW_ASK_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WITHDRAW_ASK_TOOL}`;
|
|
8589
|
+
var WAKE_ME_TOOL = "wake_me";
|
|
8590
|
+
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
8591
|
+
var CANCEL_WAKE_TOOL = "cancel_wake";
|
|
8592
|
+
var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
|
|
8593
|
+
var TURN_ACT_TIMEOUT_MS = 45e3;
|
|
8594
|
+
function createSkipState() {
|
|
8595
|
+
return { skipped: false, reason: null };
|
|
8596
|
+
}
|
|
8597
|
+
function toolError(text) {
|
|
8598
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
8599
|
+
}
|
|
8600
|
+
async function performAct(acts, act) {
|
|
8601
|
+
let result;
|
|
8602
|
+
try {
|
|
8603
|
+
result = await acts.api.turnAct(
|
|
8604
|
+
acts.workspaceId,
|
|
8605
|
+
acts.turnId,
|
|
8606
|
+
act,
|
|
8607
|
+
AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
|
|
8608
|
+
);
|
|
8609
|
+
} catch (err) {
|
|
8610
|
+
if (err instanceof ApiError && err.status >= 500) {
|
|
8611
|
+
return uncertainOutcome(`${err.status}: ${apiErrorMessage(err)}`);
|
|
8612
|
+
}
|
|
8613
|
+
if (err instanceof ApiError) {
|
|
8614
|
+
if (err.status === 409 && apiErrorCode(err) === "no_active_turn") {
|
|
8615
|
+
return toolError(
|
|
8616
|
+
"No active turn: this turn has already settled, so the act was not performed."
|
|
8617
|
+
);
|
|
8618
|
+
}
|
|
8619
|
+
return toolError(`The act was refused (${err.status}): ${apiErrorMessage(err)}`);
|
|
8620
|
+
}
|
|
8621
|
+
return uncertainOutcome(err instanceof Error ? err.message : String(err));
|
|
8622
|
+
}
|
|
8623
|
+
if (!result.ok) return toolError(JSON.stringify(result));
|
|
8624
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
8625
|
+
}
|
|
8626
|
+
function uncertainOutcome(cause) {
|
|
8627
|
+
return toolError(
|
|
8628
|
+
`The outcome of this act could not be confirmed (${cause}). It may already have been performed, with only the response lost. Do NOT simply repeat the call: a repeat is a second, separate act, not a retry. Carry on, and say in your reply that you could not confirm it.`
|
|
8629
|
+
);
|
|
8630
|
+
}
|
|
8631
|
+
function createTurnControlMcpServer(acts, skipState) {
|
|
8632
|
+
const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
|
|
8633
|
+
const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
|
|
8634
|
+
const RECORDER_TOOL = { readOnlyHint: true, openWorldHint: false };
|
|
8635
|
+
return createSdkMcpServer({
|
|
8636
|
+
name: COMPANION_LOCAL_MCP_SERVER,
|
|
8637
|
+
version: "0.0.0",
|
|
8638
|
+
tools: [
|
|
8639
|
+
...skipState ? [
|
|
8640
|
+
tool(
|
|
8641
|
+
SKIP_TURN_TOOL,
|
|
8642
|
+
TURN_CONTROL_TOOL_DESCRIPTIONS.skip_turn,
|
|
8643
|
+
{
|
|
8644
|
+
reason: z15.string().min(1).max(MAX_SKIP_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.skip_turn.reason)
|
|
8645
|
+
},
|
|
8646
|
+
async (args) => {
|
|
8647
|
+
skipState.skipped = true;
|
|
8648
|
+
skipState.reason = args.reason;
|
|
8649
|
+
return {
|
|
8650
|
+
content: [{ type: "text", text: JSON.stringify({ skipped: true }) }]
|
|
8651
|
+
};
|
|
8652
|
+
},
|
|
8653
|
+
{ annotations: RECORDER_TOOL, alwaysLoad: true }
|
|
8654
|
+
)
|
|
8655
|
+
] : [],
|
|
8656
|
+
tool(
|
|
8657
|
+
ASK_TOOL,
|
|
8658
|
+
TURN_CONTROL_TOOL_DESCRIPTIONS.ask,
|
|
8659
|
+
{
|
|
8660
|
+
targetUserId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.targetUserId),
|
|
8661
|
+
question: z15.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.question),
|
|
8662
|
+
headline: z15.string().min(1).max(MAX_ASK_HEADLINE_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.headline),
|
|
8663
|
+
options: z15.array(
|
|
8664
|
+
z15.union([
|
|
8665
|
+
z15.string().min(1).max(MAX_ASK_OPTION_BYTES),
|
|
8666
|
+
z15.object({
|
|
8667
|
+
label: z15.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.label"]),
|
|
8668
|
+
move: z15.object({
|
|
8669
|
+
task: z15.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.task"]),
|
|
8670
|
+
status: z15.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move.status"])
|
|
8671
|
+
}).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["options.move"])
|
|
8672
|
+
})
|
|
8673
|
+
])
|
|
8674
|
+
).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.options),
|
|
8675
|
+
questions: z15.array(
|
|
8676
|
+
z15.object({
|
|
8677
|
+
headline: z15.string().min(1).max(MAX_ASK_HEADLINE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.headline"]),
|
|
8678
|
+
body: z15.string().min(1).max(MAX_ASK_QUESTION_BYTES).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.body"]),
|
|
8679
|
+
options: z15.array(
|
|
8680
|
+
z15.union([
|
|
8681
|
+
z15.string().min(1).max(MAX_ASK_OPTION_BYTES),
|
|
8682
|
+
z15.object({
|
|
8683
|
+
label: z15.string().min(1).max(MAX_ASK_OPTION_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.label"]),
|
|
8684
|
+
move: z15.object({
|
|
8685
|
+
task: z15.string().min(1).max(200).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.task"]),
|
|
8686
|
+
status: z15.enum(TASK_STAGES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move.status"])
|
|
8687
|
+
}).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options.move"])
|
|
8688
|
+
})
|
|
8689
|
+
])
|
|
8690
|
+
).min(MIN_ASK_OPTIONS).max(MAX_ASK_OPTIONS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["questions.options"])
|
|
8691
|
+
})
|
|
8692
|
+
).min(1).max(MAX_ASK_ITEMS).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.questions),
|
|
8693
|
+
replaces: z15.object({
|
|
8694
|
+
askId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.askId"]),
|
|
8695
|
+
reason: z15.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask["replaces.reason"])
|
|
8696
|
+
}).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.ask.replaces)
|
|
8697
|
+
},
|
|
8698
|
+
async (args) => {
|
|
8699
|
+
const hasSingle = args.question !== void 0;
|
|
8700
|
+
const hasArray = args.questions !== void 0 && args.questions.length > 0;
|
|
8701
|
+
if (hasSingle && hasArray) {
|
|
8702
|
+
return toolError(
|
|
8703
|
+
"Provide either `question` (single) or `questions` (array), not both."
|
|
8704
|
+
);
|
|
8705
|
+
}
|
|
8706
|
+
if (!hasSingle && !hasArray) {
|
|
8707
|
+
return toolError("Provide `question` (single) or `questions` (array).");
|
|
8708
|
+
}
|
|
8709
|
+
return performAct(acts, {
|
|
8710
|
+
kind: "ask",
|
|
8711
|
+
callId: randomUUID2(),
|
|
8712
|
+
// CT1350: the swap rides the ask, so the server commits both halves
|
|
8713
|
+
// in one transaction — the person never sees neither or both.
|
|
8714
|
+
...args.replaces ? { replaces: args.replaces } : {},
|
|
8715
|
+
ask: hasArray ? {
|
|
8716
|
+
targetUserId: args.targetUserId,
|
|
8717
|
+
questions: args.questions.map((q) => ({
|
|
8718
|
+
headline: q.headline,
|
|
8719
|
+
...q.body ? { body: q.body } : {},
|
|
8720
|
+
...q.options && q.options.length > 0 ? { options: q.options } : {}
|
|
8721
|
+
}))
|
|
8722
|
+
} : {
|
|
8723
|
+
targetUserId: args.targetUserId,
|
|
8724
|
+
question: args.question,
|
|
8725
|
+
...args.headline ? { headline: args.headline } : {},
|
|
8726
|
+
...args.options && args.options.length > 0 ? { options: args.options } : {}
|
|
8727
|
+
}
|
|
8728
|
+
});
|
|
8729
|
+
},
|
|
8730
|
+
{ annotations: ACT_TOOL, alwaysLoad: true }
|
|
8731
|
+
),
|
|
8732
|
+
tool(
|
|
8733
|
+
WAKE_ME_TOOL,
|
|
8734
|
+
TURN_CONTROL_TOOL_DESCRIPTIONS.wake_me,
|
|
8735
|
+
{
|
|
8736
|
+
afterSeconds: z15.number().int().positive().optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.afterSeconds),
|
|
8737
|
+
at: z15.string().datetime({ offset: true }).optional().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.at),
|
|
8738
|
+
note: z15.string().min(1).max(MAX_WAKE_NOTE_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.wake_me.note)
|
|
8739
|
+
},
|
|
8740
|
+
async (args) => {
|
|
8741
|
+
const hasAfter = args.afterSeconds !== void 0;
|
|
8742
|
+
const hasAt = args.at !== void 0;
|
|
8743
|
+
if (hasAfter && hasAt) {
|
|
8744
|
+
return toolError(
|
|
8745
|
+
"Provide either `afterSeconds` (relative) or `at` (absolute), not both."
|
|
8746
|
+
);
|
|
8747
|
+
}
|
|
8748
|
+
if (!hasAfter && !hasAt) {
|
|
8749
|
+
return toolError("Provide `afterSeconds` or `at`.");
|
|
8750
|
+
}
|
|
8751
|
+
return performAct(acts, {
|
|
8752
|
+
kind: "wake",
|
|
8753
|
+
callId: randomUUID2(),
|
|
8754
|
+
wake: {
|
|
8755
|
+
...hasAfter ? { afterSeconds: args.afterSeconds } : {},
|
|
8756
|
+
...hasAt ? { at: args.at } : {},
|
|
8757
|
+
note: args.note
|
|
8758
|
+
}
|
|
8759
|
+
});
|
|
8760
|
+
},
|
|
8761
|
+
{ annotations: ACT_TOOL, alwaysLoad: true }
|
|
8762
|
+
),
|
|
8763
|
+
// CT1350: the standalone withdrawal. Not annotated destructive — an ask
|
|
8764
|
+
// is a question, not data, and a harness that gated this on a live
|
|
8765
|
+
// operator would hang a turn nobody is watching (the `cancel_wake`
|
|
8766
|
+
// reasoning). Not idempotent either: a second call with the same id is
|
|
8767
|
+
// refused with `ask_not_open`, which is information, not a no-op.
|
|
8768
|
+
tool(
|
|
8769
|
+
WITHDRAW_ASK_TOOL,
|
|
8770
|
+
TURN_CONTROL_TOOL_DESCRIPTIONS.withdraw_ask,
|
|
8771
|
+
{
|
|
8772
|
+
askId: z15.string().uuid().describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.askId),
|
|
8773
|
+
reason: z15.string().min(1).max(MAX_WITHDRAW_REASON_BYTES).describe(TURN_CONTROL_FIELD_DESCRIPTIONS.withdraw_ask.reason)
|
|
8774
|
+
},
|
|
8775
|
+
async (args) => performAct(acts, {
|
|
8776
|
+
kind: "withdraw_ask",
|
|
8777
|
+
callId: randomUUID2(),
|
|
8778
|
+
withdraw: { askId: args.askId, reason: args.reason }
|
|
8779
|
+
}),
|
|
8780
|
+
{ annotations: ACT_TOOL, alwaysLoad: true }
|
|
8781
|
+
),
|
|
8782
|
+
// CT990: the inverse. No arguments — it can only ever stand down the
|
|
8783
|
+
// caller's own wake in the conversation it's holding a turn in.
|
|
8784
|
+
tool(
|
|
8785
|
+
CANCEL_WAKE_TOOL,
|
|
8786
|
+
TURN_CONTROL_TOOL_DESCRIPTIONS.cancel_wake,
|
|
8787
|
+
{},
|
|
8788
|
+
async () => performAct(acts, { kind: "cancel_wake", callId: randomUUID2() }),
|
|
8789
|
+
{ annotations: CANCEL_TOOL, alwaysLoad: true }
|
|
8790
|
+
)
|
|
8791
|
+
]
|
|
8792
|
+
});
|
|
8793
|
+
}
|
|
8794
|
+
|
|
8795
|
+
// src/build-options.ts
|
|
8796
|
+
function cabaneMcpUrl(baseUrl) {
|
|
8797
|
+
return `${trimSlash2(baseUrl)}/api/mcp`;
|
|
8798
|
+
}
|
|
8799
|
+
function turnControlMcpUrl(baseUrl) {
|
|
8800
|
+
return `${trimSlash2(baseUrl)}/api/turn-control`;
|
|
8801
|
+
}
|
|
8802
|
+
function buildCompanionTurnRequest(params) {
|
|
8803
|
+
const { turnContext: t } = params;
|
|
8804
|
+
return {
|
|
8805
|
+
systemPrompt: t.systemPrompt,
|
|
8806
|
+
prompt: t.prompt,
|
|
8807
|
+
content: t.content,
|
|
8808
|
+
config: t.config,
|
|
8809
|
+
policy: t.policy,
|
|
8810
|
+
session: t.session,
|
|
8811
|
+
cabane: {
|
|
8812
|
+
mcpUrl: cabaneMcpUrl(params.baseUrl),
|
|
8813
|
+
bearer: params.turnToken,
|
|
8066
8814
|
activeConversationId: params.activeConversationId,
|
|
8067
8815
|
workspaceId: params.workspaceId,
|
|
8068
8816
|
turnControlUrl: turnControlMcpUrl(params.baseUrl)
|
|
@@ -8137,7 +8885,7 @@ function readOutboxFloor(read, turnId, log) {
|
|
|
8137
8885
|
}
|
|
8138
8886
|
|
|
8139
8887
|
// src/prepare-failure.ts
|
|
8140
|
-
import { spawn as
|
|
8888
|
+
import { spawn as spawn5 } from "child_process";
|
|
8141
8889
|
import { hostname } from "os";
|
|
8142
8890
|
function prepareFailureCause(error) {
|
|
8143
8891
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -8175,7 +8923,7 @@ async function runPrepareWithReporting(runner, hook, input, context, reportError
|
|
|
8175
8923
|
}
|
|
8176
8924
|
function reportFailure(config, body) {
|
|
8177
8925
|
return new Promise((resolve2, reject) => {
|
|
8178
|
-
const child =
|
|
8926
|
+
const child = spawn5(config.command, config.args ?? [], {
|
|
8179
8927
|
stdio: ["pipe", "ignore", "ignore"],
|
|
8180
8928
|
env: { ...process.env, ...config.env }
|
|
8181
8929
|
});
|
|
@@ -8199,7 +8947,7 @@ function reportFailure(config, body) {
|
|
|
8199
8947
|
}
|
|
8200
8948
|
|
|
8201
8949
|
// src/prepared.ts
|
|
8202
|
-
import { mkdirSync as mkdirSync7, readFileSync as
|
|
8950
|
+
import { mkdirSync as mkdirSync7, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "fs";
|
|
8203
8951
|
import { join as join11 } from "path";
|
|
8204
8952
|
function dirFor(workspaceId) {
|
|
8205
8953
|
return join11(cabaneDir(), "prepared", encodeURIComponent(workspaceId));
|
|
@@ -8214,7 +8962,7 @@ function readPrepared(workspaceId, conversationId, agentId) {
|
|
|
8214
8962
|
const path = pathFor2(workspaceId, conversationId, agentId);
|
|
8215
8963
|
if (!existsSync7(path)) return null;
|
|
8216
8964
|
try {
|
|
8217
|
-
const parsed = JSON.parse(
|
|
8965
|
+
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
8218
8966
|
if (parsed && typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
|
|
8219
8967
|
return {
|
|
8220
8968
|
cwd: parsed.cwd,
|
|
@@ -8239,20 +8987,20 @@ function clearPrepared(workspaceId, conversationId, agentId) {
|
|
|
8239
8987
|
}
|
|
8240
8988
|
|
|
8241
8989
|
// src/secrets.ts
|
|
8242
|
-
import { existsSync as existsSync8, readFileSync as
|
|
8990
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
|
|
8243
8991
|
import { join as join12 } from "path";
|
|
8244
|
-
import { z as
|
|
8992
|
+
import { z as z16 } from "zod";
|
|
8245
8993
|
var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
8246
8994
|
function secretsPath() {
|
|
8247
8995
|
return join12(cabaneDir(), "secrets.json");
|
|
8248
8996
|
}
|
|
8249
|
-
var secretStoreSchema =
|
|
8997
|
+
var secretStoreSchema = z16.record(z16.string(), z16.string());
|
|
8250
8998
|
function loadSecretStore() {
|
|
8251
8999
|
const path = secretsPath();
|
|
8252
9000
|
if (!existsSync8(path)) return makeStore({});
|
|
8253
9001
|
let raw;
|
|
8254
9002
|
try {
|
|
8255
|
-
raw =
|
|
9003
|
+
raw = readFileSync7(path, "utf8");
|
|
8256
9004
|
} catch (err) {
|
|
8257
9005
|
throw new ConfigError(
|
|
8258
9006
|
`couldn't read ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -8333,7 +9081,7 @@ function resolveMcpSecrets(mcpServers, store) {
|
|
|
8333
9081
|
import { appendFileSync, chmodSync as chmodSync4, copyFileSync, mkdirSync as mkdirSync8, readdirSync, rmSync as rmSync6 } from "fs";
|
|
8334
9082
|
import { basename, dirname as dirname6, join as join13 } from "path";
|
|
8335
9083
|
function transcriptsDir() {
|
|
8336
|
-
return join13(
|
|
9084
|
+
return join13(runnerStateDir(), "transcripts");
|
|
8337
9085
|
}
|
|
8338
9086
|
var RETAIN = 200;
|
|
8339
9087
|
var ANOMALY_RETAIN = 50;
|
|
@@ -8343,344 +9091,88 @@ var TranscriptWriter = class {
|
|
|
8343
9091
|
onWarn;
|
|
8344
9092
|
constructor(dir2, meta, onWarn) {
|
|
8345
9093
|
this.onWarn = onWarn;
|
|
8346
|
-
this.path = join13(dir2, fileName(meta));
|
|
8347
|
-
try {
|
|
8348
|
-
mkdirSync8(dir2, { recursive: true });
|
|
8349
|
-
try {
|
|
8350
|
-
chmodSync4(dir2, 448);
|
|
8351
|
-
} catch {
|
|
8352
|
-
}
|
|
8353
|
-
pruneOld(dir2, RETAIN);
|
|
8354
|
-
} catch (err) {
|
|
8355
|
-
this.fail(err);
|
|
8356
|
-
}
|
|
8357
|
-
this.line({ type: "_meta", ...meta });
|
|
8358
|
-
}
|
|
8359
|
-
// Append a raw SDK message verbatim.
|
|
8360
|
-
write(msg) {
|
|
8361
|
-
this.line(msg);
|
|
8362
|
-
}
|
|
8363
|
-
// Append the outcome footer.
|
|
8364
|
-
close(outcome) {
|
|
8365
|
-
this.line({ type: "_outcome", ...outcome });
|
|
8366
|
-
}
|
|
8367
|
-
// CT1145: keep an independent copy of an empty successful envelope after the
|
|
8368
|
-
// footer has landed. The ordinary 200-file FIFO keeps rotating as before;
|
|
8369
|
-
// this bucket retains the newest 50 anomalies regardless of ordinary traffic.
|
|
8370
|
-
// Best-effort like every other transcript operation: observability can never
|
|
8371
|
-
// fail the turn.
|
|
8372
|
-
preserveAnomaly() {
|
|
8373
|
-
if (this.broken) return;
|
|
8374
|
-
try {
|
|
8375
|
-
const dir2 = join13(dirname6(this.path), "anomalies");
|
|
8376
|
-
mkdirSync8(dir2, { recursive: true, mode: 448 });
|
|
8377
|
-
chmodSync4(dir2, 448);
|
|
8378
|
-
const target = join13(dir2, basename(this.path));
|
|
8379
|
-
copyFileSync(this.path, target);
|
|
8380
|
-
chmodSync4(target, 384);
|
|
8381
|
-
pruneOld(dir2, ANOMALY_RETAIN);
|
|
8382
|
-
} catch (err) {
|
|
8383
|
-
this.fail(err);
|
|
8384
|
-
}
|
|
8385
|
-
}
|
|
8386
|
-
line(obj) {
|
|
8387
|
-
if (this.broken) return;
|
|
8388
|
-
try {
|
|
8389
|
-
appendFileSync(this.path, JSON.stringify(obj) + "\n", { mode: 384 });
|
|
8390
|
-
} catch (err) {
|
|
8391
|
-
this.fail(err);
|
|
8392
|
-
}
|
|
8393
|
-
}
|
|
8394
|
-
fail(err) {
|
|
8395
|
-
if (this.broken) return;
|
|
8396
|
-
this.broken = true;
|
|
8397
|
-
this.onWarn?.(`transcript write failed (${err instanceof Error ? err.message : String(err)})`);
|
|
8398
|
-
}
|
|
8399
|
-
};
|
|
8400
|
-
function fileName(meta) {
|
|
8401
|
-
const d = new Date(meta.ts);
|
|
8402
|
-
const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
8403
|
-
const slug = safe2(meta.workspaceSlug) || "ws";
|
|
8404
|
-
const short = meta.dispatchId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "turn";
|
|
8405
|
-
return `${stamp}-${slug}-${short}.jsonl`;
|
|
8406
|
-
}
|
|
8407
|
-
function pad(n) {
|
|
8408
|
-
return String(n).padStart(2, "0");
|
|
8409
|
-
}
|
|
8410
|
-
function safe2(s) {
|
|
8411
|
-
return s.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
8412
|
-
}
|
|
8413
|
-
function pruneOld(dir2, retain) {
|
|
8414
|
-
let files;
|
|
8415
|
-
try {
|
|
8416
|
-
files = readdirSync(dir2).filter((f) => f.endsWith(".jsonl"));
|
|
8417
|
-
} catch {
|
|
8418
|
-
return;
|
|
8419
|
-
}
|
|
8420
|
-
if (files.length <= retain) return;
|
|
8421
|
-
const drop = files.sort().slice(0, files.length - retain);
|
|
8422
|
-
for (const f of drop) {
|
|
8423
|
-
try {
|
|
8424
|
-
rmSync6(join13(dir2, f), { force: true });
|
|
8425
|
-
} catch {
|
|
8426
|
-
}
|
|
8427
|
-
}
|
|
8428
|
-
}
|
|
8429
|
-
|
|
8430
|
-
// src/turn-containment.ts
|
|
8431
|
-
import { execFile, execFileSync, spawn as spawn5 } from "child_process";
|
|
8432
|
-
import { readFileSync as readFileSync7 } from "fs";
|
|
8433
|
-
import { promisify } from "util";
|
|
8434
|
-
var execFileAsync = promisify(execFile);
|
|
8435
|
-
var asContained = (child) => child;
|
|
8436
|
-
var CONTAINMENT_STOP_GRACE_SEC = 5;
|
|
8437
|
-
var FALLBACK_GRACE_MS = CONTAINMENT_STOP_GRACE_SEC * 1e3;
|
|
8438
|
-
var CAPABILITY_PROBE_TIMEOUT_MS = 4e3;
|
|
8439
|
-
function turnScopeUnit(turnId, attempt) {
|
|
8440
|
-
const safe3 = turnId.replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
8441
|
-
return `cabane-turn-${safe3}-${attempt}`;
|
|
8442
|
-
}
|
|
8443
|
-
function scopeCapabilityUsable(env, timeoutMs = CAPABILITY_PROBE_TIMEOUT_MS) {
|
|
8444
|
-
if (process.platform !== "linux") return false;
|
|
8445
|
-
try {
|
|
8446
|
-
execFileSync("systemd-run", ["--user", "--scope", "--quiet", "--collect", "--", "/bin/true"], {
|
|
8447
|
-
env,
|
|
8448
|
-
timeout: timeoutMs,
|
|
8449
|
-
stdio: "ignore"
|
|
8450
|
-
});
|
|
8451
|
-
return true;
|
|
8452
|
-
} catch {
|
|
8453
|
-
return false;
|
|
8454
|
-
}
|
|
8455
|
-
}
|
|
8456
|
-
function classifyStopError(message) {
|
|
8457
|
-
if (/failed to connect to bus|failed to get d-?bus connection|connection refused|refusing to operate|spawn systemctl|systemctl.*ENOENT|ENOENT.*systemctl/i.test(
|
|
8458
|
-
message
|
|
8459
|
-
)) {
|
|
8460
|
-
return "failure";
|
|
8461
|
-
}
|
|
8462
|
-
if (/not loaded|could not be found|no such unit|unit .* not found/i.test(message))
|
|
8463
|
-
return "absent";
|
|
8464
|
-
return "failure";
|
|
8465
|
-
}
|
|
8466
|
-
function currentSystemdUnit(cgroupText) {
|
|
8467
|
-
for (const line of cgroupText.split("\n")) {
|
|
8468
|
-
const path = line.trim().split(":").pop();
|
|
8469
|
-
if (!path?.startsWith("/")) continue;
|
|
8470
|
-
const leaf = path.split("/").filter(Boolean).pop();
|
|
8471
|
-
if (leaf && (leaf.endsWith(".service") || leaf.endsWith(".scope"))) return leaf;
|
|
8472
|
-
}
|
|
8473
|
-
return null;
|
|
8474
|
-
}
|
|
8475
|
-
function readCurrentSystemdUnit() {
|
|
8476
|
-
try {
|
|
8477
|
-
return currentSystemdUnit(readFileSync7("/proc/self/cgroup", "utf8"));
|
|
8478
|
-
} catch {
|
|
8479
|
-
return null;
|
|
8480
|
-
}
|
|
8481
|
-
}
|
|
8482
|
-
function processGroupMembers(pgid) {
|
|
8483
|
-
try {
|
|
8484
|
-
const out = execFileSync("ps", ["-eo", "pid=,pgid="], { timeout: 5e3 }).toString();
|
|
8485
|
-
return {
|
|
8486
|
-
ok: true,
|
|
8487
|
-
members: out.split("\n").map((line) => /^\s*(\d+)\s+(\d+)\s*$/.exec(line)).filter((m) => m !== null).filter((m) => Number(m[2]) === pgid && Number(m[1]) !== process.pid).map((m) => Number(m[1]))
|
|
8488
|
-
};
|
|
8489
|
-
} catch (err) {
|
|
8490
|
-
return { ok: false, error: (err instanceof Error ? err.message : String(err)).slice(0, 150) };
|
|
8491
|
-
}
|
|
8492
|
-
}
|
|
8493
|
-
var sleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
8494
|
-
var MANAGER_ENV_KEYS = ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "PATH"];
|
|
8495
|
-
var managerEnvKey = (env) => MANAGER_ENV_KEYS.map((k) => `${k}=${env[k] ?? ""}`).join("\0");
|
|
8496
|
-
function createTurnContainment(turnId, deps = {}) {
|
|
8497
|
-
const probe = deps.capabilityProbe ?? scopeCapabilityUsable;
|
|
8498
|
-
const fallbackGraceMs = deps.fallbackGraceMs ?? FALLBACK_GRACE_MS;
|
|
8499
|
-
const ownUnit = (deps.ownSystemdUnit ?? readCurrentSystemdUnit)();
|
|
8500
|
-
const scopeUnits = [];
|
|
8501
|
-
const groups = [];
|
|
8502
|
-
let kind = null;
|
|
8503
|
-
let probedEnvKey = null;
|
|
8504
|
-
let attempt = 0;
|
|
8505
|
-
function spawnInScope(options) {
|
|
8506
|
-
const unit = turnScopeUnit(turnId, attempt++);
|
|
8507
|
-
scopeUnits.push({ unit, env: options.env });
|
|
8508
|
-
deps.log?.("containment: spawning the turn CLI into a systemd scope", {
|
|
8509
|
-
unit,
|
|
8510
|
-
partOf: ownUnit,
|
|
8511
|
-
command: options.command
|
|
8512
|
-
});
|
|
8513
|
-
return asContained(
|
|
8514
|
-
spawn5(
|
|
8515
|
-
"systemd-run",
|
|
8516
|
-
[
|
|
8517
|
-
"--user",
|
|
8518
|
-
"--scope",
|
|
8519
|
-
"--quiet",
|
|
8520
|
-
"--collect",
|
|
8521
|
-
`--unit=${unit}`,
|
|
8522
|
-
`--property=TimeoutStopSec=${CONTAINMENT_STOP_GRACE_SEC}s`,
|
|
8523
|
-
// Bind the scope's life to the companion's own unit, so stopping or
|
|
8524
|
-
// restarting the companion still takes its in-flight turns with it —
|
|
8525
|
-
// which is what happens today, and what the scope would otherwise undo.
|
|
8526
|
-
// Omitted outside a unit, where there is nothing to bind to.
|
|
8527
|
-
...ownUnit ? [`--property=PartOf=${ownUnit}`] : [],
|
|
8528
|
-
"--",
|
|
8529
|
-
options.command,
|
|
8530
|
-
...options.args
|
|
8531
|
-
],
|
|
8532
|
-
{
|
|
8533
|
-
...options.cwd ? { cwd: options.cwd } : {},
|
|
8534
|
-
env: options.env,
|
|
8535
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
8536
|
-
}
|
|
8537
|
-
)
|
|
8538
|
-
);
|
|
8539
|
-
}
|
|
8540
|
-
function spawnInGroup(options) {
|
|
8541
|
-
const child = spawn5(options.command, options.args, {
|
|
8542
|
-
...options.cwd ? { cwd: options.cwd } : {},
|
|
8543
|
-
env: options.env,
|
|
8544
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
8545
|
-
detached: process.platform !== "win32"
|
|
8546
|
-
});
|
|
8547
|
-
if (child.pid !== void 0) groups.push({ pgid: child.pid, child });
|
|
8548
|
-
deps.log?.("containment: no host-owned containment here \u2014 best effort only", {
|
|
8549
|
-
platform: process.platform,
|
|
8550
|
-
pid: child.pid
|
|
8551
|
-
});
|
|
8552
|
-
return asContained(child);
|
|
8553
|
-
}
|
|
8554
|
-
async function reapScopes(result) {
|
|
8555
|
-
for (const { unit, env } of scopeUnits) {
|
|
8556
|
-
try {
|
|
8557
|
-
await execFileAsync("systemctl", ["--user", "stop", `${unit}.scope`], {
|
|
8558
|
-
env,
|
|
8559
|
-
timeout: (CONTAINMENT_STOP_GRACE_SEC + 10) * 1e3
|
|
8560
|
-
});
|
|
8561
|
-
} catch (err) {
|
|
8562
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
8563
|
-
if (classifyStopError(message) === "failure") {
|
|
8564
|
-
result.failures.push({ target: unit, error: message.slice(0, 200) });
|
|
8565
|
-
continue;
|
|
8566
|
-
}
|
|
8567
|
-
result.reaped.push(unit);
|
|
8568
|
-
continue;
|
|
8569
|
-
}
|
|
8570
|
-
try {
|
|
8571
|
-
const { stdout } = await execFileAsync(
|
|
8572
|
-
"systemctl",
|
|
8573
|
-
["--user", "show", `${unit}.scope`, "--property=ActiveState", "--value"],
|
|
8574
|
-
{ env, timeout: 1e4 }
|
|
8575
|
-
);
|
|
8576
|
-
const state = stdout.trim();
|
|
8577
|
-
if (state === "" || state === "inactive" || state === "failed") result.reaped.push(unit);
|
|
8578
|
-
else result.failures.push({ target: unit, error: `scope still ${state} after stop` });
|
|
8579
|
-
} catch (err) {
|
|
8580
|
-
result.failures.push({
|
|
8581
|
-
target: unit,
|
|
8582
|
-
error: `could not confirm the scope stopped: ${(err instanceof Error ? err.message : String(err)).slice(0, 150)}`
|
|
8583
|
-
});
|
|
9094
|
+
this.path = join13(dir2, fileName(meta));
|
|
9095
|
+
try {
|
|
9096
|
+
mkdirSync8(dir2, { recursive: true });
|
|
9097
|
+
try {
|
|
9098
|
+
chmodSync4(dir2, 448);
|
|
9099
|
+
} catch {
|
|
8584
9100
|
}
|
|
9101
|
+
pruneOld(dir2, RETAIN);
|
|
9102
|
+
} catch (err) {
|
|
9103
|
+
this.fail(err);
|
|
8585
9104
|
}
|
|
9105
|
+
this.line({ type: "_meta", ...meta });
|
|
8586
9106
|
}
|
|
8587
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
8612
|
-
continue;
|
|
8613
|
-
}
|
|
8614
|
-
for (const pid of first.members) {
|
|
8615
|
-
try {
|
|
8616
|
-
process.kill(pid, "SIGTERM");
|
|
8617
|
-
} catch {
|
|
8618
|
-
}
|
|
8619
|
-
}
|
|
8620
|
-
await sleep2(fallbackGraceMs);
|
|
8621
|
-
const afterTerm = processGroupMembers(pgid);
|
|
8622
|
-
if (!afterTerm.ok) {
|
|
8623
|
-
result.failures.push({
|
|
8624
|
-
target,
|
|
8625
|
-
error: `could not re-enumerate the process group after SIGTERM: ${afterTerm.error}`
|
|
8626
|
-
});
|
|
8627
|
-
continue;
|
|
8628
|
-
}
|
|
8629
|
-
for (const pid of afterTerm.members) {
|
|
8630
|
-
try {
|
|
8631
|
-
process.kill(pid, "SIGKILL");
|
|
8632
|
-
} catch {
|
|
8633
|
-
}
|
|
8634
|
-
}
|
|
8635
|
-
const afterKill = processGroupMembers(pgid);
|
|
8636
|
-
if (!afterKill.ok) {
|
|
8637
|
-
result.failures.push({
|
|
8638
|
-
target,
|
|
8639
|
-
error: `could not confirm the process group is empty after SIGKILL: ${afterKill.error}`
|
|
8640
|
-
});
|
|
8641
|
-
continue;
|
|
8642
|
-
}
|
|
8643
|
-
if (afterKill.members.length)
|
|
8644
|
-
result.failures.push({
|
|
8645
|
-
target,
|
|
8646
|
-
error: `${afterKill.members.length} process(es) still in the group after SIGKILL`
|
|
8647
|
-
});
|
|
8648
|
-
else result.reaped.push(target);
|
|
9107
|
+
// Append a raw SDK message verbatim.
|
|
9108
|
+
write(msg) {
|
|
9109
|
+
this.line(msg);
|
|
9110
|
+
}
|
|
9111
|
+
// Append the outcome footer.
|
|
9112
|
+
close(outcome) {
|
|
9113
|
+
this.line({ type: "_outcome", ...outcome });
|
|
9114
|
+
}
|
|
9115
|
+
// CT1145: keep an independent copy of an empty successful envelope after the
|
|
9116
|
+
// footer has landed. The ordinary 200-file FIFO keeps rotating as before;
|
|
9117
|
+
// this bucket retains the newest 50 anomalies regardless of ordinary traffic.
|
|
9118
|
+
// Best-effort like every other transcript operation: observability can never
|
|
9119
|
+
// fail the turn.
|
|
9120
|
+
preserveAnomaly() {
|
|
9121
|
+
if (this.broken) return;
|
|
9122
|
+
try {
|
|
9123
|
+
const dir2 = join13(dirname6(this.path), "anomalies");
|
|
9124
|
+
mkdirSync8(dir2, { recursive: true, mode: 448 });
|
|
9125
|
+
chmodSync4(dir2, 448);
|
|
9126
|
+
const target = join13(dir2, basename(this.path));
|
|
9127
|
+
copyFileSync(this.path, target);
|
|
9128
|
+
chmodSync4(target, 384);
|
|
9129
|
+
pruneOld(dir2, ANOMALY_RETAIN);
|
|
9130
|
+
} catch (err) {
|
|
9131
|
+
this.fail(err);
|
|
8649
9132
|
}
|
|
8650
9133
|
}
|
|
8651
|
-
|
|
8652
|
-
|
|
8653
|
-
|
|
8654
|
-
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
if (kind === null || envKey !== probedEnvKey) {
|
|
8658
|
-
kind = probe(options.env) ? "systemd-scope" : "process-group";
|
|
8659
|
-
probedEnvKey = envKey;
|
|
8660
|
-
deps.log?.("containment: resolved strategy for this turn", {
|
|
8661
|
-
kind,
|
|
8662
|
-
platform: process.platform
|
|
8663
|
-
});
|
|
8664
|
-
}
|
|
8665
|
-
return kind === "systemd-scope" ? spawnInScope(options) : spawnInGroup(options);
|
|
8666
|
-
},
|
|
8667
|
-
async reap() {
|
|
8668
|
-
const result = {
|
|
8669
|
-
kind: kind ?? "unused",
|
|
8670
|
-
reaped: [],
|
|
8671
|
-
failures: [],
|
|
8672
|
-
unverified: []
|
|
8673
|
-
};
|
|
8674
|
-
await reapScopes(result);
|
|
8675
|
-
await reapGroups(result);
|
|
8676
|
-
deps.log?.("containment: reap complete", {
|
|
8677
|
-
kind: result.kind,
|
|
8678
|
-
reaped: result.reaped,
|
|
8679
|
-
failures: result.failures
|
|
8680
|
-
});
|
|
8681
|
-
return result;
|
|
9134
|
+
line(obj) {
|
|
9135
|
+
if (this.broken) return;
|
|
9136
|
+
try {
|
|
9137
|
+
appendFileSync(this.path, JSON.stringify(obj) + "\n", { mode: 384 });
|
|
9138
|
+
} catch (err) {
|
|
9139
|
+
this.fail(err);
|
|
8682
9140
|
}
|
|
8683
|
-
}
|
|
9141
|
+
}
|
|
9142
|
+
fail(err) {
|
|
9143
|
+
if (this.broken) return;
|
|
9144
|
+
this.broken = true;
|
|
9145
|
+
this.onWarn?.(`transcript write failed (${err instanceof Error ? err.message : String(err)})`);
|
|
9146
|
+
}
|
|
9147
|
+
};
|
|
9148
|
+
function fileName(meta) {
|
|
9149
|
+
const d = new Date(meta.ts);
|
|
9150
|
+
const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
9151
|
+
const slug = safe2(meta.workspaceSlug) || "ws";
|
|
9152
|
+
const short = meta.dispatchId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || "turn";
|
|
9153
|
+
return `${stamp}-${slug}-${short}.jsonl`;
|
|
9154
|
+
}
|
|
9155
|
+
function pad(n) {
|
|
9156
|
+
return String(n).padStart(2, "0");
|
|
9157
|
+
}
|
|
9158
|
+
function safe2(s) {
|
|
9159
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
9160
|
+
}
|
|
9161
|
+
function pruneOld(dir2, retain) {
|
|
9162
|
+
let files;
|
|
9163
|
+
try {
|
|
9164
|
+
files = readdirSync(dir2).filter((f) => f.endsWith(".jsonl"));
|
|
9165
|
+
} catch {
|
|
9166
|
+
return;
|
|
9167
|
+
}
|
|
9168
|
+
if (files.length <= retain) return;
|
|
9169
|
+
const drop = files.sort().slice(0, files.length - retain);
|
|
9170
|
+
for (const f of drop) {
|
|
9171
|
+
try {
|
|
9172
|
+
rmSync6(join13(dir2, f), { force: true });
|
|
9173
|
+
} catch {
|
|
9174
|
+
}
|
|
9175
|
+
}
|
|
8684
9176
|
}
|
|
8685
9177
|
|
|
8686
9178
|
// src/turn-committer.ts
|
|
@@ -9371,7 +9863,9 @@ var TurnExecution = class {
|
|
|
9371
9863
|
try {
|
|
9372
9864
|
await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
|
|
9373
9865
|
activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9374
|
-
|
|
9866
|
+
// CT1679: a runner leases under its launcher's boot id — the one the
|
|
9867
|
+
// device heartbeats — or the lost-run sweep reads it as a restart.
|
|
9868
|
+
bootId: this.opts.inRunner?.bootId ?? bootId,
|
|
9375
9869
|
// CT1380: the durable record that this turn resumed, and from where.
|
|
9376
9870
|
...this.resumedFromSeq !== void 0 ? { resumedFromSeq: this.resumedFromSeq } : {},
|
|
9377
9871
|
// CT1454: the issuance the context THIS attempt runs on came from. Read
|
|
@@ -9468,10 +9962,14 @@ var TurnExecution = class {
|
|
|
9468
9962
|
const { payload, workspaceId, turnId, turnLog } = this;
|
|
9469
9963
|
await guardBundledBinary(this.integrityContext(), (r, e) => this.concluded(r, e), this.outcome);
|
|
9470
9964
|
const onWarn = (msg, meta) => turnLog.debug(meta ?? {}, msg);
|
|
9965
|
+
const sandbox = this.opts.harnessSandbox ? await this.opts.harnessSandbox({ cwd: this.effectiveCwd, log: turnLog }) : null;
|
|
9471
9966
|
const adapters = [];
|
|
9472
9967
|
if (this.opts.claudeCodeAvailable?.() ?? true) {
|
|
9473
9968
|
this.containment = createTurnContainment(this.turnId, {
|
|
9474
|
-
log: (msg, meta) => turnLog.debug(meta ?? {}, msg)
|
|
9969
|
+
log: (msg, meta) => turnLog.debug(meta ?? {}, msg),
|
|
9970
|
+
// CT1679: a group outside the runner's own is reported, so a launcher
|
|
9971
|
+
// can stop it if the runner dies without reaping it.
|
|
9972
|
+
...this.opts.inRunner?.onProcessGroup ? { onProcessGroup: this.opts.inRunner.onProcessGroup } : {}
|
|
9475
9973
|
});
|
|
9476
9974
|
const containment = this.containment;
|
|
9477
9975
|
adapters.push(
|
|
@@ -9480,20 +9978,43 @@ var TurnExecution = class {
|
|
|
9480
9978
|
onWarn,
|
|
9481
9979
|
augmentOptions: (options) => ({
|
|
9482
9980
|
...options,
|
|
9483
|
-
spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(
|
|
9981
|
+
spawnClaudeCodeProcess: (spawnOptions) => containment.spawn(
|
|
9982
|
+
sandbox ? { ...spawnOptions, ...sandbox.wrap(spawnOptions.command, spawnOptions.args) } : spawnOptions
|
|
9983
|
+
)
|
|
9484
9984
|
})
|
|
9485
9985
|
})
|
|
9486
9986
|
);
|
|
9487
9987
|
}
|
|
9488
9988
|
if (this.opts.opencodeServerUrl) {
|
|
9489
|
-
|
|
9989
|
+
const serverUrl = this.opts.opencodeServerUrl;
|
|
9990
|
+
adapters.push(
|
|
9991
|
+
createOpencodeAdapter(
|
|
9992
|
+
// CT1679: the transport's per-server turn mutex is per process; in a
|
|
9993
|
+
// runner the turn also holds the device-wide lock every runner shares.
|
|
9994
|
+
this.opts.inRunner ? {
|
|
9995
|
+
transport: this.opts.inRunner.opencodeTransport(serverUrl),
|
|
9996
|
+
onWarn
|
|
9997
|
+
} : { serverUrl, onWarn }
|
|
9998
|
+
)
|
|
9999
|
+
);
|
|
9490
10000
|
}
|
|
9491
10001
|
if (this.opts.codexAvailable?.() ?? false) {
|
|
10002
|
+
const inRunner = this.opts.inRunner;
|
|
10003
|
+
const codexBinary = sandbox || inRunner ? (this.opts.resolveBundled ?? resolveBundledBinary)("codex").path : null;
|
|
10004
|
+
let codexPath = codexBinary && sandbox ? sandbox.executableFor(codexBinary) : codexBinary;
|
|
10005
|
+
if (codexPath && inRunner) {
|
|
10006
|
+
this.containment ??= createTurnContainment(this.turnId, {
|
|
10007
|
+
log: (msg, meta) => turnLog.debug(meta ?? {}, msg),
|
|
10008
|
+
...this.opts.inRunner?.onProcessGroup ? { onProcessGroup: this.opts.inRunner.onProcessGroup } : {}
|
|
10009
|
+
});
|
|
10010
|
+
codexPath = inRunner.codexExecutable(codexPath, this.containment);
|
|
10011
|
+
}
|
|
9492
10012
|
adapters.push(
|
|
9493
10013
|
createCodexAdapter({
|
|
9494
10014
|
enabled: true,
|
|
9495
10015
|
onWarn,
|
|
9496
|
-
writeInstructionsFile: writeCodexInstructionsFile
|
|
10016
|
+
writeInstructionsFile: writeCodexInstructionsFile,
|
|
10017
|
+
...codexPath ? { transport: createSdkCodexTransport({ codexPathOverride: codexPath }) } : {}
|
|
9497
10018
|
})
|
|
9498
10019
|
);
|
|
9499
10020
|
}
|
|
@@ -10030,6 +10551,24 @@ var Dispatcher = class {
|
|
|
10030
10551
|
notifyStart: (info) => this.notifyStart(info),
|
|
10031
10552
|
notifyEnd: (info) => this.notifyEnd(info)
|
|
10032
10553
|
};
|
|
10554
|
+
if (this.opts.runner) {
|
|
10555
|
+
return runTurnInRunner(
|
|
10556
|
+
this.opts,
|
|
10557
|
+
this.opts.runner,
|
|
10558
|
+
{
|
|
10559
|
+
registerAbort: supervisor.registerAbort,
|
|
10560
|
+
releaseAbort: supervisor.releaseAbort,
|
|
10561
|
+
sessionWriteNoticeClaimed: () => this.sessionWriteNotified.has(key),
|
|
10562
|
+
markSessionWriteNotice: () => {
|
|
10563
|
+
this.sessionWriteNotified.add(key);
|
|
10564
|
+
},
|
|
10565
|
+
notifyStart: supervisor.notifyStart,
|
|
10566
|
+
notifyEnd: supervisor.notifyEnd
|
|
10567
|
+
},
|
|
10568
|
+
payload,
|
|
10569
|
+
{ turnId: opts.turnId ?? randomUUID4(), resumed: opts.resumed === true }
|
|
10570
|
+
);
|
|
10571
|
+
}
|
|
10033
10572
|
return new TurnExecution(this.opts, supervisor, payload, opts).run();
|
|
10034
10573
|
}
|
|
10035
10574
|
// SJ383: cancel a specific (conversation, agent) run if one is in flight in
|
|
@@ -10048,6 +10587,176 @@ var Dispatcher = class {
|
|
|
10048
10587
|
}
|
|
10049
10588
|
};
|
|
10050
10589
|
|
|
10590
|
+
// src/providers/local-process.ts
|
|
10591
|
+
import { spawn as spawn6 } from "child_process";
|
|
10592
|
+
import { constants } from "os";
|
|
10593
|
+
var DEFAULT_KILL_GRACE_MS = 3e4;
|
|
10594
|
+
var DEFAULT_REAP_GRACE_MS = 3e3;
|
|
10595
|
+
function exitCode(code, signal) {
|
|
10596
|
+
if (code !== null) return code;
|
|
10597
|
+
const signum = signal ? constants.signals[signal] : void 0;
|
|
10598
|
+
return signum !== void 0 ? 128 + signum : 1;
|
|
10599
|
+
}
|
|
10600
|
+
function createLocalProcessProvider(opts = {}) {
|
|
10601
|
+
const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
10602
|
+
const spawnFn = opts.spawnFn ?? spawn6;
|
|
10603
|
+
const reapGraceMs = opts.reapGraceMs ?? DEFAULT_REAP_GRACE_MS;
|
|
10604
|
+
const killFn = opts.killFn ?? ((pid, signal) => process.kill(pid, signal));
|
|
10605
|
+
const groupAlive = (pgid) => {
|
|
10606
|
+
try {
|
|
10607
|
+
killFn(-pgid, 0);
|
|
10608
|
+
return true;
|
|
10609
|
+
} catch {
|
|
10610
|
+
return false;
|
|
10611
|
+
}
|
|
10612
|
+
};
|
|
10613
|
+
const signalGroup = (pgid, signal) => {
|
|
10614
|
+
try {
|
|
10615
|
+
killFn(-pgid, signal);
|
|
10616
|
+
} catch {
|
|
10617
|
+
}
|
|
10618
|
+
};
|
|
10619
|
+
return {
|
|
10620
|
+
name: "local-process",
|
|
10621
|
+
async acquire(attempt) {
|
|
10622
|
+
return { provider: "local-process", id: attempt.turnId };
|
|
10623
|
+
},
|
|
10624
|
+
exec(_handle, argv, env, { signal, stdin }) {
|
|
10625
|
+
const [command, ...args] = argv;
|
|
10626
|
+
if (!command) throw new Error("local-process: exec needs a command");
|
|
10627
|
+
const child = spawnFn(command, args, {
|
|
10628
|
+
env: { ...env },
|
|
10629
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
10630
|
+
detached: process.platform !== "win32"
|
|
10631
|
+
});
|
|
10632
|
+
child.stdin?.on("error", () => {
|
|
10633
|
+
});
|
|
10634
|
+
if (stdin) child.stdin?.write(stdin);
|
|
10635
|
+
const exit = new Promise((resolve2) => {
|
|
10636
|
+
let settled = false;
|
|
10637
|
+
const done = (code) => {
|
|
10638
|
+
if (settled) return;
|
|
10639
|
+
settled = true;
|
|
10640
|
+
signal.removeEventListener("abort", stop);
|
|
10641
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
10642
|
+
resolve2(code);
|
|
10643
|
+
};
|
|
10644
|
+
let forceTimer;
|
|
10645
|
+
const stop = () => {
|
|
10646
|
+
child.kill("SIGTERM");
|
|
10647
|
+
forceTimer = setTimeout(() => child.kill("SIGKILL"), killGraceMs);
|
|
10648
|
+
forceTimer.unref?.();
|
|
10649
|
+
};
|
|
10650
|
+
child.once("error", () => done(1));
|
|
10651
|
+
child.once("close", (code, sig) => done(exitCode(code, sig)));
|
|
10652
|
+
if (signal.aborted) stop();
|
|
10653
|
+
else signal.addEventListener("abort", stop, { once: true });
|
|
10654
|
+
});
|
|
10655
|
+
if (!child.stdout || !child.stderr || !child.stdin)
|
|
10656
|
+
throw new Error("local-process: runner stdio missing");
|
|
10657
|
+
const pid = child.pid;
|
|
10658
|
+
return {
|
|
10659
|
+
exit,
|
|
10660
|
+
...pid !== void 0 ? { pid } : {},
|
|
10661
|
+
stdout: child.stdout,
|
|
10662
|
+
stderr: child.stderr,
|
|
10663
|
+
stdin: child.stdin,
|
|
10664
|
+
async reap(groups) {
|
|
10665
|
+
if (process.platform === "win32") return;
|
|
10666
|
+
const targets = [.../* @__PURE__ */ new Set([...pid !== void 0 ? [pid] : [], ...groups])].filter(
|
|
10667
|
+
groupAlive
|
|
10668
|
+
);
|
|
10669
|
+
if (!targets.length) return;
|
|
10670
|
+
for (const pgid of targets) signalGroup(pgid, "SIGTERM");
|
|
10671
|
+
await new Promise((resolve2) => setTimeout(resolve2, reapGraceMs));
|
|
10672
|
+
for (const pgid of targets.filter(groupAlive)) signalGroup(pgid, "SIGKILL");
|
|
10673
|
+
}
|
|
10674
|
+
};
|
|
10675
|
+
},
|
|
10676
|
+
async release() {
|
|
10677
|
+
}
|
|
10678
|
+
};
|
|
10679
|
+
}
|
|
10680
|
+
|
|
10681
|
+
// src/runner/device-locks.ts
|
|
10682
|
+
var DeviceLocks = class {
|
|
10683
|
+
holders = /* @__PURE__ */ new Map();
|
|
10684
|
+
queues = /* @__PURE__ */ new Map();
|
|
10685
|
+
// `owner` is unique across the device; `grant` is called once, when it holds.
|
|
10686
|
+
request(key, owner, grant) {
|
|
10687
|
+
if (!this.holders.has(key)) {
|
|
10688
|
+
this.holders.set(key, owner);
|
|
10689
|
+
grant();
|
|
10690
|
+
return;
|
|
10691
|
+
}
|
|
10692
|
+
const queue = this.queues.get(key) ?? [];
|
|
10693
|
+
queue.push({ owner, grant });
|
|
10694
|
+
this.queues.set(key, queue);
|
|
10695
|
+
}
|
|
10696
|
+
// Gives back what `owner` holds and withdraws what it is waiting for.
|
|
10697
|
+
release(owner) {
|
|
10698
|
+
for (const [key, queue] of this.queues) {
|
|
10699
|
+
const rest = queue.filter((w) => w.owner !== owner);
|
|
10700
|
+
if (rest.length) this.queues.set(key, rest);
|
|
10701
|
+
else this.queues.delete(key);
|
|
10702
|
+
}
|
|
10703
|
+
for (const [key, holder] of [...this.holders]) {
|
|
10704
|
+
if (holder !== owner) continue;
|
|
10705
|
+
this.holders.delete(key);
|
|
10706
|
+
this.handOn(key);
|
|
10707
|
+
}
|
|
10708
|
+
}
|
|
10709
|
+
// What owners starting with `prefix` hold, by key — one runner's, when it
|
|
10710
|
+
// exits, so the launcher can decide which it may give back at once.
|
|
10711
|
+
heldBy(prefix) {
|
|
10712
|
+
return [...this.holders].filter(([, owner]) => owner.startsWith(prefix)).map(([key, owner]) => ({ key, owner }));
|
|
10713
|
+
}
|
|
10714
|
+
// Withdraws every request still waiting from owners starting with `prefix`,
|
|
10715
|
+
// leaving what they hold.
|
|
10716
|
+
withdraw(prefix) {
|
|
10717
|
+
for (const [key, queue] of this.queues) {
|
|
10718
|
+
const rest = queue.filter((w) => !w.owner.startsWith(prefix));
|
|
10719
|
+
if (rest.length) this.queues.set(key, rest);
|
|
10720
|
+
else this.queues.delete(key);
|
|
10721
|
+
}
|
|
10722
|
+
}
|
|
10723
|
+
holder(key) {
|
|
10724
|
+
return this.holders.get(key);
|
|
10725
|
+
}
|
|
10726
|
+
handOn(key) {
|
|
10727
|
+
const queue = this.queues.get(key);
|
|
10728
|
+
const next = queue?.shift();
|
|
10729
|
+
if (!queue?.length) this.queues.delete(key);
|
|
10730
|
+
if (!next) return;
|
|
10731
|
+
this.holders.set(key, next.owner);
|
|
10732
|
+
next.grant();
|
|
10733
|
+
}
|
|
10734
|
+
};
|
|
10735
|
+
|
|
10736
|
+
// src/runner/harness-sandbox.ts
|
|
10737
|
+
import { chmodSync as chmodSync5, mkdirSync as mkdirSync9, mkdtempSync, rmSync as rmSync7, writeFileSync as writeFileSync6 } from "fs";
|
|
10738
|
+
import { homedir as homedir3, tmpdir as tmpdir2, userInfo as userInfo2 } from "os";
|
|
10739
|
+
import { join as join15 } from "path";
|
|
10740
|
+
import { SandboxManager } from "@anthropic-ai/sandbox-runtime";
|
|
10741
|
+
function osSandboxUsable(log) {
|
|
10742
|
+
if (!SandboxManager.isSupportedPlatform()) {
|
|
10743
|
+
log.warn(
|
|
10744
|
+
{ platform: process.platform },
|
|
10745
|
+
'runners: sandbox "os" is not available on this platform; turns run unsandboxed'
|
|
10746
|
+
);
|
|
10747
|
+
return false;
|
|
10748
|
+
}
|
|
10749
|
+
const deps = SandboxManager.checkDependencies();
|
|
10750
|
+
if (deps.errors.length) {
|
|
10751
|
+
log.warn(
|
|
10752
|
+
{ errors: deps.errors },
|
|
10753
|
+
'runners: sandbox "os" is missing what it needs on this host; turns run unsandboxed'
|
|
10754
|
+
);
|
|
10755
|
+
return false;
|
|
10756
|
+
}
|
|
10757
|
+
return true;
|
|
10758
|
+
}
|
|
10759
|
+
|
|
10051
10760
|
// src/opencode-models.ts
|
|
10052
10761
|
var OPENCODE_RUNTIME = "opencode";
|
|
10053
10762
|
function mapOpencodeProviders(json) {
|
|
@@ -10127,14 +10836,14 @@ var DispatchClaims = class {
|
|
|
10127
10836
|
// src/outbox.ts
|
|
10128
10837
|
import {
|
|
10129
10838
|
existsSync as existsSync11,
|
|
10130
|
-
mkdirSync as
|
|
10839
|
+
mkdirSync as mkdirSync10,
|
|
10131
10840
|
readdirSync as readdirSync3,
|
|
10132
10841
|
readFileSync as readFileSync8,
|
|
10133
10842
|
renameSync as renameSync4,
|
|
10134
|
-
rmSync as
|
|
10135
|
-
writeFileSync as
|
|
10843
|
+
rmSync as rmSync8,
|
|
10844
|
+
writeFileSync as writeFileSync7
|
|
10136
10845
|
} from "fs";
|
|
10137
|
-
import { join as
|
|
10846
|
+
import { join as join16 } from "path";
|
|
10138
10847
|
var MAX_ENTRIES = 2e3;
|
|
10139
10848
|
var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
10140
10849
|
var Outbox = class {
|
|
@@ -10147,25 +10856,25 @@ var Outbox = class {
|
|
|
10147
10856
|
// Resolved lazily (per call, like cursor.ts) so tests that swap HOME between
|
|
10148
10857
|
// cases route writes at the right tmpdir.
|
|
10149
10858
|
dir() {
|
|
10150
|
-
return
|
|
10859
|
+
return join16(runnerStateDir(), "outbox", encodeURIComponent(this.workspaceId));
|
|
10151
10860
|
}
|
|
10152
10861
|
fileFor(turnId, seq) {
|
|
10153
|
-
return
|
|
10862
|
+
return join16(this.dir(), `${encodeURIComponent(turnId)}__${seq}.json`);
|
|
10154
10863
|
}
|
|
10155
10864
|
// Persist a commit for later draining. Atomic (temp file + rename) so a
|
|
10156
10865
|
// concurrent `list()` never reads a half-written entry, then enforces the
|
|
10157
10866
|
// per-workspace bounds.
|
|
10158
10867
|
persist(entry) {
|
|
10159
10868
|
const dir2 = this.dir();
|
|
10160
|
-
|
|
10869
|
+
mkdirSync10(dir2, { recursive: true });
|
|
10161
10870
|
const target = this.fileFor(entry.turnId, entry.seq);
|
|
10162
10871
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
10163
10872
|
try {
|
|
10164
|
-
|
|
10873
|
+
writeFileSync7(tmp, JSON.stringify(entry) + "\n", "utf8");
|
|
10165
10874
|
renameSync4(tmp, target);
|
|
10166
10875
|
} catch (err) {
|
|
10167
10876
|
try {
|
|
10168
|
-
|
|
10877
|
+
rmSync8(tmp, { force: true });
|
|
10169
10878
|
} catch {
|
|
10170
10879
|
}
|
|
10171
10880
|
this.log?.warn(
|
|
@@ -10192,7 +10901,7 @@ var Outbox = class {
|
|
|
10192
10901
|
const entries = [];
|
|
10193
10902
|
for (const name of names) {
|
|
10194
10903
|
if (!name.endsWith(".json")) continue;
|
|
10195
|
-
const full =
|
|
10904
|
+
const full = join16(dir2, name);
|
|
10196
10905
|
try {
|
|
10197
10906
|
const parsed = JSON.parse(readFileSync8(full, "utf8"));
|
|
10198
10907
|
if (parsed && typeof parsed.turnId === "string" && typeof parsed.seq === "number" && typeof parsed.path === "string") {
|
|
@@ -10242,7 +10951,7 @@ var Outbox = class {
|
|
|
10242
10951
|
// Remove a delivered (or terminally-discarded) entry. No-op if already gone.
|
|
10243
10952
|
remove(turnId, seq) {
|
|
10244
10953
|
try {
|
|
10245
|
-
|
|
10954
|
+
rmSync8(this.fileFor(turnId, seq), { force: true });
|
|
10246
10955
|
} catch {
|
|
10247
10956
|
}
|
|
10248
10957
|
}
|
|
@@ -10261,7 +10970,7 @@ var Outbox = class {
|
|
|
10261
10970
|
"A saved update is unreadable and couldn't be delivered. Check the conversation for missing replies."
|
|
10262
10971
|
);
|
|
10263
10972
|
try {
|
|
10264
|
-
|
|
10973
|
+
rmSync8(full, { force: true });
|
|
10265
10974
|
} catch {
|
|
10266
10975
|
}
|
|
10267
10976
|
}
|
|
@@ -10295,53 +11004,6 @@ var Outbox = class {
|
|
|
10295
11004
|
}
|
|
10296
11005
|
};
|
|
10297
11006
|
|
|
10298
|
-
// src/run-config.ts
|
|
10299
|
-
import { z as z15 } from "zod";
|
|
10300
|
-
var mcpStdioServerSchema = z15.object({
|
|
10301
|
-
type: z15.literal("stdio").optional(),
|
|
10302
|
-
command: z15.string().min(1),
|
|
10303
|
-
args: z15.array(z15.string()).optional(),
|
|
10304
|
-
env: z15.record(z15.string(), z15.string()).optional()
|
|
10305
|
-
});
|
|
10306
|
-
var mcpHttpServerSchema = z15.object({
|
|
10307
|
-
type: z15.literal("http"),
|
|
10308
|
-
url: z15.string().url(),
|
|
10309
|
-
headers: z15.record(z15.string(), z15.string()).optional()
|
|
10310
|
-
});
|
|
10311
|
-
var mcpSseServerSchema = z15.object({
|
|
10312
|
-
type: z15.literal("sse"),
|
|
10313
|
-
url: z15.string().url(),
|
|
10314
|
-
headers: z15.record(z15.string(), z15.string()).optional()
|
|
10315
|
-
});
|
|
10316
|
-
var mcpServerDefSchema = z15.union([mcpHttpServerSchema, mcpSseServerSchema, mcpStdioServerSchema]);
|
|
10317
|
-
var thinkingConfigSchema = z15.discriminatedUnion("type", [
|
|
10318
|
-
z15.object({ type: z15.literal("adaptive") }),
|
|
10319
|
-
z15.object({ type: z15.literal("enabled"), budgetTokens: z15.number().int().positive().optional() }),
|
|
10320
|
-
z15.object({ type: z15.literal("disabled") })
|
|
10321
|
-
]);
|
|
10322
|
-
var effortSchema = z15.enum(["low", "medium", "high", "xhigh", "max"]);
|
|
10323
|
-
var runConfigSchema = z15.object({
|
|
10324
|
-
// CT788: the host-access binary replaced the `assistant`/`coding`/`custom` mode
|
|
10325
|
-
// trio + its custom tool lists — `true` grants the host filesystem/shell, absent
|
|
10326
|
-
// is the locked surface. Kept in lockstep with `@cabane/shared`'s
|
|
10327
|
-
// `agentRunConfigSchema` (independent zod, forward-compatible: unknown keys are
|
|
10328
|
-
// stripped, so an older companion riding a newer server never rejects the config).
|
|
10329
|
-
hostAccess: z15.boolean().optional(),
|
|
10330
|
-
mcpServers: z15.record(z15.string(), mcpServerDefSchema).optional(),
|
|
10331
|
-
model: z15.string().min(1).optional(),
|
|
10332
|
-
thinking: thinkingConfigSchema.optional(),
|
|
10333
|
-
effort: effortSchema.optional()
|
|
10334
|
-
});
|
|
10335
|
-
var EMPTY_RUN_CONFIG = {};
|
|
10336
|
-
function parseRunConfig(raw, onWarn) {
|
|
10337
|
-
const result = runConfigSchema.safeParse(raw ?? {});
|
|
10338
|
-
if (result.success) return result.data;
|
|
10339
|
-
onWarn?.(
|
|
10340
|
-
`run-config from Cabane didn't parse (${result.error.issues.map((i) => i.path.join(".") || "(root)").join(", ")}) \u2014 falling back to the locked assistant default`
|
|
10341
|
-
);
|
|
10342
|
-
return EMPTY_RUN_CONFIG;
|
|
10343
|
-
}
|
|
10344
|
-
|
|
10345
11007
|
// src/sse.ts
|
|
10346
11008
|
import { createParser } from "eventsource-parser";
|
|
10347
11009
|
var MAX_BACKOFF_MS = 3e4;
|
|
@@ -10506,6 +11168,8 @@ var CompanionSupervisor = class {
|
|
|
10506
11168
|
exitFn;
|
|
10507
11169
|
reexecFn;
|
|
10508
11170
|
dispatcherFactory;
|
|
11171
|
+
runnerCommand;
|
|
11172
|
+
providerForRunners;
|
|
10509
11173
|
unrunnableRetryDelaysMs;
|
|
10510
11174
|
deviceApi = null;
|
|
10511
11175
|
deviceSub = null;
|
|
@@ -10550,6 +11214,8 @@ var CompanionSupervisor = class {
|
|
|
10550
11214
|
this.exitFn = opts.exit ?? ((code) => process.exit(code));
|
|
10551
11215
|
this.reexecFn = opts.reexec ?? defaultReexec;
|
|
10552
11216
|
this.dispatcherFactory = opts.dispatcherFactory;
|
|
11217
|
+
if (opts.runnerCommand) this.runnerCommand = opts.runnerCommand;
|
|
11218
|
+
if (opts.runnerProvider) this.providerForRunners = opts.runnerProvider;
|
|
10553
11219
|
this.unrunnableRetryDelaysMs = opts.unrunnableRetryDelaysMs ?? DEFAULT_UNRUNNABLE_RETRY_DELAYS_MS;
|
|
10554
11220
|
this.latchBootBundledState();
|
|
10555
11221
|
}
|
|
@@ -10995,6 +11661,21 @@ var CompanionSupervisor = class {
|
|
|
10995
11661
|
);
|
|
10996
11662
|
}
|
|
10997
11663
|
}
|
|
11664
|
+
// CT1679: the locks every runner on this device shares, held here.
|
|
11665
|
+
runnerLocks = new DeviceLocks();
|
|
11666
|
+
// CT1679: `runners.sandbox`, checked against this host once. A host that
|
|
11667
|
+
// can't make the sandbox says so once and runs its turns unwrapped.
|
|
11668
|
+
sandboxUsable;
|
|
11669
|
+
runnerSandbox() {
|
|
11670
|
+
if (this.config.runners?.sandbox !== "os") return "none";
|
|
11671
|
+
this.sandboxUsable ??= osSandboxUsable(this.log);
|
|
11672
|
+
return this.sandboxUsable ? "os" : "none";
|
|
11673
|
+
}
|
|
11674
|
+
// CT1679: one provider for the device, built on first use.
|
|
11675
|
+
runnerProvider() {
|
|
11676
|
+
this.providerForRunners ??= createLocalProcessProvider();
|
|
11677
|
+
return this.providerForRunners;
|
|
11678
|
+
}
|
|
10998
11679
|
buildDispatcher(ctx) {
|
|
10999
11680
|
const local = localAgentConfig(this.config, {
|
|
11000
11681
|
agentId: ctx.agentId,
|
|
@@ -11045,7 +11726,17 @@ var CompanionSupervisor = class {
|
|
|
11045
11726
|
observer: this.hub.observerFor(ctx.workspaceId, ctx.agentId, ctx.workspaceSlug),
|
|
11046
11727
|
transcriptDir: transcriptsDir(),
|
|
11047
11728
|
// CT566: the shared connector-health memory the turn records into on settle.
|
|
11048
|
-
connectorHealth: this.connectorHealth
|
|
11729
|
+
connectorHealth: this.connectorHealth,
|
|
11730
|
+
...runnersEnabled(this.config) ? {
|
|
11731
|
+
runner: {
|
|
11732
|
+
provider: this.runnerProvider(),
|
|
11733
|
+
command: this.runnerCommand ?? selfRunnerCommand(),
|
|
11734
|
+
...this.config.deviceToken ? { deviceToken: this.config.deviceToken } : {},
|
|
11735
|
+
connectorHealth: this.connectorHealth,
|
|
11736
|
+
locks: this.runnerLocks,
|
|
11737
|
+
sandbox: this.runnerSandbox()
|
|
11738
|
+
}
|
|
11739
|
+
} : {}
|
|
11049
11740
|
});
|
|
11050
11741
|
}
|
|
11051
11742
|
async removeWorkspace(workspaceId) {
|
|
@@ -11336,7 +12027,7 @@ var CompanionSupervisor = class {
|
|
|
11336
12027
|
);
|
|
11337
12028
|
}
|
|
11338
12029
|
const resumedTurnId = recoveredTurnId ?? (dispatchId ? turnIdForEvent(workspaceId, dispatchId) : null);
|
|
11339
|
-
const turnId = resumedTurnId ??
|
|
12030
|
+
const turnId = resumedTurnId ?? randomUUID5();
|
|
11340
12031
|
if (dispatchId) {
|
|
11341
12032
|
const liveTurnIds = rememberTurnId(workspaceId, dispatchId, turnId);
|
|
11342
12033
|
if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
|
|
@@ -11641,9 +12332,9 @@ async function waitForBoundedHeartbeat(heartbeat) {
|
|
|
11641
12332
|
}
|
|
11642
12333
|
function defaultReexec() {
|
|
11643
12334
|
clearRuntimeState();
|
|
11644
|
-
void import("child_process").then(({ spawn:
|
|
12335
|
+
void import("child_process").then(({ spawn: spawn7 }) => {
|
|
11645
12336
|
try {
|
|
11646
|
-
const child =
|
|
12337
|
+
const child = spawn7(process.execPath, process.argv.slice(1), {
|
|
11647
12338
|
stdio: "inherit",
|
|
11648
12339
|
detached: false
|
|
11649
12340
|
});
|
|
@@ -11713,22 +12404,22 @@ function handleUncaught(log, err, origin) {
|
|
|
11713
12404
|
}
|
|
11714
12405
|
|
|
11715
12406
|
// src/crash-marker.ts
|
|
11716
|
-
import { existsSync as existsSync12, mkdirSync as
|
|
11717
|
-
import { join as
|
|
12407
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync9, writeFileSync as writeFileSync8 } from "fs";
|
|
12408
|
+
import { join as join17 } from "path";
|
|
11718
12409
|
function crashMarkerPath() {
|
|
11719
|
-
return
|
|
12410
|
+
return join17(cabaneDir(), "last-error.json");
|
|
11720
12411
|
}
|
|
11721
12412
|
function recordCrash(rec) {
|
|
11722
12413
|
try {
|
|
11723
|
-
|
|
11724
|
-
|
|
12414
|
+
mkdirSync11(cabaneDir(), { recursive: true });
|
|
12415
|
+
writeFileSync8(crashMarkerPath(), JSON.stringify(rec, null, 2) + "\n");
|
|
11725
12416
|
} catch {
|
|
11726
12417
|
}
|
|
11727
12418
|
}
|
|
11728
12419
|
function clearCrash() {
|
|
11729
12420
|
try {
|
|
11730
12421
|
const path = crashMarkerPath();
|
|
11731
|
-
if (existsSync12(path))
|
|
12422
|
+
if (existsSync12(path)) rmSync9(path, { force: true });
|
|
11732
12423
|
} catch {
|
|
11733
12424
|
}
|
|
11734
12425
|
}
|
|
@@ -11771,7 +12462,7 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
11771
12462
|
opencodeServerUrl: cfg.opencode?.serverUrl,
|
|
11772
12463
|
codex: isCodexEnabled(cfg)
|
|
11773
12464
|
});
|
|
11774
|
-
const instanceId =
|
|
12465
|
+
const instanceId = randomUUID6();
|
|
11775
12466
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
11776
12467
|
const pre = readLiveRuntimeState();
|
|
11777
12468
|
if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
|